JVM(8):后端编译与优化

1. 即时编译器

        目前主流的两款商用Java虚拟机里,Java程序最初都是通过解释器执行的。当虚拟机发现某个方法或者代码块运行频繁,就会把代码认为是热点代码 ( $Hot\ \ Spot\ \ Code$ ),并将这些代码编译为本地机器码,同时进行代码优化。完成这个任务的后端编译器就称为即时编译器。

1.1 解释器与编译器

        当程序需要迅速启动和执行时,解释器可以省去编译时间立即运行;当程序启动后,越来越多的代码会被编译成本地代码,从而减少解释器带来的中间损耗,获得更快的执行速度。如果程序运行环境中内存限制过大,可以使用解释执行从而节约内存,反之可以使用编译执行提升效率。当编译器采取的优化手段出现罕见陷阱 ( $Uncommon\ \ Trap$ ) 时,可以通过逆优化 ( $Deoptimization$ ) 退回到解释状态执行。
        HotSpot虚拟机中内置了两个(或三个)即时编译器,其中两个存在已久的编译器分别称为客户端编译器 ( $Client\ \ Compiler$ ) 和服务端编译器 ( $Server\ \ Compiler$ ),简称为 $C1$ 编译器和 $C2$ 编译器。第三个编译器是JDK 10时出现的,目标是替代 $C2$ 的 $Graal$ 编译器。
        在分层编译 ( $Tiered\ \ Compilation$ ) 的工作模式出现前,通常采用解释器与一个编译器直接搭配的方式工作,这时用户也可以通过 $-client$ 和 $-server$ 参数直接运行模式。解释器与编译器搭配使用的方式称为混合模式 ( $Mixed\ \ Mode$ ),也可以通过 $-Xint$ 或者 $-Xcomp$ 强制运行解释模式或者编译模式。
        即时编译需要占用程序运行时间,而且编译过程中还需要进行优化。要想达到好的优化效果,解释器需要替编译器收集性能监控信息。当然,这也会影响解释执行的速度。为了寻求启动响应速度与运行效率之间的平衡,HotSpot虚拟机在编译子系统中加入了分层编译,包括:

  1. 第 $0$ 层,程序解释执行,不开启性能监控;
  2. 第 $1$ 层,使用客户端编译器,进行简单优化,不开启性能监控;
  3. 第 $2$ 层,使用客户端编译器,开启方法和回边次数统计等监控;
  4. 第 $3$ 层,使用客户端编译器,开启全部性能监控,除了第 $2$ 层的统计信息外,还会收集分支跳转、虚方法等统计信息;
  5. 第 $4$ 层,使用服务端编译器将字节码编译为本地代码,需要更多的优化,还可能采取一些不可靠的激进优化。

        实施分层编译后,解释器、客户端编译器、服务端编译器就会同时工作,热点代码可能会被多次编译。使用客户端编译器编译可以获得更高的编译速度,使用服务端编译器可以获得更好的编译质量。

1.2 编译对象与触发条件

        即时编译器的编译目标是热点代码,在这里热点代码主要有两类:

        后者主要是解决少次调用方法内存在循环次数较多的循环体的问题,因此这两类热点代码的编译对象都是整个方法体,不同之处在于执行入口点字节码序号 ( $Byte\ \ Code\ \ Index$, $BCI$ ),因为后者的编译发生在方法执行过程中,所以栈中的方法会在执行的过程中被替换,称为栈上替换 ( $On\ \ Stack\ \ Replacement$, $OSR$ )。
        判断一段代码是不是热点代码的行为称为热点探测 ( $Hot\ \ Spot\ \ Code\ \ Detection$ )。主流的热点探测方式有两种:

        HotSpot虚拟机中采用的是第二种方法,每个方法有两个计数器:方法调用计数器 ( $Invocation\ \ Counter$ ) 和回边(在循环边界往回跳转)计数器 ( $Back\ \ Edge\ \ Counter$ )。这两个计数器都存在一个阈值,只要超过这个阈值,就认为方法为热点代码。
        对于方法调用计数器,在方法被调用时,虚拟机会先检查方法是否已被即时编译,如果没有则递增计数器,超过阈值便提交编译请求。在客户端编译器中,阈值为 $1500$ ;在服务端编译器中,阈值为 $10000$ 。可以通过 $-XX:CompileThreshold$ 设定。默认情况下执行引擎不会等待编译请求完成,而是会继续解释执行字节码,直到编译完成。方法调用计数器存储的并不是方法被调用的次数,而是一个相对频率。如果在一定时间内方法调用次数没有超过阈值,那么计数器数值就会减半,称为热度衰减 ( $Counter\ \ Decay$ ),衰减的周期称为半衰周期 ( $Counter\ \ Half\ \ Life\ \ Time$ )。虚拟机会在垃圾收集的过程中顺便执行热度衰减操作,可以通过 $-XX:-UserCounterDecay$ 来关闭热度衰减,或者通过 $-XX:CounterHalfLifeTime$ 设置半衰周期,单位是秒。
        对于回边计数器,在遇到字节码的控制流向后跳转指令时就递增,同样在默认情况下不会同步编译请求。回边计数器不存在热度衰减的过程,也就是说统计的是方法循环执行的绝对次数。当回边计数器溢出时,还会将方法调用计数器的值也调整为溢出,从而使得下次进入该方法时执行标准编译过程。虽然HotSpot虚拟机中提供了 $-XX:BackEdgeThreshold$ 参数,但是实际上并没有作用。我们通过另一个参数 $-XX:OnStackReplacePercentage$ ( $OSR$ 比率 ) 间接调整回边计数器阈值,计算公式有两种:

1.3 编译过程

        默认情况下,解释器不会同步编译请求,用户可以通过 $-XX:-BackgroundCompilation$ 禁止。对于客户端编译器来说,后台编译是一个三段式的编译过程:

        服务端编译使用的是经过针对性调整后的可以容忍很高优化复杂度的高级编译器,执行大部分优化动作如无用代码消除、循环展开、循环外提、消除公共表达式、常量传播、基本块重排等,以及一些与Java相关的优化技术如消除范围检查和空值检查等,此外还有可能根据性能监控信息进行一些激进优化,如守护内联、分支频率预测等。服务端编译采用的寄存器分配器是一个全局图着色分配器,可以充分利用某些处理器架构上的大寄存器集合。因此,虽然服务端编译的速度很慢,但是能够产生可以大幅减少执行时间的代码,从而抵消掉编译开销,这也使得一些非服务端应用选择使用服务端模式运行。

2. 提前编译器

        提前编译器存在两条分支,一条是在程序运行前将程序代码编译成机器码,另一条是把原本即时编译器在运行时要做的工作提前做好并保存下来。Java的即时编译需要占用程序运行时间和运算资源,而提前编译可以在一定程度上减少即时编译的工作量,这也是提前编译的目的之一。因为不需要考虑执行时间和资源消耗等问题,所以提前编译可以使用重负载的优化手段。但是即时编译相较于提前编译还是存在着天然优势:

3. 编译器优化技术

3.1 方法内联

        方法内联即将方法实现复制到调用方法中,从而避免方法调用。但是JVM中,只有 $invokespecial$ 和 $invokestatic$ 调用的方法才会在编译期进行解析,其他方法都必须在运行时进行多态选择,即Java中默认的实例方法都是虚方法。为了解决虚方法带来的问题,JVM引入了类型继承关系分析 ( $Class\ \ Hierarchy\ \ Analysis$, $CHA$ ) 技术,用于确定目前已加载的类中某个接口是否存在多于一种方式的实现、某个类是否存在子类、子类是否存在方法覆盖等。如果方法只有一个版本,那么就可以假设应用程序的全貌就是现在这样,从而进行内联,这种方式称为守护内联 ( $Guarded\ \ Inlining$ )。当之前的条件假设不成立时,虚拟机加载到令方法接收者的继承关系发生变化的类,也就是守护内联失败时,就必须抛弃已经编译的代码,退回解释状态。如果方法存在多个版本,那么即时编译器会采用内联缓存 ( $Inling\ \ Cache$ ) 的方式减少开销,即记录方法接收者的版本信息,并在之后比较版本信息,如果相同,则为单态内联缓存 ( $Monomorphic\ \ Inline\ \ Cache$ ),比普通方法调用的开销要小一些,因为不需要查询虚方法表;如果不同,则退化为超多态内联缓存 ( $Megamorphic\ \ Inline\ \ Cache$ ),相当于普通调用的方法开销。

3.2 逃逸分析

        逃逸分析 ( $Escape\ \ Analysis$ ) 并不是直接优化代码的手段,而是为其他优化措施提供依据的分析技术。基本原理是:分析对象动态作用域,对象在方法内被定义后又被外部方法引用,称为方法逃逸;被外部线程访问,称为线程逃逸。对象的逃逸程度按从低到高排为不逃逸、方法逃逸和线程逃逸。对于不逃逸或者逃逸程度低的对象,可以进行优化:

3.3 数组边界检查消除

        对于JVM的执行子系统来说,每次数组元素的读写都有一次隐式地边界检查。如果可以在编译期根据数据流分析确定数组长度,并对一些代码进行下标判断,就可以提前发现其是否越界,从而避免了每次执行时的判断,这个过程称为数组边界检查消除。

JVM(8):后端编译与优化