JVM之后端编译与优化

 把class文件转换成与本地基础设施(硬件指令集、操作系统)相关的二进制机器码,这个过程可以看做是整个编译过程的后端。

  JVM 的前端编译器: Javac。后端编译器:即时编译器(JIT 编译器)和提前编译器(AOT 编译器)。

 java虚拟机规范中并没有规定必须包含即时编译或者提前编译,但是这些是提升编译的一个方案,用来衡量虚拟机的品质。以下提到的即时编译器特值HotSpot虚拟机内置的即时编译器,虚拟机特指HotSpot虚拟机。

1. 即时编译器

 目前主流的两款商用 JVM(HotSpot、OpenJ9)中,Java 程序最初都是通过「解释器(Interpreter)」解释执行的,当 JVM 发现某个方法或代码块的执行特别频繁,就会认为它们是“热点代码(Hot Spot Code)”。

 为了提高热点代码的执行效率,JVM 会在「运行时」把这部分代码编译成本地机器码,并用各种手段去优化代码。运行时完成这个任务的后端编译器被称为「即时编译器」。

1.1 解释器与编译器

解释器(Interpreter):工作在「运行时」,对源码一行一行的解释然后执行,然后返回结果。

编译器(Compiler):往往是在「执行」之前完成,产出是一种可执行或需要再编译或者解释的「代码」。

编译器和解释器的理解

 HotSpot中内置了2或3个编译器:

  • 客户端编译器(Client Compiler,C1)
  • 服务端编译器(Server Compiler,C2,也叫Opto编译器)
  • Graal编译器(JDK 10 出现用于替代 C2)

 分层编译出现前,解释器通常和其中一个编译器搭配使用。虚拟机默认会根据硬件来性能自动选择虚拟机运行在客户端模式还是服务器模式,也可以-clent -server手动指定。

解释执行:启动速度快。
C1 编译执行:预热较快,运行时,执行快,相对更高的编译速度。
C2 编译执行:需要较慢,运行时,执行快,相对更好的编译质量。

 现在的解释器和编译器并不是单独工作了,而是交互执行。

交互执行

1.2 分层编译

 在分层编译的工作模式出现前,HotSpot 虚拟机通常时采用解释器与其中一个编译器直接搭配的方式工作。为了在程序启动相应速度与运行效率之间达到最佳平衡,HotSpot 虚拟机在编译子系统中加入了分层编译功能。

 解释器和编译器搭配使用的方式称为”混合模式“。

1
2
3
java -version        //Java HotSpot(TM) 64-Bit Server VM (build 25.211-b12, mixed mode)
java -Xint -version //Java HotSpot(TM) 64-Bit Server VM (build 25.211-b12, interpreted mode)
java -Xcomp -version //Java HotSpot(TM) 64-Bit Server VM (build 25.211-b12, compiled mode)
  • level 0:interpreter 解释执行。
  • level 1:C1 编译,无 profiling(性能监控)
  • level 2:C1 编译,仅方法及循环 back-edge 执行次数的 profiling
  • level 3:C1 编译,除 level 2 中的 profiling 外还包括 branch(针对分支跳转字节码)及 receiver type(针对成员方法调用或类检测,如 checkcast,instnaceof,aastore 字节码)的 profiling
  • level 4:C2 编译,将字节码编译成本地代码。相比客户端,会启用更多耗时长的优化。

分层编译

 以上层次并不是一层不变的,根据不同的运行参数,虚拟机可以调整分层的数量,分层编译的交互如下如所示:

分层编译交互

上图列举了4 种编译模式(非全部)。

  1. 通常情况下,一个方法先被解释执行(level 0),然后被C1 编译(level 3),再然后被得到profile 数据的C2 编译(level 4)。
  2. 如果编译对象非常简单,虚拟机认为通过C1 编译或通过C2 编译并无区别,便会直接由C1 编译且不插入profiling 代码(level 1)。
  3. 在C1 忙碌的情况下,interpreter 会触发profiling,而后方法会直接被C2 编译;
  4. 在C2 忙碌的情况下,方法则会先由C1 编译并保持较少的profiling(level 2),以获取较高的执行效率(与3 级相比高30%)。

 实施分层编译后,解释器、客户端编译器和服务端编译器会同时工作,热点代码可能会被多次编译。用C1获取更高的编译速度,用C2获取更好的编译质量,在解释执行时也无需承担额外性能监控的任务,在C2采用高复杂度的优化算法时,C1可以采用简单优化来为它争取更多的编译时间。

1.2 热点代码和热点探测

热点代码

 运行时会被即时编译器编译的目标是“热点代码”,主要包括下面两类:

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

 前者是jvm标准的即时编译,以整个方法作为编译对象。

 后者依然以方法作为编译对象,但是触发即时编译的入口不一样(即不是从方法的第一行开始)。编译时会传入执行入口点的字节码序号(ByteCodeIndex,BCI)。由于该情况发生在方法执行的过程中,也被称为栈上替换(On Stack Replacement,OSR)。也就是方法的栈帧还在栈上,但方法已经被替换了。

热点探测

 只有探测出了该代码是热点代码,才会执行即时编译。主流的热点探测方式有以下2种:

  1. 基于采样的热点探测(Sample Based Hot Spot Code Detection)

    周期性的去检查一下所有线程的调用栈顶,若发现某些方法经常出现在栈顶,该方法就会被认为是“热点代码”。J9 虚拟机使用过该方法。

    优点:实现简单高效,而且可以通过堆栈信息获取到方法之间的调用关系;

    缺点:难以精确的确定方法热度,容易受到线程阻塞的干扰(即方法阻塞时可能长时间处于栈顶,可能产生误判)。

  2. 基于计数器的热点探测(Counter Based Hot Spot Code Detection)

    为每个方法(或代码块)建立计数器来统计方法的执行次数,当次数超过一定的阈值就认为是“热点代码”。HotSpot 虚拟机就是使用该方法进行探测的。

    优点:统计结果更加精确严谨;

    缺点:统计起来稍麻烦(要为每个方法建立并维护计数器),而且不能直接获取到方法的调用关系。

 在HotSpot中采用的是基于计数器的热点探测技术。HotSpot使用了2种计数器:

  • 方法调用计数器(Invocation Counter):统计方法被调用的次数。

    客户端阈值:1500。服务端阈值:10000。可以使用-XX:CompileThreadhod设定。

  • 回边计数器(Back Edge Counter):统计方法中循环体代码执行的次数,目的是触发栈上替换。

    字节码中遇到控制流向后跳转的指令称为“回边”。

方法调用计数器流程

 方法调用计数器统计的并非方法被调用的绝对次数,而是是一个相对的执行频率。

 在一段时间内,如果方法的调用次数未到达阈值,计数器就会减少为原先的一半。该过程被称为热度衰减(Counter Decay),这段时间则被称为半衰周期(Counter Half Life Time)。这样的话,只要程序运行的时间足够长,程序中大部分代码都会被编译成本地代码。-XX: UseCounterDecay来设置关闭热度衰减,-XX: CounterHalfLifeTime设置半衰周期(秒)。

回边计数器流程

回边计数器流程

-XX:CompileThreadhod阈值。

-XX: OnStackReplacePercentage调整回边计数器的阈值的OSR比例,默认933。

-XX:InterpreterProfilePercentage 解释器监控比率,默认 33。

 客户单模式下,阈值 = 阈值 * OSR比例 / 100 ,默认为13995。

 服务器模式下,阈值 = 阈值 * (OSR比例 - 解释器监控比率) / 100 ,默认为10700。

 回边计数器没有热度衰减,因此这个值就是循环的绝对次数。

 以上两个附图实际上描述的是客户端模式下的探测过程,服务端模式下,更复杂。

1.3 编译过程

 默认情况下, 不论是方法调用产生的标准编译请求还是栈上替换编译请求,在编译器完成编译之前,虚拟机都将按照解释方式执行代码,而编译动作会在后台继续进行。可使用-XX: -BackgroundCompilation来禁止后台编译,禁止后,如果触发了即时编译,执行线程会向虚拟机提交编译请求,然后阻塞直至编译完成,然后执行编译器输出的本地代码。

 那么在后台编译的过程中,编译器具体会做什么事情呢?

客户端编译器编译过程

 编译器主要目的是提供各种优化手段,客户端编译器的关注点在于局部性的优化,而服务端的关注点在耗时较长的全局性优化。

 客户端编译的优化过程分为3个阶段:

  • 阶段1:将字节码构造成高级中间代码表示(High-Level Intermediate Representation,HIR,与目标机器指令集无关的中间表示)。
    • 在构造HIR之前,编译器会在字节码上做部分基础优化,比如方法内联、常量传播等。
    • HIR使用静态单分配(Static Single Assignment,SSA)的形式来表示代码值,这样可以让一些在HIR的构造过程之中和之后进行的优化动作更容易实现。
  • 阶段2:将HIR构造成低级中间代码表示(LIR,与目标机器指令相关的中间表示)。
    • 在构造LIR之前,编译器会在HIR上完成一些优化,比如空值检查消除、范围检查消除等,
  • 阶段3:使用线性扫描算法(Liner Scan Register Allocation)在LIR上分配寄存器、并在LIR上做窥孔(Peep hole)优化,然后产生机器代码。这一部分和平台相关,也就是系统平台相关,比如x86架构会产生x86指令,arm产生arm指令等,

 客户端编译器执行过程如下图所示:

client compile

服务端编译器编译过程

 服务端编译器是专门面向服务端的典型应用场景,并特意针对服务端配置调整过的编译器,是一个能容忍高复杂优化的高级编译器。塔会执行大部分经典的优化动作:

  • 无用代码消除(Dead Code Elimination)
  • 循环展开(Loop Unrolling)
  • 循环表达式外提(Loop Expression Hoisting)
  • 消除公共子表达式(Common Subexpression Elimination)
  • 常量传播(Constant Propagation)
  • 基本块重排序(Basic Block Reordering)
  • ……
  • 范围检查消除(Range Check Elimination)
  • 空值检查消除(Null CheckElimination )
  • ……
  • 守护内联(Guarded Inlining)
  • 分支频率预测(Branch Frequency Prediction)
  • ……

服务端编译是一个复杂的过程,部分细节参考1.4

1.4 实战:查看及分析即时编译结果

需要支持FastDebug或者SlowDebug优化级别的HotSpot虚拟机才能支持,Produce级别无法使用。因此需要自行编译jdk源码,等我编译了再来写。

2. 提前编译器

2.1 提前编译的优缺点

 提前编译器(Ahead Of Time Compiler,AOT编译器):直接把程序编译成与目标机器指令集相关的二进制代码的过程。目前有两种主要的实现方式:

  • 1.与传统C/C++编译器类似,在程序运行之前把程序代码编译成机器码的静态翻译工作

  • 2.把原本即时编译器在运行时要做的编译工作提前做好并保存下来,下次运行到这些代码(譬如公共库代码在被同一台机器其他Java进程使用)时直接把它加载进来使用

 即时编译缺点:要占用程序运行时间和运算资源,即使现在的JIT编译器已经很先进,采用分层编译可以先用快且低质量的即时编译器为高质量的即时编译器争取出更多时间,但是消耗的运算资源都是原本可以用于程序运行的。例如最耗时的优化措施之一“过程间分析”,需要在全程序范围内做大量工作。

 如果是在程序运行之前进行静态编译,这些耗时操作就可以大胆的进行,因此方式1弥补了即时编译的缺点。

 方式2本质上是给即时编译器做缓存加速,去改善Java程序的启动时间,以及需要一段时间预热之后才能达到最高性能的问题。这种提前编译被称为动态提前编译或者直接叫即时编译缓存。HotSpot运行时可以直接加载这些编译结果,实现快速程序启动速度,减少程序达到全速运行状态所需要的时间。

JIT编译器相比AOT编译器的优点:

  • 性能分析制导优化。解释器和客户端编译器在运行期间会不断收集性能监控信息,这些信息一般无法在静态分析是获得,或者不一定存在唯一的解,但在动态运行时很容易得到。
  • 激进预测性优化
    • 静态优化无论何时都必须保证优化后的所有程序外部可见影响(不仅仅是执行结果)与优化前必须是一致的,否则优化会导致程序报错或不对。
    • 即时编译的策略就可以不必那么保守,如果性能监控信息能够支持它做出一些正确的可能很大但是无法保证绝对正确的预测判断,就可以进行大胆的优化,大不了退回到低级i按一起甚至解释器上运行。而这样的优化往往能够大幅度降低目标程序的复杂度,输出运行速度非常高的代码。
    • 链接时优化。由于Java天生是动态连接的,一个个class文件在运行期被加载到虚拟机内存中,然后在即时编译器里产生优化后的代码。而C++等的主程序和动态链接库代码在编译时是完全独立的,当跨界调用的时候,需要做相应的优化。提前编译无法做到链接后的优化。

2.2 实战:Jaotc的提前编译

1
2
3
4
5
6
7
# 生成字节码文件
javac Hello.java
# 生成静态链接库,jaotc在jdk/bin目录下,jdk9及其之后才有
jaotc --output libHello.a Hello.classs

# 现在可以使用这个静态链接库来执行程序了
java -XX:AOTLibrary=./libHello.a Hello ## Hello为类名

 这是个很小的jaotc提前编译的例子,目前为止jaotc还有许多需要完善的地方,仍然难以直接编译spring boot、mybatis等常见的第三方工具库,甚至众多java模块中,只能顺利编译的只有java.base模块。但是随着Graal编译器的逐渐成熟,Jaotc未来可期。

3. 编译器优化技术

https://wiki.openjdk.java.net/display/HotSpot/PerformanceTacticIndex

 以上是HotSpot团队列出的比较全面的即时编译器优化技术,特别多,但是不难理解,举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 原始代码
public class B {
int value;
final int get(){
return value;
}
}
public void foo() {
y = b.get();
//do sth
z = b.get();
sum = y + z;
}

 以上代码看起来很简单,实际上仍然可以优化。

第1步,优化内联

 优点:1.去除调用方法成本(方法版本查找,建立栈帧等)。2.为其他优化建立良好的基础。优化后,代码如下:

1
2
3
4
5
6
public void foo(){
y = b.value;
//do sth
z = b.value;
sum = y + z;
}

第2步,冗余访问消除
 假设注释部分的do sth不会改变b.value的值,那么y=b.value保证了yb.value的值一致,如此则不必再访问b的局部变量了。如果b.value看作是表达式,那么该优化可以看作是一种公共子表达式消除。优化后代码:

1
2
3
4
5
6
public void foo(){
y = b.value;
//do sth
z = y;
sum = y + z;
}

第3步,复写传播

 程序中没必要使用一个额外的变量z,它完全等于y,因此可以使用y代替z。复写传播后代码如下:

1
2
3
4
5
6
public void foo(){
y = b.value;
//do sth
y = y;
sum = y + y;
}

第4步,无用代码消除

 无用代码:完全没意义的代码,或者永远不会被执行的代码。优化后,代码如下:

1
2
3
4
5
public void foo(){
y = b.value;
//do sth
sum = y + y;
}

 优化后的代码在效果上和原始代码一致,但是在字节码和机器码指令的执行上,可就大大提高了效率。

 优化手段众多,其中最具代表性的优化技术有以下4种:

  • 方法内联:最重要
  • 逃逸分析:最前沿
  • 公共子表达式消除:最经典,语言无关
  • 数组边界检查消除:最经典,语言相关

3.1 方法内联

 最重要的优化技术之一,是所有优化的基础,被业内称为“优化之母”。

 方法内联:把目标代码的源代码原封不动的“复制”到发起调用的方法之中,避免发生真实的调用。

 注意:这里的代码指的被编译后的字节码或者机器码。

1
2
3
4
5
6
7
8
9
10
public static void foo(Object obj) {
if (obj != null) {
System.out.println("hello");
}
}

public static void testInline() {
Object obj = null;
foo(obj);
}

 方法内联看起来简单,实际上实现起来很复杂。由于方法调用中,虚方法只有在运行期间才能确定下来方法版本,因此编译器在编译时很难确定方法版本。又由于java是面向对象编程,对象的默认方法就是虚方法,所以大大增加了内联的难度。

 为了解决这个问题,C/C++选择了在默认方法前加上final,使得默认方法不为虚方法,当需要用到多态的时候,用virtual关键字来修饰。但是java选择了在虚拟机中解决这个问题。

 为了解决虚方法的内联问题,Java虚拟机引入了类型继承关系分析(Class Hierarchy Analysis,CHA)技术。主要用于确定整个应用程序范围内,目前已加载的类中,某个接口是否有多于一种实现、某个类是否存在子类、某个子类是否覆盖了父亲的某个虚方法等信息。这样在进行内联时,会根据情况来进行不同的内联。

  • 如果是非虚方法,不必选择方法版本,直接进行内联,这种方式百分之百安全。
  • 如果是虚方法,则查询CHA,确定是否真的有多个版本可供选择
    • 如果只有一个版本,则“假设程序就是按照当前来运行的”,进行内联,这种方式叫守护内联(Guarded Inlining)。但是由于程序动态链接,说不定下一刻就会加载新的类型从而更改CHA结论,因此该方法属于激进预测性优化,必须留好退路。如果一直没有加载新的类型,则继续进行下去,如果加载了,则抛弃已经编译好的代码,进行解释执行或者重新编译。
    • 如果有多个版本,那么JIT编译器会使用内联缓存(Inline Cache)的方式来缩减方法调用开销。内联缓存是一个建立在目标方法正常入口之前的缓存。
      • 未发生方法调用之前,内联缓存状态为空。
      • 当第一次调用发生后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者的版本。
      • 以后进来的每次调用的方法接收者版本都是一样的,那么这时它就是种单态内联缓存(Monomorphic Inline Cache),通过这种方式,开销仅仅只是比非虚方法多了一次类型判断而已。
      • 如果出现方法接收者不一致, 就说明程序用到了 虚方法的多态特性, 这时候会退化成超多态内联缓存(Megamorphic Inline Cache),其开销相当于真正查找虚方法表来进行。

3.2 逃逸分析

 原理:分析对象动态作用域,当一个对象在方法里面被定义之后,它可能被外部方法所引用,例如作为调用参数传入到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸

  对象逃逸程度由低到高:从不逃逸、方法逃逸、线程逃逸。根据逃逸程度可以进行不同程度的优化:

  • 栈上分配(Stack Allocation):对一个不会逃逸出线程的对象在栈上分配就是一个不错的主意,对象所占用的内存会随着栈桢的出栈而销毁,大量对象会随着方法的结束而自动销毁,垃圾收集系统也会减少压力。栈上分配支持方法逃逸,不支持线程逃逸。
  • 标量替换(Scalar Replacement):把Java对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换。假如逃逸分析证明一个对象不会被方法外部访问,并且可以将这个对象拆散,那么程序真正执行的时候就不会去创建该对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替,而且这些成员变量还可以分配在栈上。不允许对象逃逸出方法范围。
  • 同步消除(Synchronization Elimination):线程同步本身是一个相当耗时的过程,如果逃逸分析·能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施就可以安全的消除掉。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
//原始代码
public class Point {
private int x;
private int y;
// getter/setter ...
}
public int test(int x) {
int xx = x + 2;
Point p = new Point(xx, 42);
return p.getX();
}



// 方法内联优化后
public int test(int x) {
int xx = x + 2;
Point p = point_memory_alloc(); // 堆中分配内存示意方法
p.x = xx; // Point 构造函数内联后
p.y = 42;
return p.x; // p.getX() 方法内联后
}



// 逃逸分析:标量替换优化后
public int test(int x) {
int xx = x + 2;
int px = xx; // 标量替换
int py = 42;
return px;
}


// 无效代码消除优化
public int test(int x) {
return x + 2;
}

不过现在逃逸分析仍然不成熟,原因是逃逸分析的计算成本非常高,甚至不能保证逃逸分析带来的性能收益会高于它的消耗。

-XX:+DoEscapeAnalysis:手动开启逃逸分析

-XX:+PrintEscapeAnalysis:查看分析结果

-XX:+EliminateAloocations:开启标量替换

-XX:+EliminateLocks:开启同步消除

-XX:+PrintEliminateLocks:查看同步消除情况

3.3 公共子表达式消除

 定义:如果一个表达式E之前就被计算过了,并且先前的计算到现在E中所有变量的值都没有改变过,那么E的这次出现就称为公共子表达式。对于这种表达式,没有必要花时间在对它重新进行计算,只需要直接用前面计算过的表达式结果替代E。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int d = (c * b) * 12 + a + (a + b * c);

//未优化前,它的字节码指令执行如下:每次都会重新计算
6: iload_3
7: iload_2
8: imul # 计算 b*c
9: bipush 12
11: imul # 计算 (c * b) * 12
12: iload_1
13: iadd # 计算 (c * b) * 12 + a
14: iload_1
15: iload_2
16: iload_3
17: imul # 计算 b*c
18: iadd # 计算 (a + b * c)
19: iadd # 计算 (c * b) * 12 + a + (a + b * c)
20: istore 4
22: iload 4
24: ireturn

 这段代码进入即时编译器后,将进行优化,编译器检测到 c * b 与 b * c 是一样的表达式,且在计算期间 b 和 c 的值不变,因此:int d = E * 12 + a + (a + E);,此时,编译器还可能进行代数化简int d = E * 13 + a + a;这样计算起来就可以节省一些时间。

3.4 数组边界检查消除

 由于Java语言是一门动态安全检查的语言,对于数组foo[],访问数组元素foo[i]的时候系统会自动进行上下界范围检查,即i必须满足i>=0 && i<foo.length的访问条件,否则将抛出运行时异常。这样每一次读写都要进行一次检查无疑是一种负担。

 有时数组边界检查不是必须继续进行的,此时就可以省略。

 例如数组下标是一个常量,如foo[3],只要在编译期根据数据流分析来确定foo.length的值,并判断下表“3”没有越界,执行时的时候就无需判断了。

 又比如对于数组访问发生在循环中,并且使用循环变量对数组进行访问。如果编译器只要通过数据流分析就可以判定循环遍历取值范围永远在[0, foo.length)之内,那就可以把数组边界检查消除。

 隐式异常优化:

1
2
3
4
5
6
7
8
9
10
11
12
13
if(foo != null){
return foo.value;
}else{
throw new NullPointException();
}

//隐式异常优化后,伪代码
try{
return foo.value;
}catch(segment_default){
uncommon_trap();
}
//当程序中大量foo不为空的时候,可以节省开销,因为只有为空的时候才判断。

4. 实战:深入理解Graal编译器

 从JDK10起,HotSpot就同时拥有3款不同的即时编译器,C1、C2和Graal编译器。

 Graal编译器支持提前编译和即时编译。JDK9事,Graal编译器以Jaotc提前编译工具的形式加入官方JDK,从JDK10起,Graal编译器可以替换服务端编译器C2,称为HotSpot分层编译中最顶层的即时编译器。

 实战略。graal源码编译失败。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!