Java 方法表与虚分派解析 - TongtongLan/Java GitHub Wiki
Java:方法的虚分派(virtual dispatch)和方法表(method table)
方法调用不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本,暂时并不涉及方法内部的具体执行过程。通过Class文件结构可以知道一切方法调用在class文件中都是以符号引用来表示的,而非方法在实际运行时内存布局中的入口地址(即直接引用)。
注意:解析与分派是在不同层次上确定目标方法的过程,而非二选一的排他关系。
解析(Resolution):一定是静态过程。在类加载的解析阶段即可完全确定方法版本,将符号引用转化为直接引用,不会延迟到运行期再去完成。
分派(Dispatch):静态或者动态过程。根据分派方式可组成构成静态单分派、静态多分派、动态单分派、动态多分派(单分派及多分派依据是宗量数)。
JVM 中方法调用字节码指令:
-
invokestatic:调用静态方法指令
-
invokespecial:调用实例构造器
<init>()方法、私有方法和父类方法 -
invokevirtual:调用所有的虚方法(final方法也是通过这条指令调用,但是 Java 语言规范中明确说明 final 方法是一种非虚方法)
-
invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象
-
invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,再执行该方法???
invokestatic 与 invokespecial指令调用的方法,都可以在解析阶段唯一确定版本,因此都采用解析调用。这类方法即非虚方法,其他方法则是虚方法(final 方法除外)
解析(Resolution)(详见Java 类的加载、链接与初始化)
在类加载阶段,会将其中一部分符号引用转化为直接引用。
前提:符合“编译器可知,运行时不可变”,主要包括静态方法、私有方法、父类方法以及 final 修饰的方法。
- 静态方法:与类型直接相关
- 私有方法:在外部不可被访问
- final 方法:无法被覆盖,没有其他方法版本
- 方法解析的具体过程
分派调用过程揭示了多态性特性的最基本体现,包括方法重载、方法重写在 JVM 中的具体实现过程(确定调用目标)。
依赖静态类型(即外观类型,与实际类型相对)来定位方法执行版本的分派行为,发生在编译阶段,但大多情况下重载版本不唯一(模糊匹配,因为字面量没有显示的静态类型,通常只能确定一个“更加合适的版本”,例如基本类型作为方法参数时的自动转型,见下面例子)。典型应用是方法重载。
方法重载例子
运行期间根据实际类型确定方法执行版本.
向上转型后调用子类覆写的方法便是一个很好地说明动态分派的例子。很显然,在判断执行父类中的方法还是子类中覆盖的方法时,如果用静态类型(即外观类型,与实际类型相对)来判断,那么无论怎么进行向上转型,都只会调用父类中的方法,但实际情况是,根据对父类实例化的子类的不同,调用的是不同子类中覆写的方法,很明显,这里是要根据变量的实际类型来分派方法的执行版本的。而实际类型的确定需要在程序运行时才能确定下来,这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
- 动态分派的实现——建立虚方法表
由于动态分派操作频繁,且需要运行时在类的方法元数据中搜索合适的目标方法,考虑到性能问题,通常为类在方法区建立一个虚方法表(Virtual Method Table,简称vtable,与此对应的在 invokeinterface 执行时会用到接口方法表——Interface Method Table,简称itable),使用虚方法表索引来代替元数据查找,以提高性能。
方法表一般在类加载的链接阶段进行初始化,在准备了类的变量初始值后,虚拟机会初始化该类的方法表——《深入理解Java虚拟机》(问题:在解析过程中,如何得知父类的方法地址,子类方法表初始化时父类方法表是否已经被初始化?)
虚方法表中存储各个方法的实际入口地址。
方法表地址存放原则:
-
如果某个方法在子类中没有被重写,那子类的虚方法表中的地址入口和父类中相同方法的地址入口一致,都指向父类的实现入口。
-
如果某个方法在子类中被重写,那么子类的虚方法表中的地址入口为子类方法实现版本的入口地址。
方法的接受者(亦即方法的调用者)与方法的参数统称为方法的宗量。但分派是根据一个宗量对目标方法进行选择,多分派是根据多于一个宗量对目标方法进行选择。
例子:
class Eat{
}
class Drink{
}
class Father{
public void doSomething(Eat arg){
System.out.println("爸爸在吃饭");
}
public void doSomething(Drink arg){
System.out.println("爸爸在喝水");
}
}
class Child extends Father{
public void doSomething(Eat arg){
System.out.println("儿子在吃饭");
}
public void doSomething(Drink arg){
System.out.println("儿子在喝水");
}
}
public class SingleDoublePai{
public static void main(String[] args){
Father father = new Father();
Father child = new Child();
father.doSomething(new Eat());
child.doSomething(new Drink());
}
}
运行结果应该很容易预测到,如下:
爸爸在吃饭
儿子在喝水
- Java语言的静态分派属于多分派类型
首先来看编译阶段编译器的选择过程,即静态分派过程。这时候选择目标方法的依据有两点:一是方法的接受者(即调用者)的静态类型是Father还是Child,二是方法参数类型是Eat还是Drink。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。
- Java语言的动态分派属于单分派类型
再来看运行阶段虚拟机的选择,即动态分派过程。由于编译期已经了确定了目标方法的参数类型(编译期根据参数的静态类型进行静态分派),因此唯一可以影响到虚拟机选择的因素只有此方法的接受者的实际类型是Father还是Child。因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。
总结: 目前为止,Java 语言是一门静态多分派、动态单分派的语言。