专栏原创出处:github-源笔记文件 (opens new window)github-源码 (opens new window),欢迎 Star,转载请附上原文出处链接和本声明。

Java JVM-虚拟机专栏系列笔记,系统性学习可访问个人复盘笔记-技术博客 Java JVM-虚拟机 (opens new window)

# 一、前言

方法调用并不等同于方法中的代码被执行,Class 文件里面存储的都只是符号引用。

这个特性给 Java 带来了更强大的动态扩展能力,某些调用需要在类加载期间,某些到运行期间才能确定目标方法的直接引用。

本节内容基于字节码分析 Java 中重载和重写的实现过程。

# 二、方法调用字节码指令

在 Java 虚拟机支持以下 5 条方法调用字节码指令

  • invokestatic:用于调用静态方法

  • invokespecial:用于调用实例构造器 <init>()方法、私有方法和父类中的方法

  • invokevirtual:用于调用所有的虚方法。

  • invokeinterface:用于调用接口方法,会在运行时再确定一个实现该接口的对象

  • invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。前面 4 条调用指令,分派逻辑都固化在 Java 虚拟机内部,而 invokedynamic 指令的分派逻辑是由用户设定的引导方法来决定的。

# 三、非虚方法与虚方法

  1. 非虚方法

有静态方法、私有方法、实例构造器、父类方法 4 种,再加上被 final 修饰的方法(尽管它使用 invokevirtual 指令调用),它们在类加载的时候就可以把符号引用解析为该方法的直接引用。

  1. 虚方法

与非虚方法相反的方法。在运行期间才能确定具体方法的版本。

# 四、静态分派与重载

public class StaticDispatch {
    
    interface Human { }
    static class Man implements Human { }
    static class Woman implements Human { }

    public void sayHello(Human guy) {
        System.out.println("Human sayHello");
    }

    public void sayHello(Man guy) {
        System.out.println("Man sayHello");
    }

    public static void main(String[] args) {
        final Human human = new Man();
        final StaticDispatch dispatch = new StaticDispatch();

        dispatch.sayHello(human); // Human sayHello
        dispatch.sayHello((Man) human); // Man sayHello
    }
}

针对上述代码,我们把 Human 称为「静态类型」,Man/Woman 称为「运行时类型」

  • 静态类型在编译期间是可知的
  • 运行时类型在运行期间才能确定,编译器在编译是并不知道 human 是个什么类型
  • 运行时类型在运行时可以通过 cast 转换为父类,因此类型会改变。

因此在编译期间,方法重载时,根据方法参数静态类型决定调用哪个方法。

main 方法的部分字节码如下:

 17: aload_1
 18: invokevirtual #10 // 确定调用 sayHello:(LStaticDispatch$Human;)V
 21: aload_2
 22: aload_1
 23: checkcast     #6  强转类型 StaticDispatch$Man
 26: invokevirtual #11 // 确定调用 sayHello:(LStaticDispatch$Man;)V
 29: return

总结:

编译阶段,依赖静态类型来决定方法执行版本的分派过程,都称为「静态分派」。静态分派的最典型应用表现就是方法重载。

注意:

如果我们把 sayHello(Man guy)方法注释后,程序依然可以编译运行,最终 2 个方法全部调用 sayHello(Human guy),因为重载的版本不是唯一的,在这种模糊的情况下,编译器会选择一个更合适的版本。 实际编码中我们应该避免这种情况发生。

# 五、动态分派与重写

public class DynamicDispatch {
    
    interface Human { 
        void sayHello();
    }

    static class Man implements Human {
        @Override public void sayHello() {
            System.out.println("Man sayHello");
        }
    }

    static class Woman implements Human {
        @Override public void sayHello() {
            System.out.println("Woman sayHello");
        }
    }

    public static void main(String[] args) {
        Human man = new Man();
        final Human woman = new Woman();

        man.sayHello(); // Man sayHello
        woman.sayHello(); // Woman sayHello

        man = new Woman();
        man.sayHello(); // Woman sayHello
    }
}

main 方法的部分字节码如下:

 15: astore_2
 16: aload_1
 17: invokeinterface #6,1 // InterfaceMethod DynamicDispatch$Human.sayHello:()V
 22: aload_2
 23: invokeinterface #6,1 // InterfaceMethod DynamicDispatch$Human.sayHello:()V
 28: new             #4   // class DynamicDispatch$Woman
 31: dup
 32: invokespecial   #5   // Method DynamicDispatch$Woman."<init>":()V
 35: astore_1
 36: aload_1
 37: invokeinterface #6,1 // InterfaceMethod DynamicDispatch$Human.sayHello:()V
 42: return

分析字节码中 invokeinterface 指令即为确定方法调用版本的关键点。

关于 invokeinterface 指令执行过程,假设 C 为 ref 类型,实际调用的方法按照下列过程查找:

  1. 如果 C 包含一个和要解析的方法名称及描述符一样的方法声明,则该声明即为要调用的方法,查找结束。

  2. 否则,如果 C 有父类,则在其父类及父类的父类中递归执行第一步中的查找。

  3. 如果还未找到,则抛出 AbstractMethodError 错误。

总结:

我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为「动态分派」。这个过程就是方法重写的本质。

# 六、一个复杂的劣质题再理解重写重载

我们看一个示例:

  • 水果接口 Fruit 有 Apple、Orange 的实现
  • Father 里面的方法是重载的
  • Son 继承了 Father,重写了方法
public class Dispatch {

    interface Fruit { }

    static class Apple implements Fruit { }

    static class Orange implements Fruit { }

    static class Father {

        public void eat(Fruit o) {
            System.out.println("Father.Fruit");
        }

        public void eat(Apple o) {
            System.out.println("Father.Apple");
        }

        public void eat(Orange o) {
            System.out.println("Father.Orange");
        }
    }

    static class Son extends Father {
        public void eat(Fruit o) {
            System.out.println("Son.Fruit");
        }

        @Override public void eat(Apple apple) {
            System.out.println("Son.Apple");
        }

        @Override public void eat(Orange apple) {
            System.out.println("Son.Orange");
        }
    }

    public static void main(String[] args) {
        final Fruit fruit = new Apple();
        final Fruit fruitImpl = new Fruit() {};
        final Apple apple = new Apple();
        final Orange orange = new Orange();

        final Father father = new Father();
        final Father son = new Son();

        father.eat(fruit);
        father.eat(fruitImpl);
        father.eat(apple);

        son.eat(fruit);
        son.eat(fruitImpl);
        son.eat(orange);
    }
}

面对最终调用的 6 个方法,我们分析思路为:

  • 编译时,哪个类调用哪个重载方法「静态分派过程」。
    因为静态分派主要参考「静态类型」,我们可以得出一个范式 静态类型.eat(静态类型 o)

  • 运行期间,到底执行哪个版本的方法,属于「动态分派过程」。
    动态分派根据运行期实际类型查找,son 调用时,先找解析的方法名称及描述符一样的方法声明,如果没有在从父类 father 找。

最终答案是:

Father.Fruit
Father.Fruit
Father.Apple
Son.Fruit
Son.Fruit
Son.Orange

# 总结

  • 「非虚方法」:类加载过程中可以解析出直接引用的方法(静态方法、私有方法、实例构造器、父类方法 4 种,final 修饰的方法)

  • 「非虚方法」:运行期间才可以确定执行版本的方法

  • 「静态分派」:编译阶段,依赖静态类型来决定方法执行版本的分派过程

  • 「静态分派」:最典型应用表现就是方法「重载」

  • 「动态分派」:运行期间,根据实际类型确定方法执行版本的分派过程

  • 「动态分派」:最典型应用表现就是方法「重写」

本内容所有源码可参考 github (opens new window)

# 参考

# 本专栏更多相关笔记

最后修改时间: 2/17/2020, 4:43:04 AM