即时编译(JIT just in time,默认是开启的)是一项用来提升应用程序运行效率的技术。通常而言,代码会先被 Java 虚拟机解释执行,之后反复执行的热点代码则会被即时编译成为机器码,直接运行在底层硬件之上。
HotSpot 虚拟机包含多个即时编译器 C1、C2 和 Graal(实验性质)。其中,Graal 是一个实验性质的即时编译器,可以通过参数 -XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler 启用,并且替换 C2。
1. 分层编译模式
在 Java 7 以前,我们需要根据程序的特性选择对应的即时编译器。对于执行时间较短的,或者对启动性能有要求的程序,我们采用编译效率较快的 C1,对应参数 -client。对于执行时间较长的,或者对峰值性能有要求的程序,我们采用生成代码执行效率较快的 C2,对应参数 -server。
Java 7 引入了分层编译(对应参数 -XX:+TieredCompilation)的概念,综合了 C1 的启动性能优势和 C2 的峰值性能优势。分层编译将 Java 虚拟机的执行状态分为了五个层次。为了方便阐述,我用“C1 代码”来指代由 C1 生成的机器码,“C2 代码”来指代由 C2 生成的机器码。五个层级分别是:
0. 解释执行;
- 执行不带 profiling 的 C1 代码;
- 执行仅带方法调用次数以及循环回边执行次数 profiling 的 C1 代码;
- 执行带所有 profiling 的 C1 代码;(除level 2中的profiling外还包括branch(针对分支跳转字节码)及receiver type(针对成员方法调用或类检测,如checkcast,instnaceof,aastore字节码)的profiling)
- 执行 C2 代码。
通常情况下,C2 代码的执行效率要比 C1 代码的高出 30% 以上。然而,对于 C1 代码的三种状态,按执行效率从高至低则是 1 层 > 2 层 > 3 层。其中 1 层的性能比 2 层的稍微高一些,而 2 层的性能又比 3 层高出 30%。这是因为 profiling 越多,其额外的性能开销越大。
profiling 是指在程序执行过程中,收集能够反映程序执行状态的数据。这里所收集的数据我们称之为程序的 profile。
上图列举了4种编译模式(非全部)。
- 通常情况下,一个方法先被解释执行(level 0),然后被C1编译(level 3),再然后被得到profile数据的C2编译(level 4)。
- 如果编译对象非常简单(trivial method--非常简单的,如方法的字节码数目比较少(如 getter/setter),而且 3 层的 profiling 没有可收集的数据),虚拟机认为通过C1编译或通过C2编译并无区别,便会直接由C1编译且不插入profiling代码(level 1)。
- 在C1忙碌的情况下,interpreter会触发profiling,而后方法会直接被C2编译;
- 在C2忙碌的情况下,方法则会先由C1编译并保持较少的profiling(level 2),以获取较高的执行效率(与3级相比高30%)。
2. 即时编译的触发
方法的调用次数以及循环回边的执行次数(循环体内循环代码的执行次数(即for中代码的循环的次数))来触发即时编译的。前面提到,Java 虚拟机在 0 层、2 层和 3 层执行状态时进行 profiling,其中就包含方法的调用次数和循环回边的执行次数。
这里的循环回边是一个控制流图中的概念。在字节码中,我们可以简单理解为往回跳转的指令。(注意,这并不一定符合循环回边的定义。)
实际上,Java 虚拟机并不会对这些计数器进行同步操作,因此收集而来的执行次数也并非精确值。不管如何,即时编译的触发并不需要非常精确的数值。只要该数值足够大,就能说明对应的方法包含热点代码。
3. Profiling优化
3.1 基于分支profile的优化
例:
1 public static int foo(boolean f, int in) {
2 int v;
3 if (f) {
4 v = in;
5 } else {
6 v = (int) Math.sin(in);
7 }
8
9 if (v == in) {
10 return 0;
11 } else {
12 return (int) Math.cos(v);
13 }
14 }
15 // 编译而成的字节码:
16 public static int foo(boolean, int);
17 Code:
18 0: iload_0
19 1: ifeq 9
20 4: iload_1
21 5: istore_2
22 6: goto 16
23 9: iload_1
24 10: i2d
25 11: invokestatic java/lang/Math.sin:(D)D
26 14: d2i
27 15: istore_2
28 16: iload_2
29 17: iload_1
30 18: if_icmpne 23
31 21: iconst_0
32 22: ireturn
33 23: iload_2
34 24: i2d
35 25: invokestatic java/lang/Math.cos:(D)D
36 28: d2i
37 29: ireturn
假设应用程序调用该方法时,所传入的 boolean 值皆为 true。那么,偏移量为 1 以及偏移量为 18 的条件跳转指令所对应的分支 profile 中,跳转的次数都为 0。
C2 可以根据这两个分支 profile 作出假设,在接下来的执行过程中,这两个条件跳转指令仍旧不会发生跳转。基于这个假设,C2 便不再编译这两个条件跳转语句所对应的 false 分支了。
“剪枝”之后,在第二个条件跳转处,v 的值只有可能为所输入的 int 值。因此,该条件跳转可以进一步被优化掉。最终的结果是,在第一个条件跳转之后,C2 代码将直接返回 0。
3.2 基于类型profile的优化
例:
1 public static int hash(Object in) {
2 if (in instanceof Exception) {
3 return System.identityHashCode(in);
4 } else {
5 return in.hashCode();
6 }
7 }
8 // 编译而成的字节码:
9 public static int hash(java.lang.Object);
10 Code:
11 0: aload_0
12 1: instanceof java/lang/Exception
13 4: ifeq 12
14 7: aload_0
15 8: invokestatic java/lang/System.identityHashCode:(Ljava/lang/Object;)I
16 11: ireturn
17 12: aload_0
18 13: invokevirtual java/lang/Object.hashCode:()I
19 16: ireturn
在我们的例子中,instanceof 指令的类型 profile 仅包含 Integer。根据这个信息,即时编译器可以假设,在接下来的执行过程中,所输入的 Object 对象仍为 Integer 实例。
因此,生成的代码将测试所输入的对象的动态类型是否为 Integer。如果是的话,则继续执行接下来的代码。(该优化源自 Graal,采用 C2 可能无法复现。)
然后,即时编译器会采用和第一个例子中一致的针对分支 profile 的优化,以及对方法调用的条件去虚化内联。
我会在接下来的篇章中详细介绍内联,这里先说结果:生成的代码将测试所输入的对象动态类型是否为 Integer。如果是的话,则执行 Integer.hashCode() 方法的实质内容,也就是返回该 Integer 实例的 value 字段。
3.3 去优化
核心都是假设。
对于分支 profile,即时编译器假设的是仅执行某一分支;对于类型 profile,即时编译器假设的是对象的动态类型仅为类型 profile 中的那几个。
假设失败的情况下,程序将何去何从?
Java 虚拟机给出的解决方案便是去优化,即从执行即时编译生成的机器码切换回解释执行。
在生成的机器码中,即时编译器将在假设失败的位置上插入一个陷阱(trap)。该陷阱实际上是一条 call 指令,调用至 Java 虚拟机里专门负责去优化的方法。与普通的 call 指令不一样的是,去优化方法将更改栈上的返回地址,并不再返回即时编译器生成的机器码中。
红色方框的问号。这些问号便代表着一个个的陷阱。一旦踏入这些陷阱,便将发生去优化,并切换至解释执行。