深入探索JVM垃圾回收:ARM服务器垃圾回收的挑战和优化
上QQ阅读APP看书,第一时间看更新

1.4.4 编译执行

解释执行是针对每一个字节码执行一段函数,由此带来的问题是执行效率低下。提高执行效率的手段就是将解释执行转换为编译执行。由于将字节码进行编译需要花费资源和时间,一种有效的方法是仅仅针对热点代码进行编译。JVM在执行过程中如果发现一个Java的函数或者函数中的某一块代码片段(代码片段通常是控制流中的一个块,或循环代码片段)频繁地被执行,就把这个函数或者代码片段编译成一个新的函数。这样带来的好处有两个:

1)节约每一个字节码调用时的成本。

2)整个函数或者代码块可以使用编译优化的技术对代码进行进一步的优化,从而提高执行效率。

目前JVM提供的优化方案主要有两种:客户端优化(也称为C1优化)和服务器优化(也称为C2优化)。C1优化和C2优化的执行原理相同,只不过采用的优化方法不同,进而编译优化所用的时间不同,优化后代码的执行效率也不同。

另外,目前JVM的优化器都是采用C++编写的,这就带来一个问题,如果想优化Java代码,必须熟悉C++。在JDK 9中启动了一个新的项目Graal,该项目是使用Java代码编写一个优化器并替代C2优化器(Graal目前还是实验性质的项目,但有不少公司评测认为其性能优于C2优化器。但出于项目活跃度及商业考虑,该项目在JDK 17中被移除)。

JVM中编译优化的过程如图1-22所示。

图1-22 JVM中编译优化过程

这个过程一般经历三个阶段并做不同的优化,分别为:

1)高级中间语言的生成及其优化。高级中间语言一般是进行语言相关、机器无关的描述,针对特定的语言进行的优化。

2)低级中间语言的生成及其优化。低级中间语言一般是进行语言无关、机器无关的描述,这是通用的中间语言描述,常见的编译优化技术基本上都针对低级中间语言进行,例如常量折叠、死代码消除、循环不变量外提等。由于编译优化需要消耗时间和CPU等资源,因此在JVM中提供了Tiered Compilation技术,即当发现代码变成热点后首先进行简单的代码优化,这样的优化产生了初级优化的机器代码并替代原来的解释执行;如果热点代码继续被反复执行,会启动高级的编译优化,并用高级编译优化后的代码替换初级优化的机器代码。使用该技术可以在编译效率和执行效率间取得一个很好的平衡,从而提高应用整体执行的效率。关于编译优化的相关技术不在本书的介绍范围内,更多信息可以参考其他书籍或者文献。

3)目标机器代码的生成。一般是进行和目标机器相关的优化,最为典型的优化就是寄存器的分配。

当编译优化完成后,JVM将在本地堆(更为准确的地方是指JVM的CodeCache)中存储编译优化后的代码,同时把描述Java方法的Method对象(参考图1-13)和编译优化代码进行关联。当执行Java的方法时,如果发现有编译优化后的代码,则直接执行编译优化后的代码。

但是编译执行的过程非常复杂,在整个编译过程中需要考虑以下几个方面:

(1)编译的内容

虚拟机应该针对热点代码进行编译以取得最好的收益。如何定义热点代码就是关键。最简单的方式是以函数为粒度,如果发现函数被调用的次数足够多,则可以将整个函数作为待编译的内容进行编译。但实际上还有一种情况,函数本身被调用的次数很少,函数内部存在一个很大的循环,并且在循环中做复杂的运算。对于该情况最好的处理方式是编译循环相关的代码片段,但这样的处理方式会带来额外的实现难度。例如如下代码:

class Test {
    static int sum(int c) {
        int res = 0;
        for(int i = 0;i < c; i++) {
            res += i;
        }
        return res;
    }
}

对于代码片段中的for循环执行1万次的数学运算,循环内部如果按照解释模式执行,则需要多次访问变量i,执行乘法和加法。假如函数sum本身不是热点,即函数sum本身不会由调用者触发执行编译优化,则对于函数sum中的循环优化片段,即语句res += i进行编译优化,并且可以执行优化后的代码。现代的高级虚拟机通常都支持代码片段的编译替换和执行。

(2)编译触发的时机

编译优化只有发现热点代码才能触发。如何定义代码是否是热点?一个简单的思路是代码执行的次数到达一定阈值就认为代码是热点,但实现中需要考虑更多的内容,特别是在多线程执行的情况中。如果一个线程执行一个循环,则可以通过对循环计数确定代码达到阈值从而触发编译,在这种情况下只需要一个线程局部的计数器就可以达到目的;实际中还有其他的情况,例如多个线程都会执行同一段代码,虽然每个线程执行代码的次数不多,但是多个线程加起来执行代码的次数就非常可观了,对于这样的情况,比较理想的设计是使用一个全局的计数器来记录热点代码执行的次数,而这样的设计需要考虑全局计数器的并发访问问题。需要指出的是,编译执行需要额外的计数器来记录热点代码,而维护额外的计数器不仅需要额外的空间来存储计数器,还会影响程序执行的效率。所以只有在可能出现热点代码的地方才会维护计数器,一般是在循环的回边(回边指的是循环体中跳转到循环起始位置继续执行的路径)中维护计数器。

(3)编译执行的方式

在确定好待编译的内容以后,需要考虑编译是同步执行还是异步执行。同步执行意味着应用程序需要等待编译结果完成后才能执行编译后的机器代码,异步执行意味着应用程序可以以解释的方式或者初级优化的代码继续执行,待编译完成后执行新的编译代码。

(4)编译代码的替换执行

要执行新的编译代码涉及原有栈帧到新的编译代码栈帧的切换。最简单的方式是当要执行新的编译代码时重新为新的代码构建栈帧,并将编译代码中所使用的变量作为参数传递,当编译代码执行结束后再返回原来的栈帧继续执行。当然,返回后需要更新原来栈帧的变量,这种方式也称为栈顶替换技术(On-Stack-Replacement,OSR)。继续使用上述sum函数进行演示,假设sum在执行到一定阈值后启动编译优化,并且在编译优化完成后执行编译优化后的代码。由于解释器是按照字节码顺序执行的,sum对应的字节码如下所示:

0  ICONST_0
1  ISTORE 1        // res = 0
2  ICONST_0
3  ISTORE 2        // i =0
4  ILOAD 2         // load i
5  ILOAD 0         // load c
6  IF_ICMPGE 13    // 大于阈值退出循环
7  ILOAD 1         // load res
8  ILOAD 2         // load i
9  IADD            // res + i
10  ISTORE 1       // store res
11  IINC 2 1       // i++
12  GOTO 4         // 回边,执行循环
13  ILOAD 1        // load res,然后返回
14  IRETURN

假设循环执行50次后认定代码片段为热点并对代码片段进行编译,当编译完成后执行。为了方便演示,使用字母A、L、B描述执行代码。其中L对应的是热点代码片段,编译执行时需要将L依赖或者使用的变量作为参数传递给编译后的代码,同时将L对应的代码片段进行编译。假设编译后形成函数sum_osr,函数的入参为L代码片段中使用的变量。替换执行时可以简单地构造一个函数调用,跳转到编译后的代码执行。代码执行完成后返回原来的栈帧继续执行。为了能够让原来的栈帧继续执行,通常需要知道原来栈帧执行的下一条指令的地址。整个过程的示意图如图1-23所示。

图1-23 OSR执行示意图

图中还有一个尚未解决的问题,从L处调用sum_osr时需要传递参数,那么此时参数可能有哪些?由于函数sum已经执行了部分代码,因此变量res和i已经不再是初值,并且res和i都将在编译代码中被使用,同时变量c也将在编译代码中被使用。这里假设执行50次后开始执行编译代码,所以i=50,此时res=1225(res=1+2+…+49=1225),另外,在解释执行时还会使用操作数栈,这些内容都将作为参数传递给编译优化的代码。

此外,虚拟机在执行编译优化时可能会进行一些激进的优化动作,例如根据已经执行的类的信息优化函数的调用关系。这就会带来额外的问题,如果类型信息发生变化,优化代码就会变成无效的,此时需要从编译优化后的代码切换到原来的解释执行方式(称为退优化)。退优化的过程中也涉及何时允许触发退优化,以及代码的替换执行等问题。编译优化是虚拟机中非常关键的模块,限于篇幅,本书不对编译优化展开介绍,读者可以参考其他的书籍或者文献。

注意

上述演示的是常规OSR技术。其中提到,由于JIT编译优化需要耗费资源和时间,在一些场景中需要更为轻量级的JIT。一种激进的实现是不做任何编译的JIT,也称为Level-0 JIT(简称L0 JIT)。在L0 JIT的实现中,通常的做法是重用解释器的栈帧,即L0 JIT尽可能重用解释器的数据(如有必要,仅仅保护两种执行模式不同的栈变量和寄存器)。例如,流行的JavaScript虚拟机V8[1]中实现了一款SparkPlug的轻量级JIT,重用了解释器Ignition的栈帧,无须额外的栈切换成本。经测试发现,相比原来的执行方式,引入SparkPlug后性能有5%~15%的提升。

最后需要指出的是,编译的执行过程和垃圾回收也有交互,即当执行垃圾回收时需要暂停编译代码的执行,这需要在编译优化的代码中考虑支持垃圾回收。关于这一内容将在第2章讨论。


[1] V8是Google浏览器Chrome的JavaScript虚拟机引擎,采用解释器Ignition解释执行代码、JIT编译器TurboFan编译优化热点代码。2021年在Ignition和TurboFan之间引入SparkPlug,用于加速JavaScript的执行。更多具体信息可以参考V8的官网。