Skip to content

JVM 执行引擎

本文主要讲解JVM中执行引擎的作用。

1. 概述

Java的执行过程整体可以分为两个部分:

  • 第一步由javac将源码编译成字节码,在这个过程中会进行词法分析、语法分析、语义分析,编译原理中这部分的编译称为前端编译。
  • 第二步是在程序运行过程中,执行字节码,又分为两种情况:
    • 解释执行:逐条读取字节码,将其翻译为机器相关的机器码,然后执行;
    • 编译执行:对热点代码进行编译,生成机器码,并放入方法区中的代码缓存;下次再次执行热点代码时,直接执行编译后的机器码。这部分的编译称为后端编译。

img

2. 解释器

解释器的作用是读取字节码,然后翻译成机器码指令,再交由CPU执行。在Java发展过程中,出现了两种解释器:字节码解释器和模板解释器。

2.1 字节码解释器

字节码解释器是通过纯软件代码模拟字节码的执行,例如iload字节码,可以通过编写如下代码翻译:

c++
// 1. 讀取 index 並將 pc 後移
unsigned char index = *(currentFrame->pc++); 
// 2. 從本地變量表中獲取值
int value = currentFrame->localVariables[index];
// 3. 將值壓入操作數棧
currentFrame->operandStack.push(value);

上述代码会由编译器生成机器相关的机器指令(就是不同平台下的JVM)。在程序执行时,读取到iload字节码,就会执行上述代码的机器指令。

但是编译器生成的机器指令很冗余,而CPU本身就是不断取指执行,指令越多,耗时也就越长。对于JVM的解释器来说,其实也就是不断取指执行,如果每个字节码指令的执行时间都很慢,那么整体效率必然很差。

所以字节码解释器优点是实现简单,但是缺点是慢。

2.2 模板解释器

上面提到字节码解释器慢是因为编译器生成的机器指令不够理想,那么我们直接跳过编译器,自己动手写汇编代码不就行了。没错,现在的HotSpot就是这样干的,这种解释器便称为模板解释器。

模板解释器相对于为每一个指令都写了一段实现对应功能的汇编代码,在JVM初始化时,汇编器会将汇编代码翻译成机器指令加载到内存中,比如执行iload指令时,直接执行对应的汇编代码即可。如何执行汇编代码?直接跳往汇编代码生成的机器指令在内存中的地址即可。

3. 即时编译器

JVM中集成了两种编译器,Client Compiler和Server Compiler,它们的作用也不同。Client Compiler注重启动速度和局部的优化,Server Compiler则更加关注全局的优化,性能会更好,但由于会进行更多的全局分析,所以启动速度会变慢。

3.1 Client Compiler

HotSpot VM带有一个Client Compiler C1编译器。这种编译器启动速度快,但是性能比较Server Compiler来说会差一些。

C1编译器的优化策略包括:方法内联,去虚拟化,冗余消除。

  • 方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程;
  • 去虚拟化:对唯一的实现类进行内联;
  • 冗余消除:在运行期间把一些不会执行的代码折叠;

3.2 Server Compiler

在Hotspot VM中,默认的Server Compiler是C2编译器。

C2编译器的优化主要是在全局层面,逃逸分析是优化的基础。基于逃逸分析,在C2上有如下几种优化:

  • 标量替换:用标量值代替聚合对象的属性值;
  • 栈上分配:对于未逃逸的对象,分配在栈上而不是堆中;
  • 同步消除:清除同步操作,通常指synchronized

3.3 Graal Compiler

从JDK 9开始,Hotspot VM中集成了一种新的Server Compiler,Graal编译器。相比C2编译器,Graal有这样几种关键特性:

  • JVM会在解释执行的时候收集程序运行的各种信息,然后编译器会根据这些信息进行一些基于预测的激进优化,比如分支预测,根据程序不同分支的运行概率,选择性地编译一些概率较大的分支。Graal比C2更加青睐这种优化,所以Graal的峰值性能通常要比C2更好。
  • 使用Java编写,对于Java语言,尤其是新特性,比如Lambda、Stream等更加友好。
  • 更深层次的优化,比如虚函数的内联、部分逃逸分析等。

Graal编译器可以通过Java虚拟机参数-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler启用。当启用时,它将替换掉HotSpot中的C2编译器,并响应原本由C2负责的编译请求。

3.4 AOT编译器

JDK 9 引入了AOT编译器(Ahead Of Time Compiler),称为静态提前编译器。

在Java 9引入了实验性的AOT编译工具jaotc。它借助了Graal编译器,将所输入的Java类文件转换为机器码,并存放至生成的动态共享库之中。

所谓AOT编译,是与即时编译相对立的一个概念。即时编译指的是在程序的运行过程中,将字节码转换为可以在机器上直接运行的机器指令,并保存到方法区中的代码缓存。而AOT编译指的是,在程序运行之前,就把源代码或字节码转换为机器码的过程。

3.5 热点代码探测

后端编译器会对热点代码进行编译,提高执行速度。一个被多次调用的方法,或者一个方法体内部循环次数较多的循环体都可以称之为热点代码。HotSpot VM采用基于计数器的热点代码探测方式,即调用一个方法或执行一次循环体,则计数加一。

采用基于计数器的热点探测方式,HotSpot VM 会为每个方法都建立两个不同类型的计数器,分别为方法调用计数器和回边计数器:

  • 方法调用计数器:用于统计方法的调用次数;
  • 回边计数器:用于统计循环体执行的循环次数;

3.5.1 方法调用计数器

方法调用计数器用于统计方法的调用次数,它的默认阈值在Client模式下是1500次,在Server模式下是10000次。超过这个阈值,就会触发JIT编译。

这个阈值可以通过虚拟机参数-XX:CompilerThreshold修改。

当一个方法被调用时,会先检查该方法是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加一,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。

image-20250409114406754

如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器值就会被减少一半,这个过程称为方法调用计数器热度的衰减,而这段时间就称为此方法统计的半衰周期。

进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-XX:-UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间够长,绝大部分方法都会被编译为本地代码。

另外,可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。

3.5.2 回边计数器

同理,回边计数器的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为回边。显然,建立回边计数器统计的目的就是为了触发OSR(On-Stack Replacement,栈上替换)编译。

什么是OSR?

通常情况下,JVM的JIT编译器会在方法被调用一定次数后(达到热点阈值)才对其进行编译优化。然而,对于一些包含无限循环或者执行时间非常长的循环的方法,它们可能在方法执行的早期就已经是热点代码,但由于方法尚未执行完毕,JIT编译器无法介入进行优化。

On-Stack Replacement 允许JVM在方法仍在执行的过程中,将方法的执行栈帧替换为已编译优化的版本。这意味着JVM可以在方法执行到某个特定的点(通常是循环的回边),并且判断该循环是热点时,不必等待整个方法执行结束,就将该循环部分的代码替换为优化后的机器码,并继续执行。

OSR 的工作原理(简化描述):

  1. 热点循环识别: JVM通过回边计数器等机制识别出长时间运行的循环。
  2. 编译请求: 当循环的回边计数器达到OSR的阈值时,JVM会向JIT编译器发送OSR编译请求,目标是编译该循环部分的代码。
  3. 栈帧替换: 一旦JIT编译器完成对循环代码的优化编译,JVM会在下一次执行到该循环的回边时,将当前方法在执行栈上的栈帧(包含局部变量、操作数栈等)的状态映射到已编译优化代码所需的栈帧格式。然后,程序的执行流程会从原来的解释执行或简单编译的代码切换到新编译优化的代码继续执行。这个过程就是“栈上替换”。

3.6 分层编译

在Java 7以前,需要研发人员根据服务的性质去选择编译器。对于需要快速启动的,或者一些不会长期运行的服务,可以采用编译效率较高的C1,对应参数-client。长期运行的服务,或者对峰值性能有要求的后台服务,可以采用峰值性能更好的C2,对应参数-server。Java 7开始引入了分层编译的概念,它结合了C1和C2的优势,追求启动速度和峰值性能的一个平衡。分层编译将JVM的执行状态分为了五个层次。五个层级分别是:

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

分层编译的工作流程:

  1. 程序启动时,大部分代码通过解释器执行。
  2. 随着方法被调用的次数增加,如果达到C1编译器的阈值,该方法会被C1编译器编译成本地机器码。
  3. 如果方法继续被频繁调用,达到C2编译器的阈值,或者C1编译的代码性能仍然无法满足需求,JVM会将该方法再次提交给C2编译器进行更深层次的优化编译。
  4. JVM会根据程序的运行状态和代码的“热度”动态地选择使用哪个层次的编译器进行编译。

总的来说,C1的编译速度更快,C2的编译质量更高,分层编译的不同编译路径,也就是JVM根据当前服务的运行情况来寻找当前服务的最佳平衡点的一个过程。从JDK 8开始,JVM默认开启分层编译。

4. 案例

我们可以使用以下虚拟机参数,来设置程序的执行策略:

  • -Xint:完全采用解释器模式执行程序;
  • -Xcomp:完全采用即时编译器模式执行程序。如果即使编译出现问题,解释器会介入执行;
  • -Xmixed:默认值,采用解释器+即时编译器的混合模式共同执行程序;

有如下代码:

java
public static void main(String[] args) {
    long startTime = System.currentTimeMillis();
    for (int i = 0; i < 100_0000; i++) {
        for (int j = 0; j < 1000; j++) {
            double a = Math.sqrt(i);
            int b = Math.floorDiv(i, 2);
        }
    }
    long endTime = System.currentTimeMillis();
    System.out.println("耗时:" + (endTime - startTime) + "ms");
}

分别用三种模式执行,结果如下:

模式-Xint-Xcomp-Xmixed
结果20400ms1740ms5ms

参考资料

[1] https://tech.meituan.com/2020/10/22/java-jit-practice-in-meituan.html

[2] https://zhuanlan.zhihu.com/p/33886967