编译器无论在何时、在何种状态下把Class文件转换成与本地基础设施相关的二进制机器码,他都可以视为整个编译过程的后端。
后端编译器编译性能的好坏、代码优化质量的高低却是衡量一款商用虛拟机优秀与否的关键指标之一。
1. 即时编译器
即时编译器是一个把Java的字节码(包括需要被解释的指令的程序)转换成可以直接发送给处理器的指令的程序。
目前主流的两款商用Java虚拟机(HotSpot、 OpenJ9)里,Java程序最初都是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”(Hot Spot Code),为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成本地机器码,并以各种手段尽可能地进行代码优化,运行时完成这个任务的后端编译器被称为即时编译器。
1.1. 解释器与编译器
尽管并不是所有的Java虛拟机都采用解释器与编译器并存的运行架构,但目前主流的商用Java虚拟机,譬如HotSpot、OpenJ9等, 内部都同时包含解释器与编译器],解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。
当程序运行环境中内存资源限制较大,可以使用解释执行节约内存(如部分嵌入式系统中和大部分的JavaCard应用中就只有解释器的存在),反之可以使用编译执行来提升效率。
1.2. 编译对象与触发条件
被判断为“热点代码”的标准:
-
被多次调用的方法。
-
被多次执行的循环体。
被多次调用的方法:前者很好理解,一个方法被调用得多了,方法体内代码执行的次数自然就多,它成为“热点代码”是理所当然的。
被多次执行的循环体:为了解决当一个方法只被调用过一次或少量的几次,但是方法体内部存在循环次数较多的循环体,这样循环体的代码也被重复执行多次,因此这些代码也应该认为是“热点代码”。
即时编译被触发的条件
热点探测:判断某段代码是不是热点代码,是不是需要触发即时编译。
目前主流的热点探测判断方式:
.基于采样的热点探测(SampleBasedHotSpotCodeDetection)。采用这种方法的虛拟机会周期性地检查各个线程的调用栈顶,如果发现某个(或某些)方法经常出现在栈项,那这个方法就是“热点方法”。基于采样的热点探测的好处是实现简单高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。
.基于计数器的热点探测(Counter Based Hot Spot Code Detection) 。采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。这种统计方法实现起来要麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系。但是它的统计结果相对来说更加精确严谨。
HotSpot使用的是第二种基于计数器的热点探测,方法,为了实现热点计数,HotSpot为每个 方法准备了两类计数器:方法调用计数器( Invocation Counter)和回边计数器( Back Edge Counter),“回边”的意思就是指在循环边界往回跳转)。当虚拟机运行参数确定的前提下,这两个计数器都有一个明确的阈值,计数器阈值一旦溢出,就会触发即时编译。
调用计数器的使用规则:当一个方法被调用时,虚拟机会先检查该方法是否存在被即时编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将该方法的调用计数器值加一,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值。一旦已超过阈值的话,将会向即时编译器提交一个该方法的代码编译请求。
在默认设置下,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那该方法的调用计数器就会被减少一半,这个过程被称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time)。
回边计数器:它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令就称为“回边”,目的:很显然,建立回边计数器统计的目的是为了触发栈上的替换编译。
两者的不同之处
与方法计数器不同,回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。
1.3. 编译过程
在默认条件下,无论是方法调用产生的标准编译请求,还是栈上替换编译请求,虚拟机在编译器还未完成编译之前,都任然将按照解释方式继续执行代码,而编译动作则在后台的编译线程中进行。
客户端的编译过程:
他是一个相对简单快速的三段式编译器,主要关注点在局部优化,而放弃许多耗时较长的全局优化手段。
-
一个平台独立的前端将字节码构造成一种高级中间代码表示。
-
一个平台相关的后端从HIR中产生低级中间代码表示。
-
最后阶段是在平台相关的后端使用线性扫描算法。
服务端编译器
而服务端编译器则是专门面向服务端的典型应用场景,并为服务端的性能配置针对性调整过的编译器,也是一个能容忍很高优化复杂度的高级编译器。
它会执行大部分经典的优化动作,如:无用代码消除( Dead Code Elimination)、循环展开(Loop Unrolling)、循环表达式外提(Loop Expression Hoisting)、消除公共子表达式(Common Subexpression Elimination)、常量传播(Constant Propagation)、基本块重排序( Basic Block Reordering)等,还会实施一些与Java语言特性密切相关的优化技术,如范围检查消除( Range Check Elimination)、空值检查消除(NullCheckElimination,不过并非所有的空值检查消除都是依赖编译器优化的,有一一些是代码运行过程中自动优化了)等。另外,还可能根据解释器或客户端编译器提供的性能监控信息,进行一些不稳定的预测性激进优化,如守护内联(GuardedInlining)、分支频率预测( Branch Frequency Prediction)。