后端编译与优化

1,118 阅读11分钟

本书部分摘自《深入理解 Java 虚拟机第三版》

概述

前面讲过前端编译是将 Java 源代码编译成 Class 字节码,那么后端编译就对应把 Class 文件转换成与本地机器相关的二进制机器码的过程。然后 JVM 把每一条要执行的字节码交给解释器,翻译成对应的机器码,由解释器执行,Java 程序就运行起来了

即时编译器

当虚拟机发现某个方法或代码块运行特别频繁,就会把这些代码认定为热点代码(HotSpot Code),为了提高热点代码的运行效率,在运行时,虚拟机将会把这些代码编译为本地机器码,并以各种手段进行代码优化,在运行时完成这个任务的后端编译器被称为即时编译器

1. 编译对象

热点代码主要有两类:

  • 被多次调用的方法
  • 被多次执行的循环体

对于这两种情况,编译的目标对象都是整个方法体。第一种情况,由于是依靠方法调用触发的编译,以整个方法为编译对象毫无疑问。而后一种情况,虽然编译器仍以整个方法作为编译对象,但执行入口(从方法第几条字节码执行开始执行)会稍有不同。

2. 触发条件

如何判断热点代码?是不是需要进行即时编译?这个行为称为热点探测(Hot Spot Code Detection),进行热点探测并不一定要知道方法具体被调用了多少次,目前主流的热点探测判定方式有两种:

  • 基于采样的热点探测(Sample Based HotSpot Code Detection)

    虚拟机会周期性地检查各个线程的调用栈顶,如果发现某个(某些)方法经常出现在栈顶,那这个方法就是热点方法。这种方式的好处是实现简单高效,可以很容易获取方法调用关系(将调用堆栈展开即可),缺点是很精确地确定一个方法的热度,容易受线程阻塞或别的外界因素的影响

  • 基于计数器的热点探测(Counter Based HotSpot Code Detection)

    虚拟机为每个方法(甚至代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定阈值就认为是热点方法。这种统计方式实现起来麻烦一些,不能直接获取方法的调用关系,但结果相对来说更加精确

HotSpot 采用基于计数器待热点探测方法,同时准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter,回边的意思是指在循环边界往回跳转)。当虚拟机运行参数确定的前提下,这两个计数器都有一个明确的阈值,一旦溢出,就会触发即时编译。

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

如果没做过任何设置,执行引擎默认不会同步等待编译请求完成,而是继续进入解释器执行字节码,直到被提交的请求被即时编译器编译完成,当编译工作完成后,这个方法的调用入口地址就会被系统自动改写成新值,下一次调用该方法就会使用已编译的版本

默认设置下,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍不足以让它提交给即时编译器编译,那该方法的调用计数器就会减半,这个过程称为方法调用计数器热度的衰减(Counter Decay),这个动作是在虚拟机进行垃圾收集时顺便进行的,也可以关闭热度衰减,让虚拟机统计方法调用的绝对次数,这样时间长了,程序中绝大部分方法都会被编译成本地代码

再看一看回边计数器,它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令就称为回边(Back Edge)。当解释器遇到一条回边指令,会先查找将要执行的代码片段是否有已经编译好的版本,如果有的话,将优先执行已编译的代码,否则就把回边计数器的值加一,然后判断方法调用器与回边计数器之和是否超过回边计数器的阈值。当超过阈值,将提交一个栈上替换编译请求,并且把回边计数器的值稍微降低,以便继续在解释器中执行循环,等待编译器输出编译结果

回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是方法循环执行的绝对次数。当计数器溢出时,它还会把方法计数器的值也调整为溢出状态,这样下次再进入这个方法的时候就会执行标准编译过程了

提前编译器

现在提前编译器的研究两条明显的分支:在程序运行前把程序代码编译成机器码的静态翻译工作,以及把原本即时编译器在运行时要做的编译工作提前做好并保存,下次运行到这些代码时直接把它加载进来使用

第一种传统的提前编译应用形式,它是为了解决即时编译的最大弱点:即时编译要占用程序的运行时间和时间资源。而第二种方式,本质是给即时编译器做缓存加速。

提前编译器因为没有执行时间和资源限制的压力,可以毫无忌惮地使用重负载的优化手段,这是一个极大的优势,但即时编译器也有它的长处:

  • 性能分析制导优化(Profile-Guided Optimization)

    即时编译器在运行过程中,会不断地收集性能监控信息,譬如条件判断通常走哪个分支、循环会进行几次等等,这些数据一般在静态分析时是无法得到的,或者说不能得到一个明确的解。但在动态运行时却能看出它们具有非常明显的偏好性,比如一个条件分支的某一路径执行频繁,就可以对热点代码进行优化和分配更多的资源

  • 激进预测性优化(Aggressive Speculative Optimization)

    静态优化必须保证优化前后的程序对外部可见影响(不仅仅是执行结果)是等效的,而即时编译可以不必如此保守,如果性能监控监控信息能够支持它做出一些正确的可能性很大但无法保证绝对正确的预测判断,就可以大胆地按照高概率的假设进行优化,万一真的走到罕见分支上,大不了退回到低级编译器甚至解释器上去执行,并不会出现无法挽救的后果

  • 链接时优化

    Java 语言天生就是动态链接的,一个个 Class 文件在运行期被加载到虚拟机内存中,然后在即时编译器里产生优化后的本地代码

编译器优化技术

编译器的目标虽然是做由程序代码翻译为本地机器码的工作,但其难点并不在于能否成功翻译出机器码,输出代码优化质量才是决定编译器优秀与否的关键

1. 方法内联

方法内联就是把目标方法的代码原封不动地复制到发起调用的方法之中,避免发生真实的方法调用。方法内联听上去很简单,但实现并不简单,因为有方法解析和分派机制。只有使用 invokespecial 指令调用的私有方法、实例构造器、父类方法、使用 invokestatic 指令调用的静态方法和被 final 修饰的方法,这些方法会在编译器解析。而其他 Java 方法必须在运行时进行方法接收者的多态选择,它们都有可能有多于一个版本的方法接收者

为了解决这个难题,Java 虚拟机引入了一种名为类型继承关系分析的技术,用于确定在目前已加载的类中,某个接口是否有多于一种的实现、某个类是否存在子类、某个子类是否覆盖了父类的某个虚方法等信息。这样,如果遇到非虚方法,直接内联即可。如果查到只有一个版本,也直接内联,这种内联称为守护内联(Guarded Inlining)。不过由于 Java 程序是动态连接的,有可能会有新的类型加载进来,所以守护内联属于激进预测性优化,必须预留好退路。假如继承关系发生变化,那么就必须抛弃已编译的代码,退回到解释状态进行执行,或重新编译

如果方法存在多个版本的目标方法可供选择,虚拟机将使用内联缓存(Inline Cache)来缩减方法调用的开销。内联缓存是一个建立在目标方法正常入口之前的缓存,如果未发生方法调用,内联缓存状态为空,当第一次调用发生后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者的版本。如果每次都一致,就直接使用,否则查找虚方法表进行方法分派

2. 逃逸分析

逃逸分析的基本原理是:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法引用,例如作为调用参数传到其他方法中,这称为方法逃逸;甚至有可能被外部线程访问,这称为线程逃逸

根据一个对象的逃逸程度,可以进行不同程度的优化:

  • 栈上分配

    对象是在栈上分配内存的,主要持有这个对象的引用,就可以访问堆中存储的对象数据。如果确定一个对象不会逃逸出线程之外,可以让这个对象在栈上分配分配内存

  • 标量替换

    若一个数据已经无法再分解成更小的数据表示,如原始数据类型,那么这些数据就称为标量。相对的,如果一个数据可以继续分解,那就称为聚合量,如对象。如果一个对象不会被方法外部访问,那这个对象就可以拆成多个标量,替换原来引用对象的成员变量的地方

  • 同步消除

    如果一个变量不会逃逸出线程,那么这个变量的读写肯定不会有竞争,对这个变量实施的同步措施也就可以安全地消除掉

3. 公共子表达式消除

如果一个表达式 E 之前已经被计算过,而且从先前的计算到现在 E 中所有变量的值都没有变化,那么 E 就称为公共子表达式。之后就没有必要再花时间重新计算了,直接用之前的计算结果代替 E 即可

假设有如下代码

int d = (c * b) * 12 + a + (a + b * c);

编译器检测到 c * bb * c 是一样的表达式,而且 b 与 c 的值不变,因此这条表达式可能被视为

int d = E * 12 + a + (a + E);

4. 数组边界检查消除

我们知道 Java 中的数组不能越界访问,否则会抛出一个运行时异常,这得益于系统会自动进行上下文的范围检查。但如果每次对数组元素的读写都要检查一次,无疑是一种负担。可无论如何,数组边界安全检查是肯定要做的,不过虚拟机会在编译期根据数据分析流判断数组下标有没有越界的可能,避免过多的开销