静态分派和动态分派
本篇为学习JAVA虚拟机的第十二篇文章,本章说明静态分派和动态分派的原理。
这里所谓的分派指的是在Java中对方法的调用。Java中有三大特性:封装、继承和多态。分派是多态性的体现,Java虚拟机底层提供了我们开发中“重写”和“重载”的底层实现。其中重载属于静态分派,而重写则是动态分派的过程。除了使用分派的方式对方法进行调用之外,还可以使用解析调用,解析调用是在编译期间就已经确定了,在类装载的解析阶段就会把符号引用转化为直接引用,不会延迟到运行期间再去完成。而分派调用则既可以是静态的也可以是动态(就是这里的静态分派和动态分派)的。
方法解析
对于方法的调用,虚拟机提供了四条方法调用的字节码指令,分别是:
-
invokestatic
: 调用静态方法 -
invokespecial
: 调用构造方法,私有方法,父类方法 -
invokevirtual
: 调用虚方法 -
invokeinterface
: 调用接口方法
其中,1和2都可以在类加载阶段确定方法的唯一版本,因此,在类加载阶段就可以把符号引用解析为直接引用,在调用时刻直接找到方法代码块的内存地址进行执行(编译时已经找到了,并且存在方法调用的入口);3和4则是在运行期间动态绑定方法的直接引用。
invokestatic
指令和invokespecial
指令调用的方法称为非虚方法,注意,final
修饰的方法也属于虚方法。
静态分派
静态分派只会涉及重载,而重载是在编译期间确定的,那么静态分派自然是一个静态的过程(因为还没有涉及到Java虚拟机)。静态分派的最直接的解释是在重载的时候是通过参数的静态类型而不是实际类型作为判断依据的。比如创建一个类O
,在O
中创建了静态类内部类A
,O
中又有两个静态类内部类B
、C
继承了这个静态内部类A
,那么实际上当编写如下的代码:
1 | public class O{ |
运行的结果是打印出连个“A method”
。原因在于静态类型的变化仅仅在使用时发生,变量本身的类型不会发生变化。
比如我们这里中A b = new B();
虽然在创建的时候是B
的对象,但是当调用o.a(b)
的时候才发现是A
的对象,所以会输出“A method”
。**也就是说在发生重载的时候,Java虚拟机是通过参数的静态类型而不是实际参数类型作为判断依据的。**因此,在编译阶段,Javac编译器选择了a(A a)
这个重载方法。
虽然编译器能够在编译阶段确定方法的版本,但是很多情况下重载的版本不是唯一的,在这种模糊的情况下,编译器会选择一个更合适的版本。例如,重载的方法中,参数列表除了参数类型不一样,其他都一样,例接收的参数有char\int\long等,传入参数‘a’,则会调用需要char类型参数的方法,去掉需要char类型参数的方法,则会调用需要int类型参数的方法。这时发生了一次自动类型转换。同样,去掉需要int类型参数的方法,则会调用需要long类型参数的方法。这里再次发生类型转换,会按照char->int->long->float->double转换类型。
动态分派
动态分派与重写(Override)有着很密切的关联。如下代码:
1 | package com.xtayfjpk.jvm.chapter8; |
这里显然不可能是根据静态类型来决定的,因为静态类型都是Human
的两个变量man
和woman
在调用sayHello()
方法时执行了不同的行为,并且变量man
在两次调用中执行了不同的方法。
导致这个现象的原是是这两个变量的实际类型不同。那么Java虚拟机是如何根据实际类型来分派方法执行版本的呢,我们使用javap
命令输出这段代码的字节码,结果如下:
1 | public static void main(java.lang.String[]); |
0-15行的字节码是准备动作,作用是建立man
和woman
的内存空间,调用Man
和Woman
类的实例构造器,将这两个实例的引用存放在第1和第2个局部变量表Slot之中,这个动作对应了代码中这两句:
1 | Human man = new Man(); |
接下来的第16-21行是关键部分,第16和第20两行分别把刚刚创建的两个对象的引用压到栈顶,这两个对象是将执行的sayHello()
方法的所有者,称为接收者(Receiver)。
第17和第21两行是方法调用指令,单从字节码的角度来看,这两条调用指令无论是指令(都是invokevirtual
)还是参数(都是常量池中Human.sayHello()
的符号引用)都完全一样,但是这两条指令最终执行的目标方法并不相同,其原因需要从invokevirutal
指令的多态查找过程开始说起,invokevirtual
指令的运行时解析过程大致分为以下步骤:
- 找到操作数栈顶的第一个元素所指向的对象实际类型,记作
C
。 - 如果在类型
C
中找到与常量中描述符和简单名称都相同的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找结束;不通过则返回java.lang.IllegalAccessError
错误。 - 否则,按照继承关系从下往上依次对
C
的各个父类进行第2步的搜索与校验过程。 - 如果始终没有找到合适的方法,则抛出
java.lang.AbstractMethodError
错误。
由于invokevirtual
指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual
指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
单分派与多分派
方法的接收者与方法的参数统称为方法的宗量。根据分派基于多少种宗量,可以将分派划分为单分派与多分派两种。单分派是根据一个宗量来对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。
在编译期的静态分派过程选择目标方法的依据有两点:一是静态类型;二是方法参数,所以Java语言的静态分派属于多分派类型。在运行阶段虚拟机的动态分派过程只能接收者的实际类型一个宗量作为目标方法选择依据,所以Java语言的动态分派属于单分派类型。所以Java语言是一门静态多分派,动态单分派语言。
JVM实现动态分派
动态分派在Java中被大量使用,使用频率及其高,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率,因此JVM在类的方法区中建立虚方法表(virtual method table
)来提高性能。
⭐⭐⭐每个类中都有一个虚方法表,表中存放着各个方法的实际入口。如果某个方法在子类中没有被重写,那子类的虚方法表中该方法的地址入口和父类该方法的地址入口一样,即子类的方法入口指向父类的方法入口。如果子类重写父类的方法,那么子类的虚方法表中该方法的实际入口将会被替换为指向子类实现版本的入口地址。
那么虚方法表什么时候被创建?虚方法表会在类加载的连接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的方法表也初始化完毕。