JVM即时编译器-你知道解释器与编译器是如何配合工作的吗?

概览

本文从主流JVM(Hotspot VM)代码执行时解释器与编译器的分工合作切入,首先学习解释执行与编译执行的关系,进而引出分层编译技术模型以及在此之下的“两器”交互过程。而后从编译触发条件出发重点论述了热点代码探索中的方法计数器、回边计数器。最后学习了C1(客户端编译器)基本工作过程以及C2(服务端编译器)的优秀特性。

解释器与编译器

对于Hotspot VM,默认情况下先由解释器解释执行Java代码,并在需要时为后端编译器执行代码性能监控。若某段代码执行频繁,被认为是“热点代码”,将会交由即时编译器进行编译优化转换为本地机器码,该段代码即转换为编译执行,这种由平台无关字节码转换为平台相关机器码的过程称之为后端编译。

其中即时编译器包含以下三款
前端编译器(C1)
客户端模式下启用,采用轻量级优化算法,致力于提供更高的编译速度
后端编译器(C2)
服务端模式下启用,一般采用重量级甚至激进优化算法,致力于提供更好的代码编译质量
Graal编译器
JDK 10引入,Java编写(便于维护),有望成为下一代编译器,致力于提供高编译效率、高编译代码输出质量、支持提前编译与即时编译。

以上提到解释器和三款即时编译器。同时也可以看到,解释器与编译器之间是互相配合工作的。细心的小伙伴还会发现上面提到了客户端、服务端模式,Hotspot会根据具体的资源环境调整运行模式,我们也可以采用-client、-server指定运行模式,该参数会影响即时编译器的选择。默认情况下,解释器和编译器依旧是配合工作的,这种情况为混合模式(Mixed Mode),当然也可以通过参数-Xint强制虚拟机解释执行,此时编译器不参与工作。也可用-Xcomp让VM优先以编译模式执行,解释器依旧参与工作;结合上下文我们知道,解释器相对于编译器除了提供性能监控、还作为激进优化失败后的“逃生门”。运行模式如下图
在这里插入图片描述

分层编译

编译出优化程度越高的代码越是需要占用程序运行时间,为了在前期程序运行响应与后期程序的运行效率之间相权衡,Hotspot从JDK7开始默认开启了分层编译。如下
0层:程序解释执行,不开启解释器性能监控
1层:启用C1(客户端编译器)编译执行,开启部分轻量级优化,不开启解释器性能监控
2层:C1编译执行,开启如方法计数、回边次数统计等部分热点代码监控
3层:C1编译执行,完全开启解释器性能监控,基于2层基础上还会收集分支跳转、虚方法调用版本等信息
4层:C2(服务端编译器)编译执行,相比C1将开启更多重量级、激进优化
虚拟机会根据实际情况在上述层级间进行跳转,进而使解释器、C1、C2互相配合工作;前期由解释器、C1获取更快的程序执行响应,为C2争取时间编译出质量更高的本地代码从而在后期获取更高的执行效率。
其交互关系如下(摘自周志明老师的《深入理解Java虚拟机》第三版)
在这里插入图片描述

编译对象与触发条件(热点代码探索)

从上可知Java代码可分解释和编译执行模式,基于分层编译下,JVM根据具体运用场景调整执行模式。基于二八法则,一般情况下约有20%代码占用了80%的CPU运转时间,这类代码可称之为“热点代码”;HotSpot会针对热点代码进行即时编译、优化成为本地机器码,以期获得后续的高效执行。据说这种热点代码探索的能力正是HotSpot命名的由来。那HotSpot是如何发现热点代码的呢?

热点代码包含以下两类

  1. 被多次调用的方法
  2. 被多次执行的循环体

而上述的“多次”如何界定?目前主流的热点判定方式包含以下两种
1、基于采样的热点探索
周期性地检查各个线程的调用栈顶所执行方法并进行记录,从而判断出哪些方法或代码块是经常被执行的。该方法实现简单并且可以方便获得方法调用关系,但会由于线程阻塞而导致误差。IBM J9 VM采用该种方法。

2、基于计数器的热点探索
额外建立并维护针对方法或代码块调用计数器,可以获得较为精准的的热点探索结果,但无法直接获得方法调用关系且实现相对复杂。HotSpot采用该方法。
针对上述两大热点代码类型,HotSpot采用了方法调用计数器(针对方法调用的计数),以及回边计数器(针对循环体执行的计数,“回边”也就是在循环边界上的往返跳转)。两类计数器都有对应阈值,一旦超过,该代码即会被判定为热点代码进进而触发即时编译。

(1)方法调用计数器
方法调用计数器是一个相对的值,其随着GC的执行而陷入热度衰减;一段时间内该方法调用计数器加上回边计数器数值未达到编译阈值即会发生减半;该阈值在客户(服务)端模式下为1500(10000),可通过-XX:CompileThreshold(CT)进行设定。同时上述这段时间称之为半衰周期,热度衰减伴随GC执行,可通过-XX:-UseCounterDecay关闭热度衰减,-XX:CounterHalfLifeTime设置半衰周期时间(s)。默认情况下,在触发编译之后,JVM会继续解释执行热点代码,直到该段代码编译完成,对应调用入口被自动改写,下一次开始执行该代码时用的就是经过标准编译优化后的代码版本了。

(2)回边计数器
再来看看运用于循环体代码执行次数记录的回边计数器;注意此处并非循环次数,如在空循环下视为自己跳转自己的过程中并不存在实际的控制流反向跳转,不会被回边计数器统计。和方法计数器类似,除了作用对象不同外还有以下主要区别。
(a)可以通过-XX:OnStackReplacePercentage(OSR,默认值:客户端933,服务端140)、-XX:InterpreterProfilePercentage(IPP,默认值为33)来间接设置阈值。
(b)没有热度衰减,记录的是循环执行次数,在计数器溢出时会将方法计数器值也调整到溢出状态,以在下次进入方法时执行标准编译
(c)回边计数器阈值需要通过具体模式下的公式计算得到:
客户端模式:方法调用计数器阈值CT(-XX:CompileThreshold)/OSR100,如此计算得到客户端默认阈值为13995.
服务端模式:(CT
OSR-IPP)/100,计算得默认阈值为10700。

当解释器遇到回边指令时会检查是否存在已编译代码版本,若没有则进行回边计数器自增并判断两个计数器数值之和是否超过回边计数器阈值,在超过的情况下提交栈上替换编译请求,并降低回边计数器值以便继续进行解释执行;此处和方法计数器类似,不会同步等待编译完成才执行程序,而是先进行解释执行,待编译动作完成后的下一次调用开始才使用编译代码版本。
执行过程如下图(摘自周志明老师的《深入理解Java虚拟机》第三版)
在这里插入图片描述

编译过程

从上我们知道,默认情况下无论是标准编译还是栈上替换编译,在后台编译未完成之前都是按解释执行代码。当然我们也可以通过-XX:-BackgroundCompilation来关闭后台编译,如此一来,在达到热点探测阈值后将会同步等待代码编译完成才继续执行编译后代码。

编译期间,编译器会做那些工作呢?以下根据C1、C2分别讨论。
C1(客户端编译器)
有以下三个阶段
(1)前端平台无关字节码转变为高级中间代码表示(HIR,High-Level Intermediate Representation
):期间包含方法内敛、常量传播等优化,生成的HIR与目标机器指令集无关,为下一阶段的编译优化做准备。
(2)从HIR中生成平台相关的地基低级中间表示(LIR):期间会做空值、范围检查消除等优化。
(3)基于LIR做线性扫描算法并分配寄存器、窥孔优化,而后生成机器代码。
以上过程如下(摘自周志明老师的《深入理解Java虚拟机》第三版)
在这里插入图片描述

C2(服务端)
基于传统编译优化技术(无用代码消除、循环展开、常量传播、范围检查等)上增加了激进优化技术,如守护内敛、逃逸分析、分支频率与预测。C2采用全局图着色分配器进行寄存器分配,可以充分利用某些处理器架构上的大寄存器集,如RISC(精简指令集)架构,结合各大优化技术,可以获得比C1更加高质量的代码编译产出,从而抵消了额外的编译时间开销。虽然其编译速度比C1慢,但依旧远远超过了传统静态优化编译器。当然,C2涉及到的技术还有很多,以上每一项技术的只言片语背后都是一款虚拟机最为复杂、最体现技术水准的部分。

参考文献/延伸阅读

1、《深入理解Java虚拟机》第三版 周志明
2、Hotspot 热点代码编译和栈上替换 源码解析
3、常量传播
4、编译器工程架构

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值