即时编译

通常而言,代码会先被 Java 虚拟机解释执行,之后反复执行的热点代码则会被即时编译成为机器码,直接运行在底层硬件之上。

在Java7之前,需要根据程序的特性,选择对应的即时编译器。对于执行时间比较短的,或者对启动性能有要求的,采用编译较快的C1,执行时间较长,或对峰值性能有要求的,采用生成代码执行效率较快的C2。

Java7引入了分层编译。将虚拟机的执行状态分为了5个层次,分别是:

  1. 解释执行
  2. 执行不带profiling的C1生成的机器码
  3. 执行仅带方法调用次数以及循环回边执行次数profiling的C1生成的机器码
  4. 执行带所有profiling的C1生成的机器码
  5. 执行C2生成的机器码

其中,C2代码的执行效率要比C1代码的高,而对于C1代码的三种状态,效率由高到低,因为profiling 越多,其额外的性能开销越大。profiling 是指在程序执行过程中,收集能够反映程序执行状态的数据。这里所收集的数据,称之为程序profile。profiler 大多通过注入 (instrumentation)或者 JVMTI 事件来实现的。

在 5 个层次的执行状态中,1 层和 4 层为终止状态。当一个方法被终止状态编译过后,如果 编译后的代码并没有失效,那么 Java 虚拟机是不会再次发出该方法的编译请求的。

image-20231127103204555

如果方法的字节码数目比较少,而且3层的profiling没有可收集的数据,那么虚拟机断定该方法对于C1和C2代码的执行效率相同。这种情况下,虚拟机会在3层编译后,直接选用1层的C1编译,这是一个终止状态,虚拟机不会再继续用4层的C2编译。

后两种展示了C1忙碌和C2忙碌的情况。

即时编译的触发

Java 虚拟机是根据方法的调用次数以及循环回边的执行次数来触发即时编译的。Java 虚拟机在 0 层、2 层和 3 层执行状态时进行 profiling,其中就包含方法的调用次数和循环回边的执行次数。

这里的循环回边是一个控制流图中的概念。在字节码中,可以简单理解为往回跳转的指令。

1
2
3
4
5
6
public static void foo(Object obj) {
int sum = 0;
for (int i = 0; i < 200; i++) {
sum += i;
}
}

Java虚拟机并不会对这些计数操作进行同步,收集的数据并不需要准确,当数值足够大,就可以说明包含热点代码。

具体来说,在不启动分层编译的情况下,当方法调用次数和循环回边的次数和,超过阈值(由参数-XX:CompileThreshold指定,使用C1时为1500,使用C2时该值为10000),便会触发即时编译。

当采用分层编译,Java虚拟机将不再采用由参数-XX:CompileThreshold 指定的阈值,而是使用另一套阈值系统,这里该阈值是动态调整的。

OSR编译

决定一个方法是否为热点代码的因素有两个:方法的调用次数、循环回边的执行次数。即时编译器是根据这两个计数器的和来触发的,为什么不维护两个不同的计数器呢?

除了以方法为单位的即时编译之外,Java 虚拟机还存在着另一种以循环为单位的即时编译,叫做 On-Stack-Replacement(OSR)编译。循环回边计数器便是用来触发这种类 型的编译的。

OSR 实际上是一种技术,它指的是在程序执行过程中,动态地替换掉 Java 方法栈桢,从而使 得程序能够在非方法入口处进行解释执行和编译后的代码之间的切换。事实上,去优化 (deoptimization)采用的技术也可以称之为 OSR。