JVM-即时编译

  即时编译(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. 解释执行;

  1. 执行不带 profiling 的 C1 代码;
  2. 执行仅带方法调用次数以及循环回边执行次数 profiling 的 C1 代码;
  3. 执行带所有 profiling 的 C1 代码;(除level 2中的profiling外还包括branch(针对分支跳转字节码)及receiver type(针对成员方法调用或类检测,如checkcast,instnaceof,aastore字节码)的profiling)
  4. 执行 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. 即时编译的触发

  Java 虚拟机是根据方法的调用次数以及循环回边的执行次数(循环体内循环代码的执行次数(即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,即时编译器假设的是仅执行某一分支;对于类型 profile,即时编译器假设的是对象的动态类型仅为类型 profile 中的那几个。

  那么,当假设失败的情况下,程序将何去何从?

  Java 虚拟机给出的解决方案便是去优化,即从执行即时编译生成的机器码切换回解释执行。

  在生成的机器码中,即时编译器将在假设失败的位置上插入一个陷阱(trap)。该陷阱实际上是一条 call 指令,调用至 Java 虚拟机里专门负责去优化的方法。与普通的 call 指令不一样的是,去优化方法将更改栈上的返回地址,并不再返回即时编译器生成的机器码中。

  在上面的程序控制流图中,我画了很多红色方框的问号。这些问号便代表着一个个的陷阱。一旦踏入这些陷阱,便将发生去优化并切换至解释执行

posted on 2020-12-28 15:04  gogoy  阅读(327)  评论(0编辑  收藏  举报

导航