方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(即直接引用)。这个特性需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
一、解析调用
在类加载的解析阶段,会将Class文件里面的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序运行之前就有一个可确定的调用版本,并且这个调用版本在运行期是不可改变的。即调用目标在程序代码写好、编译器进行编译时就必须确定下来,这种方法的调用就称为解析。
符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类。前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本。
与之相对应的,在java虚拟中提供了5条方法调用字节码指令:
只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法4类。它们在类加载的时候就会把符号引用解析为该方法的直接引用,这些方法称为非虚方法,由于final方法不能被覆盖,也属于非虚方法,其他方法就称为虚方法。
二、分派调用
1、静态分派(发生在编译时期)
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。
下面看这段代码:
可能你会问,为什么会选择参数类型为Human的重载呢。在解决这个问题之前,需要明确两个概念:
Human man = new Man();
上面这行代码中的 "Human"称为变量的静态类型(Static Type),或者叫做外观类型(Apparent Type),后面的“Man”则称为变量的实际类型(Actual Type)。
静态类型和实际类型的区别在于:静态类型的变化仅仅是在使用时发生变化,变量本身的静态类型不会发生改变,并且最终的静态类型是在编译期可知;而实际类型变化的结果在运行期才可确定。例如下面代码:
虚拟机在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,因此,在编译期,Javac 编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了 sayHello(Human),并把这个方法的符号引用写入 main() 方法的两条 invokevirtual 指令的参数中。
2、动态分派(发生在运行时期)
运行期根据实际类型确定方法执行版本的分派过程称为动态分派。它和重写(Override)有着很密切的关联。
还是sayHello的例子。
通过javap输出这段代码的字节码:
16~21句是关键部分,16、20两句将创建的两个对象的引用压到栈顶,这两个对象是将要执行的 sayHello() 方法的所有者,称为接受者(Receiver);17、21句是方法调用指令,但是这两条指令的最终执行的目标方法并不相同。原因就需要从 invokevirtual 指令的动态查找过程开始说起, invokevirtual 指令的运行时解析过程大致分为以下几个步骤:
1、找到操作数栈顶的第一个元素所指向的对象的实际类型,记做 C。
2、如果在类型 C 中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验。如果通过,则返回这个方法的直接引用,查找过程结束:如果不通过,则返回 java.lang.IllegalAccessError 异常。
3、否则,按照继承关系从下往上依次对C 的各个父类进行第2步的搜索和验证过程。
4、如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodError 异常。
由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是java语言方法重写的本质。
三、单分派和多分派
方法的接收者与方法的参数统称为方法的宗量。根据分派基于多少种宗量,可以把分派划分成单分派和多分派。单分派是根据一个宗量对目标方法进行选择,多分派是根据多于一个宗量对目标方法进行选择。
看下面的代码:
静态分派的过程的选择目标方法的依据有两条:
1、静态类型是Father还是Son
2、方法参数是QQ还是360
选择结果的最终产物是产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向Father.hardChoice(360)及Father.hardChoice(QQ)方法的符号引用。因为是根据两个宗量进行选择,所以静态分派属于多分派类型。
在动态分派的过程中,由于编译器已经决定了目标方法的签名,因此只需要找到方法的接受者的实际类型就可以了。因为是根据一个宗量进行选择,所以动态分派属于单分派类型。
四、虚拟机动态分派的实现
由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法。虚拟机出于对性能的考虑,最常用的“稳定优化”的手段就是为类在方法区中建立一个虚方法表(Virtual Method Table,也成为vtable,与此对应的,在invokeinterface执行时也用到接口方法表——Interface Method Table,简称itable),使用方法表索引来代替元数据查找以提高性能。
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口。如上图,Son 重写了来自于 Father 的全部方法,因此 Son 的方法表没有指向 Father 类型数据的箭头。但是 Son 和 Father 都没有重写来自于 Object 的方法,所以他们的方法表中所有从 Object 继承来的方法都指向了 Object 的数据类型。
方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值之后,虚拟机会把该类的方法表也初始化完毕。