方法调用

方法调用就3块,解析,分派,动态语言支持(这块读者可以自行百度,这篇暂且不表)。

解析

所有方法调用中的目标方法在 Class 文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution)。

在 Java 语言中符合 “编译期可知,运行期不可变” 这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。

与之相对应的是,在 Java 虚拟机里面提供了 5 条方法调用字节码指令,分别如下。

  • invokestatic:调用静态方法。
  • invokespecial:调用实例构造器 <init> 方法、私有方法和父类方法。
  • invokevirtual::调用所有的虚方法。
  • invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
  • invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,再次之前的 4 条调用指令,分派逻辑是固化在 Java 虚拟机内部的,而 invokedynamic 指令的分配逻辑是由用户所设定的引导方法决定的。

只要能被 invokestatic 和 invokespecial 指令调用的方法,都可以在解析阶段确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法 4 类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以称为非虚方法。与之相反,其他方法称为虚方法(除去 final 方法,后文会提到)。代码清单 8-5 演示了一个最常见的解析调用的例子,此样例中,静态方法 sayHello() 只可能属于类型 StaticResolution,没有任何手段可以覆盖或隐藏这个方法。

Java 中的非虚方法除了使用 invokestatic、invokespecial 调用的方法之外还有一种,就是被 final 修饰的方法。虽然 final 方法是使用 invokevirtual 指令来调用的,但是由于它无法被覆盖,没有其他版本,所以也无须对方法接收者进行多态选择,又或者说多态选择的结果肯定是唯一的。在 Java 语言规范中明确说明了 final 方法是一种非虚方法。

解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派。这两类分派方式的两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派 4 种分派组合情况。

分派

众所周知,Java 是一门面向对象的程序语言,因为 Java 具备面向对象的 3 个基本特征:继承、封装和多态。本节讲解的分派调用过程将会揭示多态性特征的一些最基本的体现,如 “重载” 和 “重写” 在 Java 虚拟机之中是如何实现的,这里的实现当然不是语法上该如何写,我们关心的依然是虚拟机如何确定正确的目标方法。

1.静态分派

静态分派(严格来说,Dispatch 这个词一般不用再静态环境中,英文技术文档的称呼是 “Method Overload Resolution”,但国内的各种资料都普遍将这种行为翻译成 “静态分派”,特此说明)前,笔者准备了一段经常出现在面试题中的程序代码,读者不妨先看一遍,想一下程序的输出结果是什么。后面我们的话题将围绕这个类的方法来重载(Overload)代码,以分析虚拟机和编译器确定方法版本的过程。方法静态分派如代码清单 8-6 所示。

代码清单 8-6 方法静态分派演示


public class StaticDispatch {

    static abstract class Human {
        
    }
    
    static class Man extends Human {
        
    }
    
    static class Woman extends Human {
        
    }
    
    public void sayHello(Human guy) {
         System.out.println("hello, guy!");
    }
    
    public void sayHello(Man guy) {
        System.out.println("hello,gentleman");
    }
    
    public void sayHello(Woman guy) {
        System.out.println("hello,lady!");
    }
    
    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        
        StaticDispatch sr = new StaticDispatch();
        sr.sayHello(man);
        sr.sayHello(woman);
    }
}
    运行结果:
image
  1. Human man = new Man();

我们把上面代码中的 “Human” 称为变量的静态类型(Static Type),或者叫做外观类型(Apparent Type),后面的 “Man” 则称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。

回到代码清单 8-6 的样例代码中。main() 里面的两次 sayHello() 方法调用,在方法接收者已经确定是对象 “sr” 的前提下,使用哪个重载版本,就完全取决于传入擦你数的数量和数据类型。代码中刻意地定义了两个静态类型相同但实际类型不同的变量,但虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,因此,在编译阶段,javac 编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了 sayHello(Human) 作为调用目标,并把这个方法的符号引用写到 main() 方法里的两条 invokevirtual 指令的参数中。

所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是 “唯一的”,往往只能确定一个 “更加适合的” 版本。这种模糊的结论在由 0 和 1 构成的计算机世界中算是比较 “稀罕” 的事情,产生这种模糊结论的主要原因是字面量不需要定义,所以字面量没有显式的静态类型,它的静态类型只能通过语言上的规则去理解和推断。代码清单 8-7 演示了何为 “更加适合的” 版本。

代码清单 8-7 重载方法匹配优先级


public class Overload {

    public static void sayHello(Object arg) {
        System.out.println("hello Object");
    }
    
    public static void sayHello(int arg) {
        System.out.println("hello int");
    }
    
    public static void sayHello(long arg) {
        System.out.println("hello long");
    }
    
    public static void sayHello(Character arg) {
        System.out.println("hello Character");
    }
    
    public static void sayHello(char arg) {
        System.out.println("hello char");
    }
    
    public static void sayHello(char... arg) {
        System.out.println("hello char ...");
    }
    
    public static void sayHello(Serializable arg) {
        System.out.println("hello Serializable");
    }
    
    public static void main(String[] args) {
        sayHello('a');
    }
}

上面的代码运行后会输出:

  1. hello char

    这很好理解,'a' 是一个 char 类型的数据,自然会寻找参数类型为 char 的重载方法,如果注释掉 sayHello(char arg) 方法,那输出会变为:
    
  2. hello int

    这时发生了一次自动类型转换,'a' 除了可以代表一个字符串,还可以代表数字 97(字符 'a' 的 Unicode 数值为十进制数字 97),因此参数类型为 int 的重载也是合适的。我们继续注释掉 sayHello(int arg) 方法,那输出会变为:
    
  3. hello long

    这时发生了两次自动类型转换,'a' 转型为整型 97 之后,进一步转型为长整数 97L,匹配了参数类型为 long 的重载。笔者在代码中没有写其他的类型如 float、double 等的重载,不过实际上自动转型还能继续发生多次,按照 char -> int -> long -> float -> double 的顺序转型进行匹配。但不会匹配到 byte 和 short 类型的重载,因为 char 到 byte 或 short 的转型是不安全的。我们继续注释掉 sayHello(long arg) 方法,那输出会变为:
    
  4. hello Character

    这时发生了一次自动装箱,'a' 被包装为它的封装类型 java.lang.Character,所以匹配到了参数类型为 Character 的重载,继续注释掉 sayHello(Character arg) 方法,那输出会变为:
    
  5. hello Serializable

    这个输出可能会让人感觉摸不着头脑,一个字符或数字与序列化有什么关系?出现 hello Serializable,是因为 java.lang.Serializable 是 java.lang.Character 类实现的一个接口,当自动装箱之后发现还是找不到装箱类,但是找到了装箱类实现了的接口类型,所以紧接着又发生一次自动转型。char 可以转型成 int,但是 Character 是绝对不会转型为 Integer 的,它只能安全地转型为它实现的接口或父类。Character 还实现了另外一个接口 java.lang.Comparable<Character>,如果同时出现两个参数分别为 Serializable 和 Comparable<Character> 的重载方法,那它们在此时的优先级是一样的。编译器无法确定要自动转型为哪种类型,会提示类型模糊,拒绝编译。程序必须在调用时显式地指定字面量的静态类型,如:sayHello((Comparable<Character>'a'),才能编译通过。下面继续注释掉 sayHello(Serializable arg) 方法,输出会变为:
    
  6. hello Object

    这时是 char 装箱后转型为父类了,如果有多个父类,那将在继承关系中从下往上开始搜索,越接近上层的优先级越低。即使方法调用传入的才参数值为 null 时,这个规则仍然适用。我们把 sayHello(Object arg) 也注释掉,输出将会变为:
    
  7. hello char ...

7 个重载方法已经被注释得只剩一个了,可见变长参数的重载优先级是最低的,这时候字符 'a' 被当做了一个数组元素。笔者适用的是 char 类型的变长参数,读者在验证时还可以选择 int 类型、Character 类型、Object 类型等变长参数重载来把上面的过程重新演示一遍。

2.动态分派

了解了静态分派,我们接下来看一下动态分派的过程,它和动态性的另外一个重要体现(注:有一种观点认为:因为重载是静态的,重写是动态的,所以只有重写算是多态性的体现,重载不算多态。笔者认为这种整理没有意义,概念仅仅是说明问题的一种工具而已)——重写(Override)有着密切的关联。我们还是用前面的 Man 和 Woman 一起 sayHello 的例子来讲解动态分派,请看代码清单 8-8 中所示的代码。

代码清单 8-8 方法动态分派演示


public class DynamicDispatch {

    static abstract class Human {
        protected abstract void sayHello();
    }
    
    static class Man extends Human {
        @Override
        protected void sayHello() {
            System.out.println("man say hello");
        }
    }
    
    static class Woman extends Human {
        @Override
        protected void sayHello() {
            System.out.println("woman say hello");
        }
    }
    
    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        man.sayHello();
        woman.sayHello();
        man = new Woman();
        man.sayHello();
    }
}

运行结果:

  1. man say hello
  2. woman say hello
  3. woman say hello

这个运行结果相信不会出乎任何人的意料,对于习惯了面向对象思维的 Java 程序员会觉得这是完全理所当然的。现在的问题还是和前面的一样,虚拟机是如何知道要调用哪个方法的?

    虽然这里不可能再根据静态类型来决定,因为静态类型同样都是 Human 的两个变量 man 和 woman 在调用 sayHello() 方法时执行了不同的行为,并且变量 man 在两次调用中执行了不同的方法。导致这个现象的原因很明显,是这两个变量的实际类型不同,Java 虚拟机是如何根据实际类型来分派方法执行版本的呢?我们使用 javap 命令输出这段代码的字节码,尝试从中寻找答案,输出结果如代码清单 8-9 所示。

代码清单 8-9 main() 方法的字节码

1.  public static void main(java.lang.String[]);  
2.  flags: ACC_PUBLIC, ACC_STATIC  
3.  Code:  
4.  stack=2, locals=3, args_size=1  
5.  0: new           #16                 // class org/fenixsoft/polymorphic/DynamicDispatch$Man  
6.  3: dup  
7.  4: invokespecial #18                 // Method org/fenixsoft/polymorphic/DynamicDispatch$Man."<init>":()V  
8.  7: astore_1  
9.  8: new           #19                 // class org/fenixsoft/polymorphic/DynamicDispatch$Woman  
10.  11: dup  
11.  12: invokespecial #21                 // Method org/fenixsoft/polymorphic/DynamicDispatch$Woman."<init>":()V  
12.  15: astore_2  
13.  16: aload_1  
14.  17: invokevirtual #22                 // Method org/fenixsoft/polymorphic/DynamicDispatch$Human.sayHello:()V  
15.  20: aload_2  
16.  21: invokevirtual #22                 // Method org/fenixsoft/polymorphic/DynamicDispatch$Human.sayHello:()V  
17.  24: new           #19                 // class org/fenixsoft/polymorphic/DynamicDispatch$Woman  
18.  27: dup  
19.  28: invokespecial #21                 // Method org/fenixsoft/polymorphic/DynamicDispatch$Woman."<init>":()V  
20.  31: astore_1  
21.  32: aload_1  
22.  33: invokevirtual #22                 // Method org/fenixsoft/polymorphic/DynamicDispatch$Human.sayHello:()V  
23.  36: return  

0 ~ 15 行的字节码是准备动作,作用是建立 man 和 woman 的内存空间、调用 Man 和 Woman 类型的实例构造器,将这两个实例的引用存放在第 1、2 个局部变量表 Slot 之中,这个动作也就对应了代码中的这两句:

  1. Human man = new Man();
  2. Human woman = new Woman();

接下来的 16 ~ 21 句是关键部分、16、20 两句分别把刚刚创建好的两个对象的引用压入到栈顶,这两个对象是将要执行的sayHello() 方法的所有者,称为接收者 (Receiver);17 和 21 句是方法调用指令,这两条调用指令但从字节码角度来看,无论是指令(都是 invokevirtual)还是参数(都是常量池中第 22 项的常量,注释显示了这个常量是 Human.sayHello() 的符号引用)完全一样的,但是这两句指令最终执行的目标方法并不相同。原因就需要从 invokevirtual 指令的多态查找过程开始说起,invokevirtual 指令的运行时解析过程大致分为以几个步骤:

  1. 找到操作数栈顶的第一个元素所执行的对象的实际类型,记作 C。
  2. 如果在类型 C 中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回 java.lang.IllegalAccessError 异常。
  3. 否则,按照继承关系从下往上一次对 C 的各个父类进行第 2 步的搜索和验证过程。
  4. 如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodError 异常。

由于 invokevirtual 指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java 语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

虚拟机动态分派的实现

前面介绍的分派过程,作为对虚拟机概念模型的解析基本上已经足够了,它已经解决了虚拟机在分派中 “会做什么” 这个问题。但是虚拟机 “具体是如何做到的”,可能各种虚拟机的实现都会有些差别。

由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索。面对这种情况,最常用的 “稳定优化” 手段就是为类在方法区中建立一个虚方法表(Virtual Method Table,也称为 vtable,与此对应的,在 invokeinterface 执行时也会用到接口方法表——Interface Method Table,简称 itable),使用虚方法表索引来代替元数据查找以提高性能。我们先看看但清单 8-10 所对应的虚方法表结构示例,如图 8-3 所示。

image

虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的;都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。图 8-3 中,Son 重写了来自 Father 的全部方法,因此 Son 的方法表没有指向 Father 类型数据的箭头。但是 Son 和 Father 都没有重写来自 Object 的方法,所以它们的方法表中所有从 Object 继承来的方法都指向了 Object 的数据类型。

为了程序实现上的方便,具有相同签名的方法,在父类、子类的虚方法表中都应该具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。

方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。

上文中笔者说方法表是分派调用的 “稳定优化” 手段,虚拟机除了使用方法表之外,在条件允许的情况下,还会使用内联缓存(Inline Cache)和基于 “类型继承关系分析”(Class Hierarchy Analysis,CHA)技术的守护内联(Guarded Inlining)两种非稳定的 “激进优化” 手段来获得更高的性能。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容