JVM方法调用原理
方法重载
方法重载在编译过程就已经能够确定,具体到每个方法调用,Java编译器会根据所传入参数的声明类型来选取重载方法。可以分为三个步骤:
- 在不考虑对基本类型自动装拆箱,以及可变长参数的情况下选取重载方法;
- 如果在第1个步骤中没有找到适配的方法,那么在允许自动装拆箱,但不允许可变长参数的情况下选取重载方法;
- 如果在第2个步骤中没有找到适配的方法,那么在允许自动装拆箱以及可变长参数的情况下选取重载方法。
如果Java编译器在同一个阶段中找到了多个适配的方法,那么它会在其中选择一个最为贴切的,而决定贴切程度的一个关键就是形式参数类型的继承关系。
方法重载也可以作用于这个类所继承而来的方法。也就是说,如果子类定义了与父类中非私有方法同名的方法,而且这两个方法的参数类型不同,那么在子类中,这两个方法同样构成了重载。
方法重写
如果子类和父类的同名方法都是静态的,那么子类中的方法隐藏了父类中的方法。如果这两个同名方法都不是静态的,且都不是私有的,并且两个方法的参数类型以及返回类型一致,那么子类的方法重写了父类中的方法。
JVM静态绑定与动态绑定
JVM识别方法的关键在于类名、方法名以及方法描述符,方法描述符是由方法的参数类型以及返回类型所构成。JVM与Java语言不同,它并不限制名字与参数类型相同,但返回类型不同的方法出现在同一个类中,对于调用这些方法的字节码来说,由于字节码所附带的方法描述符包含了返回类型,因此JVM能够准确地识别目标方法。
如果子类定义了与父类中非私有、非静态方法同名的方法,那么只有当这两个方法的参数类型以及返回类型一致,JVM才会判定为重写。
对于Java语言中重写而JVM中非重写的情况,编译器会通过生成桥接方法来实现Java中的重写语义。
对方法重载在编译阶段已经完成,但是方法重写需要在运行时才能决定。重载也被称为静态绑定,重写也被称为动态绑定。这个说法在JVM语境下并非完全正确。这是因为某个类中的重载方法可能被它的子类所重写,因此Java编译器会将所有对非私有实例方法的调用编译为需要动态绑定的类型。
确切地说,JVM中的静态绑定指的是在解析时便能够直接识别目标方法的情况,而动态绑定则指的是需要在运行过程中根据调用者的动态类型来识别目标方法的情况。
Java字节码中与调用相关的指令共有五种:
- invokestatic:用于调用静态方法。
- invokespecial:用于调用私有实例方法、构造器,以及使用super关键字调用父类的实例方法或构造器,和所实现接口的默认方法。
- invokevirtual:用于调用非私有实例方法。
- invokeinterface:用于调用接口方法。
- invokedynamic:用于调用动态方法。
对于invokestatic以及invokespecial而言,Java虚拟机能够直接识别具体的目标方法。
而对于invokevirtual以及invokeinterface而言,在绝大部分情况下,虚拟机需要在执行过程中,根据调用者的动态类型,来确定具体的目标方法。唯一的例外在于,如果虚拟机能够确定目标方法有且仅有一个,比如说目标方法被标记为final,那么它可以不通过动态类型,直接确定目标方法。
符号引用
在编译过程中,并不知道目标方法的具体内存地址。因此,Java编译器会暂时用符号引用来表示该目标方法。这一符号引用包括目标方法所在的类或接口的名字,以及目标方法的方法名和方法描述符。
符号引用存储在class文件的常量池之中。根据目标方法是否为接口方法,这些引用可分为接口符号引用和非接口符号引用,可以使用javap -v 类名.class
来打印某个类的常量池。
对于非接口符号引用,假定该符号引用所指向的类为C,则JVM会按照如下步骤进行查找:
- 在C中查找符合名字及描述符的方法。
- 如果没有找到,在C的父类中继续搜索,直至Object类。
- 如果没有找到,在C所直接实现或间接实现的接口中搜索,这一步搜索得到的目标方法必须是非私有、非静态的。并且,如果目标方法在间接实现的接口中,则需满足C与该接口之间没有其他符合条件的目标方法。如果有多个符合条件的目标方法,则任意返回其中一个。
从这个解析算法可以看出,静态方法也可以通过子类来调用。此外,子类的静态方法会隐藏(注意与重写区分)父类中的同名、同描述符的静态方法。
对于接口符号引用,假定该符号引用所指向的接口为I,则JVM会按照如下步骤进行查找:
- 在I中查找符合名字及描述符的方法。
- 如果没有找到,在Object类中的公有实例方法中搜索。
- 如果没有找到,则在I的超接口中搜索。这一步的搜索结果的要求与非接口符号引用步骤3的要求一致。
经过上述的解析步骤之后,符号引用会被解析成实际引用。对于可以静态绑定的方法调用而言,实际引用是一个指向方法的指针。对于需要动态绑定的方法调用而言,实际引用则是一个方法表的索引。
方法
类加载的准备阶段,它除了为静态字段分配内存之外,还会构造与该类相关联的方法表。方法表分为两种:
- invokevirtual所使用的虚方法表;
- invokeinterface所使用的接口方法表。
方法表本质上是一个数组,每个数组元素指向一个当前类及其祖先类中非私有的实例方法。这些方法可能是具体的、可执行的方法,也可能是没有相应字节码的抽象方法。方法表满足两个特质:
- 子类方法表中包含父类方法表中的所有方法;
- 子类方法在方法表中的索引值,与它所重写的父类方法的索引值相同。
方法调用指令中的符号引用会在执行之前解析成实际引用。对于静态绑定的方法调用而言,实际引用将指向具体的目标方法。对于动态绑定的方法调用而言,实际引用则是方法表的索引值(实际上并不仅是索引值)。执行过程中,JVM将获取调用者的实际类型,并在该实际类型的虚方法表中,根据索引值获得目标方法。这个过程便是动态绑定。
用了方法表的动态绑定与静态绑定相比,仅仅多出几个内存解引用操作:访问栈上的调用者,读取调用者的动态类型,读取该类型的方法表,读取方法表中某个索引值所对应的目标方法。
即时编译还拥有另外两种性能更好的优化手段:内联缓存(inlining cache)和方法内联(method inlining)。
内联缓存
内联缓存是一种加快动态绑定的优化技术。它能够缓存虚方法调用中调用者的动态类型,以及该类型所对应的目标方法。在之后的执行过程中,如果碰到已缓存的类型,内联缓存便会直接调用该类型所对应的目标方法。如果没有碰到已缓存的类型,内联缓存则会退化至使用基于方法表的动态绑定。
在针对多态的优化手段中有三种状态:
- 单态内联缓存指的是仅有一种状态的情况。它便是只缓存了一种动态类型以及它所对应的目标方法。它的实现非常简单:比较所缓存的动态类型,如果命中,则直接调用对应的目标方法。
- 多态内联缓存指的是有限数量种状态的情况。它则缓存了多个动态类型及其目标方法。它需要逐个将所缓存的动态类型与当前动态类型进行比较,如果命中,则调用对应的目标方法。一般来说,会将更加热门的动态类型放在前面。在实践中,大部分的虚方法调用均是单态的,也就是只有一种动态类型。为了节省内存空间,JVM只采用单态内联缓存。
- 超多态内联缓存指的是更多种状态的情况。通常用一个具体数值来区分多态和超多态。在这个数值之下,我们称之为多态。否则,我们称之为超多态。
当内联缓存没有命中的情况下,JVM需要重新使用方法表进行动态绑定。对于内联缓存中的内容,有两种选择。一是替换单态内联缓存中的记录。这种做法就好比CPU中的数据缓存,它对数据的局部性有要求,即在替换内联缓存之后的一段时间内,方法调用的调用者的动态类型应当保持一致,从而能够有效地利用内联缓存。因此,在最坏情况下,用两种不同类型的调用者,轮流执行该方法调用,那么每次进行方法调用都将替换内联缓存。也就是说,只有写缓存的额外开销,而没有用缓存的性能提升。另外一种选择则是劣化为超多态状态。这也是JVM的具体实现方式。处于这种状态下的内联缓存,实际上放弃了优化的机会。它将直接访问方法表,来动态绑定目标方法。与替换内联缓存记录的做法相比,它牺牲了优化的机会,但是节省了写缓存的额外开销。
虽然内联缓存附带内联二字,但是它并没有内联目标方法。这里需要明确的是,任何方法调用除非被内联,否则都会有固定开销。这些开销来源于保存程序在该方法中的执行位置,以及新建、压入和弹出新方法所使用的栈帧。
对于极其简单的方法而言,比如说getter/setter,这部分固定开销占据的CPU时间甚至超过了方法本身。此外,在即时编译中,方法内联不仅仅能够消除方法调用的固定开销,而且还增加了进一步优化的可能性。
单态内联缓存和超多态内联缓存的性能差距
测试如下代码:
// 消除方法内联的影响 Run With: java -XX:CompileCommand='dontinline,*.testInliningCache' Main
public abstract class Main {
abstract int testInliningCache();
public static void main(String[] args) {
Main a = new Main1();
Main b = new Main2();
long current = System.currentTimeMillis();
for (int i = 1; i <= 2_000_000_000; i++) {
if (i % 100_000_000 == 0) {
long temp = System.currentTimeMillis();
System.out.println(temp - current);
current = temp;
}
Main c = (i < 1_000_000_000) ? a : b;
int d = c.testInliningCache();
}
}
}
class Main1 extends Main {
@Override
int testInliningCache() {
return 1;
}
}
class Main2 extends Main {
@Override
int testInliningCache() {
return 2;
}
}
编译运行以下命令:
javac Main.java
java -XX:CompileCommand='dontinline,*.testInliningCache' Main
运行结果如下:
CompilerOracle: dontinline *.testInliningCache
238
225
223
225
227
224
223
222
224
225
249
253
254
247
247
246
246
246
247
247
从运行结果可以看出,前10次的单态内联缓存的运行时间都不超过240ms,大多在230ms左右,而后10次的超多态内联缓存的运行时间都超过了240ms,可以看出,单态内联缓存的性能是高于超多态内联缓存。