目前主流的 Java 虚拟机里,Java 程序最初都是通过 解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为热点代码,为了提高热点代码的执行效率,虚拟机在运行时会把这些热点代码编译成与本地平台相关的机器码,并以各种手段尽可能地进行代码优化,执行该任务的编译器被称为 即时编译器(Just In Time Compiler)。即时编译是以方法为单位进行编译的。

解释器与编译器

尽管并非所有的 Java 虚拟机都采用 解释器 与 编译器 并存的运行架构,但目前主流的商用 Java 虚拟机,如 HotSpot、OpenJ9 等内部都同时包含解释器与编译器,两者各有优势。

当程序需要迅速启动和执行时,解释器可以省去编译的时间,立即运行。当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。当程序运行环境中内存资源限制较大,可以使用解释执行节约内存,反之可以使用编译执行来提升效率。

同时,解释器还可以作为编译器激进优化时后备的逃生门,当激进优化的假设不成立时可以通过逆优化退回到解释状态继续执行,因此在整个Java 虚拟机执行架构里,解释器与编译器是相辅相成地配合工作。
image.png

HotSpot 虚拟机中内置了两个(或三个)即时编译器,其中有两个编译器存在已久,分别被称为客户端编译器服务端编译器,简称 C1 编译器 和 C2 编译器,第三个是在 JDK 10 时才出现的、长期目标是代替 C2 的 Graal 编译器。我们重点关注传统的 C1、C2 编译器的工作过程。

分层编译

在分层编译(Tiered Compilation)模式出现前,HotSpot 虚拟机通常是采用解释器与其中一个编译器直接搭配的方式工作,程序使用哪个编译器只取决于虚拟机运行的模式,HotSpot 虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用 -client 或 -server 参数手动指定运行模式。

  • 对于执行时间较短或对启动性能有要求的程序,采用编译效率较快的 C1,对应参数:-client
  • 对于执行时间较长或对峰值性能有要求的程序,采用生成代码执行效率较快的 C2,对应参数:-server

无论采用的编译器是客户端编译器还是服务端编译器,解释器与编译器搭配使用的方式在虚拟机中被称为混合模式(Mixed Mode),用户也可以使用参数 -Xint 强制虚拟机运行于解释模式(Interpreted Mode),这时候编译器完全不介入工作,全部代码都使用解释方式执行。也可以使用参数 -Xcomp 强制虚拟机运行于编译模式(Compiled Mode),这时将优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程。可以通过虚拟机的 -version 命令的输出结果显示出这三种模式,内容如下图所示:
image.png

由于即时编译器编译本地代码需要占用程序运行时间,通常要编译出优化程度越高的代码,所花费的时间便会越长;而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,这对解释执行阶段的速度也有所影响。为了在程序启动响应速度与运行效率间达到最佳平衡,HotSpot 虚拟机在 Java 7 中加入了 分层编译(对应参数 -XX:+TieredCompilation)的功能,作为服务端模式下虚拟机中的默认编译策略。

分层编译将 Java 虚拟机的执行状态分为了五个层次,这五个层次分别是:

  • 第 0 层:程序纯解释执行,并且不开启性能监控功能(Profiling);
  • 第 1 层:执行不带 Profiling 的 C1 代码;
  • 第 2 层:执行仅带方法调用次数以及循环回边执行次数 Profiling 的 C1 代码;
  • 第 3 层:执行带所有 Profiling 的 C1 代码;
  • 第 4 层:执行 C2 代码,C2 会启用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。

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

如下图所示,在这 5 个层次的执行状态中,第 1 层和第 4 层为 终止状态。当一个方法被终止状态编译过后,如果编译后的代码并没有失效,那么 Java 虚拟机是不会再次发出该方法的编译请求的。
image.png

上图展示了这五个层次在执行时的 4 个不同的编译路径:

  • 通常情况下,热点方法会被第 3 层的 C1 编译,然后再被第 4 层的 C2 编译。

  • 如果方法的字节码指令较少(如 getter/setter)且第 3 层的 profiling 没有可收集的数据,则 Java 虚拟机认为该方法对于 C1 代码和 C2 代码的执行效率相同。此时 Java 虚拟机会在第 3 层编译后直接选择用第 1 层的 C1 编译。由于这是一个终止状态,因此 Java 虚拟机不会继续用第 4 层的 C2 编译。

  • 在 C1 忙碌时,Java 虚拟机在解释执行过程中对程序进行 profiling,而后直接由第 4 层的 C2 编译。

  • 在 C2 忙碌时,方法会被第 2 层的 C1 编译后再被第 3 层的 C1 编译,以减少在第 3 层的执行时间。

从 Java 8 开始,Java 虚拟机默认采用 分层编译 的方式。不管是开启还是关闭分层编译,-client 和 -server 参数都是无效的了。当关闭分层编译时,Java 虚拟机将直接采用 C2。如果希望只用 C1,那么可以在打开分层编译的情况下使用参数 -XX:TieredStopAtLevel=1。此时 Java 虚拟机会在解释执行后直接由 1 层的 C1 进行编译。

在 64 位 Java 虚拟机中,默认情况下编译线程的总数目是根据处理器数量来调整的。对应参数为 -XX:+CICompilerCountPerCPU,默认为 true;当通过参数 -XX:+CICompilerCount 强制设定总编译线程的数目时,该参数将被设置为 false。Java 虚拟机会将编译线程按照 1:2 的比例分配给 C1 和 C2(至少各为 1 个)。比如一个四核机器,总的编译线程数目为 3,其中就包含了一个 C1 编译线程和两个 C2 编译线程。

热点探测

JVM 会根据统计信息,动态决定什么方法被编译,什么方法解释执行,即使是已经编译过的代码,也可能在不同的运行阶段不再是热点,JVM 会将这种代码从 Code Cache 中移除出去,以减少内存占用。上面提到在运行过程中会被即时编译器编译的目标就是热点代码,这里的热点代码主要有两类:

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

对于这两种情况,编译的目标对象都是整个方法体,而不会是单独的循环体。对于第二种情况,尽管编译动作是由循环体所触发的,热点只是方法的一部分,但编译器依然必须以整个方法作为编译对象。这种编译方式因为编译发生在方法执行的过程中,因此被很形象地称为栈上替换(On Stack Replacement,OSR),即方法的栈帧还在栈上,方法就被替换了。

OSR 实际上是一种技术,它指的是在程序执行过程中,动态地替换掉 Java 方法栈桢,从而使得程序能够在非方法入口处进行解释执行和编译后的代码之间的切换。事实上,去优化(deoptimization)采用的技术也可以称之为 OSR。

同时 Java 虚拟机判断某段代码是否是热点代码的行为称为 热点探测,其实进行热点探测并不一定要知道方法具体被调用了多少次,目前主流的热点探测判定方式有两种:

基于采样的热点探测
采用这种方法的虚拟机会周期性地检查各个线程的调用栈顶,如果发现某个方法经常出现在栈顶,那这个方法就是热点方法。基于采样的热点探测的好处是实现简单高效,还可以很容易地获取方法调用关系,缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。

基于计数器的热点探测
采用这种方法的虚拟机会为每个方法建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是热点方法。这种统计方法需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系。但是它的统计结果相对来说更加精确严谨。

在 HotSpot 虚拟机中使用的是基于计数器的热点探测方法,为了实现热点计数,HotSpot 为每个方法准备了两类计数器:方法调用计数器 和 循环回边计数器。这两个计数器都有一个明确的阈值,计数器阈值一旦溢出就会触发即时编译。实际上,Java 虚拟机并不会对这些计数器进行同步操作,因此收集而来的执行次数也并非精确值。不管如何,即时编译的触发并不需要非常精确的数值。只要该数值足够大,就能说明对应的方法包含热点代码。

具体来说,在不启用分层编译的情况下,当方法的调用次数和循环回边次数的和,超过 -XX:CompileThreshold 参数指定的阈值(在客户端 C1 模式下是 1500 次,在服务端 C2 模式下是 10000 次)时,便会触发即使编译。当启用分层编译时,Java 虚拟机将不再采用该参数指定的阈值,而是使用另一套阈值系统,其中阈值的大小是动态调整的。

1. 方法调用计数器

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

如果没有做过任何设置,执行引擎默认不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被即时编译器编译完成。当编译工作完成后,这个方法的调用入口地址就会被系统自动改写成新值,下一次调用该方法时就会使用已编译的版本了,整个即时编译的交互过程如下图所示:
方法调用计数器触发即时编译.png
在默认设置下,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍不足以让它提交给即时编译器编译,那该方法的调用计数器就会被减少一半,这个过程被称为方法调用计数器热度的衰减,进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数 -XX:-UseCounterDecay 来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样只要系统运行时间足够长,程序中绝大部分方法都会被编译成本地代码。

2. 循环回边计数器

建立回边计数器的主要目的是为了触发 OSR(On StackReplacement)编译,即栈上编译。在一些循环周期比较长的代码段中,当循环达到回边计数器阈值时,JVM 会认为这段是热点代码,JIT 编译器就会将这段代码编译成机器语言并缓存,在该循环时间段内,会直接将执行代码替换,执行缓存的机器语言。

这里的循环回边是一个控制流图中的概念。在字节码中,我们可以简单理解为控制流往回跳转的指令,显然建立循环回边计数器统计的目的是为了触发栈上的替换编译。

  1. public static void foo(Object obj) {
  2. int sum = 0;
  3. for (int i = 0; i < 200; i++) {
  4. sum += i;
  5. }
  6. }

举例来说,上面这段代码将被编译为下面的字节码。其中,偏移量为 18 的字节码将往回跳至偏移量为 7 的字节码中。在解释执行时,每当运行一次该指令,Java 虚拟机便会将该方法的循环回边计数器加 1。

  1. public static void foo(java.lang.Object);
  2. Code:
  3. 0: iconst_0
  4. 1: istore_1
  5. 2: iconst_0
  6. 3: istore_2
  7. 4: goto 14
  8. 7: iload_1
  9. 8: iload_2
  10. 9: iadd
  11. 10: istore_1
  12. 11: iinc 2, 1
  13. 14: iload_2
  14. 15: sipush 200
  15. 18: if_icmplt 7
  16. 21: return

在即时编译过程中,我们会识别循环的头部和尾部。在上面这段字节码中,循环的头部是偏移量为 14 的字节码,尾部为偏移量为 11 的字节码。循环尾部到循环头部的控制流边就是真正意义上的循环回边,C1 将在这个位置插入增加循环回边计数器的代码。

工作过程:

当解释器遇到一条循环回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有则优先执行已编译的代码,否则把循环回边计数器的值加一,然后判断方法调用计数器与循环回边计数器值之和是否超过循环回边计数器的阈值。当超过阈值时会提交一个栈上替换(OSR)编译请求,并把循环回边计数器的值稍微降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果,整个执行过程如下图所示:
回边计数器触发即时编译.png
与方法调用计数器不同,循环回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。当计数器溢出时,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。

编译过程

在默认条件下,无论是方法调用产生的标准编译请求,还是栈上替换编译请求,虚拟机在编译器还未完成编译之前,都仍将按照解释方式继续执行代码,而编译动作则在后台的编译线程中进行。用户可以通过参数 -XX:-BackgroundCompilation 来禁止后台编译,后台编译被禁止后,当达到触发即时编译的条件时,执行线程向虚拟机提交编译请求后会一直阻塞等待,直到编译完成再开始执行编译器输出的本地代码。

我们可以使用参数 -XX:+PrintCompilation 来打印项目中的即时编译情况,输出示例如下:

  1. 88 15 3 CompilationTest::foo (16 bytes)
  2. 88 16 3 java.lang.Integer::valueOf (32 bytes)
  3. 88 17 4 CompilationTest::foo (16 bytes)
  4. 88 18 4 java.lang.Integer::valueOf (32 bytes)
  5. 89 15 3 CompilationTest::foo (16 bytes) made not entrant
  6. 89 16 3 java.lang.Integer::valueOf (32 bytes) made not entrant
  7. 90 19 % 3 CompilationTest::main @ 5 (33 bytes)

简单解释一下该参数的输出:第一列是时间,第二列是 Java 虚拟机维护的编译 ID。

接下来是一系列标识,包括 %(是否 OSR 编译),s(是否 synchronized 方法),!(是否包含异常处理器),b(是否阻塞了应用线程),n(是否为 native 方法)。再接下来则是编译层次,以及方法名。如果是 OSR 编译,那么方法名后面还会跟着 @ 以及循环所在的字节码。

当发生去优化时,你将看到之前出现过的编译,不过被标记了 “made not entrant”。它表示该方法不能再被进入。当 Java 虚拟机检测到所有的线程都退出该编译后的 “made not entrant” 时,会将该方法标记为 “made zombie”,此时虚拟机就可以回收这块代码所占据的空间(Code Cache)了。

Profiling

上面提到,分层编译中的 0 层、2 层和 3 层都会进行 profiling,收集能够反映程序执行状态的数据。其中,最为基础的便是方法的调用次数以及循环回边的执行次数。它们被用于触发即时编译。此外,0 层和 3 层还会收集用于 4 层 C2 编译的数据,比如:分支跳转字节码的分支 profile(branch profile),包括跳转次数和不跳转次数,以及非私有实例方法调用指令、强制类型转换 checkcast 指令、类型测试 instanceof 指令,和引用类型的数组存储 aastore 指令的类型 profile(receiver type profile)。

分支 profile 和类型 profile 的收集会给应用程序带来不少的性能开销。因此在通常情况下,我们不会在解释执行过程中收集分支 profile 以及类型 profile。只有在方法触发 C1 编译后,Java 虚拟机认为该方法有可能被 C2 编译,才会在该方法的 C1 代码中收集这些 profile。

那么这些耗费巨大代价收集而来的 profile 具体有什么作用呢?答案是,C2 可以根据收集得到的数据进行猜测,假设接下来的执行同样会按照所收集的 profile 进行,从而作出一些比较激进的优化。

1. 基于分支 profile 的优化

代码示例如下,显然,当输入的 boolean 值为 true 时,这段代码将返回 0。

  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. if (v == in) {
  9. return 0;
  10. } else {
  11. return (int) Math.cos(v);
  12. }
  13. }
  14. // 编译而成的字节码:
  15. public static int foo(boolean, int);
  16. Code:
  17. 0: iload_0
  18. 1: ifeq 9
  19. 4: iload_1
  20. 5: istore_2
  21. 6: goto 16
  22. 9: iload_1
  23. 10: i2d
  24. 11: invokestatic java/lang/Math.sin:(D)D
  25. 14: d2i
  26. 15: istore_2
  27. 16: iload_2
  28. 17: iload_1
  29. 18: if_icmpne 23
  30. 21: iconst_0
  31. 22: ireturn
  32. 23: iload_2
  33. 24: i2d
  34. 25: invokestatic java/lang/Math.cos:(D)D
  35. 28: d2i
  36. 29: ireturn

假设应用程序调用该方法时,所传入的 boolean 值皆为 true。那么,偏移量为 1 以及偏移量为 18 的条件跳转指令所对应的分支 profile 中,跳转的次数都为 0。程序流执行过程如下图所示:
image.png
C2 可以根据这两个分支 profile 作出假设,在接下来的执行过程中,这两个条件跳转指令仍旧不会发生跳转。基于这个假设,C2 便不再编译这两个条件跳转语句所对应的 false 分支了。

此处暂且不管当假设错误的时候会发生什么,先来看一看剩下来的代码。经过“剪枝”之后,在第二个条件跳转处,v 的值只有可能为所输入的 int 值。因此,该条件跳转可以进一步被优化掉。最终的结果是,在第一个条件跳转之后,C2 代码将直接返回 0。
image.png
总结一下,根据条件跳转指令的分支 profile,即时编译器可以将从未执行过的分支剪掉,以避免编译这些很有可能不会用到的代码,从而节省编译时间以及部署代码所要消耗的内存空间。此外,“剪枝”将精简程序的数据流,从而触发更多的优化。

在现实中,分支 profile 出现仅跳转或者仅不跳转的情况并不多见。当然,即时编译器对分支 profile 的利用也不仅限于“剪枝”。它还会根据分支 profile,计算每一条程序执行路径的概率,以便某些编译器优化优先处理概率较高的路径。

2. 基于类型 profile 的优化

另外一个例子则是关于 instanceof 以及方法调用的类型 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

假设应用程序调用该方法时,所传入的 Object 皆为 Integer 实例。那么,偏移量为 1 的 instanceof 指令的类型 profile 仅包含 Integer,偏移量为 4 的分支跳转语句的分支 profile 中不跳转的次数为 0,偏移量为 13 的方法调用指令的类型 profile 仅包含 Integer。
image.png
和基于分支 profile 的优化一样,基于类型 profile 的优化同样也是作出假设,从而精简控制流以及数据流。这两者的核心都是假设。对于分支 profile,即时编译器假设的是仅执行某一分支;对于类型 profile,即时编译器假设的是对象的动态类型仅为类型 profile 中的那几个。

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

去优化

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

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

在上面的程序控制流图中,红色方框中的问号便代表着一个个的陷阱。一旦踏入这些陷阱,便发生去优化,并切换至解释执行。去优化的过程相当复杂,由于即时编译器采用了许多优化方式,其生成的代码和原本的字节码的差异非常之大。在去优化的过程中,需要将当前机器码的执行状态转换至某一字节码之前的执行状态,并从该字节码开始执行。这便要求即时编译器在编译过程中记录好这两种执行状态的映射。

比如,经过逃逸分析之后,机器码可能并没有实际分配对象,而是在各个寄存器中存储该对象的各个字段(标量替换)。在去优化过程中,Java 虚拟机需要还原出这个对象,以便解释执行时能够使用该对象。

当根据映射关系创建好对应的解释执行栈桢后,Java 虚拟机便会采用 OSR 技术,动态替换栈上的内容,并在目标字节码处开始解释执行。