深入理解Java虚拟机:(十二)JVM内的即时编译器

一、概述

在部分的商用虚拟机中(Sun HotSpot、IBM J9)中,Java 程序最初是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为 “热点代码” (Hot Spot Code)。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just In Time Compiler,简称 JIT 编译器)。

二、HotSpot 虚拟机内的即时编译器

本节我们来了解 HotSpot 虚拟机内的即时编译器的运作过程,同时还要思考并解决以下几个问题:

  • 为何 HotSpot 虚拟机要使用解释器和编译器并存的架构?
  • 为何 HotSpot 虚拟机要实现两个不同的即时编译器?
  • 程序何时使用解释器执行?何时使用编译器执行?
  • 哪些程序代码会被编译为本地代码?如何编译为本地代码?
  • 如何从外部观察即时编译器的编译过程和编译结果?

1、解释器与编译器

尽管并不是所有 Java 虚拟机都采用解释器与编译器并存的架构,但许多主流的商用虚拟机,如 HotSpot、J9 等,都同时包含解释器与编译器。解释器和编译器两者各有优势:

解释器优点:当程序需要迅速启动的时候,解释器可以首先发挥作用,省去了编译的时间,立即执行。解释执行占用更小的内存空间。同时,当编译器进行的激进优化失败的时候,还可以进行逆优化来恢复到解释执行的状态。

编译器优点:在程序运行时,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获得更高的执行效率。

因此,整个虚拟机执行架构中,解释器与编译器经常配合工作,如下图所示。

在这里插入图片描述

2、分层编译模式

HotSpot 中内置了两个即时编译器,分别称为 Client Compiler和 Server Compiler ,或者简称为 C1 编译器和 C2 编译器。HotSpot 虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用 -client-server 参数去强制指定虚拟机运行在 Client 模式或 Server 模式。

这种配合使用的方式称为“混合模式”(Mixed Mode),用户可以使用参数 -Xint 强制虚拟机运行于 “解释模式”(Interpreted Mode),这时候编译器完全不介入工作。另外,使用 -Xcomp 强制虚拟机运行于 “编译模式”(Compiled Mode),这时候将优先采用编译方式执行,但是解释器仍然要在编译无法进行的情况下接入执行过程。通过虚拟机 -version 命令可以查看当前默认的运行模式。

在这里插入图片描述

由于即时编译器编译本地代码需要占用程序运行时间,要编译出优化程度更高的代码,所花费的时间可能更长;而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,这对解释执行的速度也有影响。

为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot 虚拟机还会逐渐启动分层编译(Tiered Compilation)的策略,分层编译(对应参数 -XX:+TieredCompilation)的概念在 JDK 1.6 时期出现,后来一直处于改进阶段,最终在 JDK 1.7 的 Server 模式虚拟机中作为默认编译器策略被开启。分层编译根据编译器优化、优化的规模与耗时,划分出五个不同的编译层次。为了方便阐述,我用“C1 代码”来指代由 C1 生成的机器码,“C2 代码”来指代由 C2 生成的机器码。五个层次分别是:

  • 程序解释执行,解释器不开启性能监控功能(Profiling);
  • 执行不带 profiling 的 C1 代码;
  • 执行仅带方法调用次数以及循环回边执行次数 profiling 的 C1 代码;
  • 执行带所有 profiling 的 C1 代码;
  • 执行 C2 代码,将字节码编译为本地代码。

通常情况下,C2 代码的执行效率要比 C1 代码的高出 30% 以上。然而,对于 C1 代码的三种状态,按执行效率从高至低则是 1 层 > 2 层 > 3 层。

其中 1 层的性能比 2 层的稍微高一些,而 2 层的性能又比 3 层高出 30%。这是因为 profiling 越多,其额外的性能开销越大。

这里解释一下,profiling 是指在程序执行过程中,收集能够反映程序执行状态的数据。这里所收集的数据我们称之为程序的 profile。

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

在这里插入图片描述

通常情况下,热点方法会被 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 默认开启了分层编译。不管是开启还是关闭分层编译,原本用来选择即时编译器的参数 -client-server 都是无效的。当关闭分层编译的情况下,Java 虚拟机将直接采用 C2。

如果你希望只是用 C1,那么你可以在打开分层编译的情况下使用参数 -XX:TieredStopAtLevel=1。在这种情况下,Java 虚拟机会在解释执行之后直接由 1 层的 C1 进行编译。

3、编译对象和触发条件

在运行过程中会被即时编译的 “热点代码” 有两类,即:

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

对于第一种,编译器会将整个方法作为编译对象,这也是标准的JIT 编译方式。对于第二种是由循环体出发的,但是编译器依然会以整个方法(而不是单独的循环体)作为编译对象,因为发生在方法执行过程中,称为栈上替换(On Stack Replacement,简称为 OSR 编译,即方法栈帧还在栈上,方法就被替换了)。

判断一段代码是否是热点代码,是不是需要出发即时编译,这样的行为称为热点探测(Hot Spot Detection),探测算法有两种,分别如下:

  • 基于采样的热点探测(Sample Based Hot Spot Detection):虚拟机会周期的对各个线程栈顶进行检查,如果某些方法经常出现在栈顶,这个方法就是“热点方法”。好处是实现简单、高效,很容易获取方法调用关系。缺点是很难确认方法的 reduce,容易受到线程阻塞或其他外因扰乱。
  • 基于计数器的热点探测(Counter Based Hot Spot Detection):为每个方法(甚至是代码块)建立计数器,执行次数超过阈值就认为是“热点方法”。优点是统计结果精确严谨。缺点是实现麻烦,不能直接获取方法的调用关系。

HotSpot 使用的是第二种——基于计数器的热点探测,并且有两类计数器:方法调用计数器(Invocation Counter )和回边计数器(Back Edge Counter )。

这两个计数器都有一个确定的阈值,超过后便会触发 JIT 编译。

(1)、首先是方法调用计数器。Client 模式下默认阈值是 1500 次,在 Server 模式下是 10000次,这个阈值可以通过 -XX:CompileThreadhold 来人为设定。如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内的方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那么这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay),而这段时间就成为此方法的统计的半衰周期( Counter Half Life Time)。进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数 -XX:CounterHalfLifeTime 参数设置半衰周期的时间,单位是秒。整个 JIT 编译的交互过程如下图。

在这里插入图片描述
(2)、第二个回边计数器,作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”( Back Edge )。显然,建立回边计数器统计的目的就是为了触发 OSR 编译。关于这个计数器的阈值, HotSpot 提供了 -XX:BackEdgeThreshold 供用户设置,但是当前的虚拟机实际上使用了 -XX:OnStackReplacePercentage 来简介调整阈值,计算公式如下:

  • Client 模式下, 公式为 方法调用计数器阈值(CompileThreshold)X OSR 比率(OnStackReplacePercentage)/ 100 。其中 OSR 比率默认为 933,那么,回边计数器的阈值为 13995
  • Server 模式下,公式为 方法调用计数器阈值(Compile Threashold)X (OSR 比率(OnStackReplacePercentage) - 解释器监控比率(InterpreterProfilePercent))/100
    其中 onStackReplacePercentage 默认值为 140,InterpreterProfilePercentage 默认值为 33,如果都取默认值,那么 Server 模式虚拟机回边计数器阈值为 10700

整个执行过程,如下图:

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

(3)、触发条件

解释执行和 C1 代码中增加循环回边计数器的位置并不相同,但这并不会对程序造成影响。

实际上,Java 虚拟机并不会对这些计数器进行同步操作,因此收集而来的执行次数也并非精确值。不管如何,即时编译的触发并不需要非常精确的数值。只要该数值足够大,就能说明对应的方法包含热点代码。

具体来说,在不启用分层编译的情况下,当方法的调用次数和循环回边的次数的和,超过由参数 -XX:CompileThreshold 指定的阈值时(使用 C1 时,该值为 1500;使用 C2 时,该值为 10000),便会触发即时编译。

当启用分层编译时,Java 虚拟机将不再采用由参数 -XX:CompileThreshold 指定的阈值(该参数失效),而是使用另一套阈值系统。在这套系统中,阈值的大小是动态调整的。

所谓的动态调整其实并不复杂:在比较阈值时,Java 虚拟机会将阈值与某个系数 s 相乘。该系数与当前待编译的方法数目成正相关,与编译线程的数目成负相关。

系数的计算方法为:
s = queue_size_X / (TierXLoadFeedback * compiler_count_X) + 1
其中X是执行层次,可取3或者4;
queue_size_X是执行层次为X的待编译方法的数目;
TierXLoadFeedback是预设好的参数,其中Tier3LoadFeedback为5,Tier4LoadFeedback为3;
compiler_count_X是层次X的编译线程数目。

在 64 位 Java 虚拟机中,默认情况下编译线程的总数目是根据处理器数量来调整的(对应参数 -XX:+CICompilerCountPerCPU,默认为 true;当通过参数 -XX:+CICompilerCount=N 强制设定总编译线程数目时,CICompilerCountPerCPU 将被设置为 false)。

Java 虚拟机会将这些编译线程按照 1:2 的比例分配给 C1 和 C2(至少各为 1 个)。举个例子,对于一个四核机器来说,总的编译线程数目为 3,其中包含一个 C1 编译线程和两个 C2 编译线程。

对于四核及以上的机器,总的编译线程的数目为:n = log2(N) * log2(log2(N)) * 3 / 2
其中N为CPU核心数目

当启用分层编译时,即时编译具体的触发条件如下。

当方法调用次数大于由参数-XX:TierXInvocationThreshold指定的阈值乘以系数,或者当方法调用次数大于由参数-XX:TierXMINInvocationThreshold指定的阈值乘以系数,并且方法调用次数和循环回边次数之和大于由参数-XX:TierXCompileThreshold指定的阈值乘以系数时,便会触发X层即时编译。
触发条件为:i > TierXInvocationThreshold * s || (i > TierXMinInvocationThreshold * s && i + b > TierXCompileThreshold * s)

其中 i 为调用次数,b 为循环回边次数

(4)、OSR 编译

可以看到,决定一个方法是否为热点代码的因素有两个:方法的调用次数循环回边的执行次数。即时编译便是根据这两个计数器的和触发的。为什么 Java 虚拟机需要维护两个不同的计数器呢?

实际上,除了以方法为单位的即时编译之外,Java 虚拟机还存在着另一种以循环为单位的即时编译,叫做 On-Stack-Replacement(OSR)编译。循环回边计数器便是用来触发这种类型的编译的。

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

在不启用分层编译的情况下,触发 OSR 编译的阈值是由参数 -XX:CompileThreshold 指定的阈值的倍数。该倍数的计算方法为:

(OnStackReplacePercentage - InterpreterProfilePercentage)/100
其中-XX:InterpreterProfilePercentage的默认值为33,当使用C1-XX:OnStackReplacePercentage933,当使用C2时为140

也就是说,默认情况下,C1 的 OSR 编译的阈值为 13500,而 C2 的为 10700。

在启用分层编译的情况下,触发 OSR 编译的阈值则是由参数 -XX:TierXBackEdgeThreshold 指定的阈值乘以系数。

OSR 编译在正常的应用程序中并不多见。它只在基准测试时比较常见,因此并不需要过多了解。

4、编译过程

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

(1)、对于 Client Compiler 模式而言

它是一个简单快速的三段式编译器,主要关注点在于局部的优化,放弃了许多耗时较长的全局优化手段。

  • 第一阶段,一个平台独立的前端将字节码构造成一种高级中间代码表示(High-Level Intermediate Representaion , HIR)。在此之前,编译器会在字节码上完成一部分基础优化,如 方法内联常量传播等优化。
  • 第二阶段,一个平台相关的后端从 HIR 中产生低级中间代码表示(Low-Level Intermediate Representation ,LIR),而在此之前会在 HIR 上完成另外一些优化,如空值检查消除范围检查消除等,让HIR 更为高效。
  • 第三阶段,在平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在 LIR 上分配寄存器,做窥孔(Peephole)优化,然后产生机器码。

Client Compiler 的大致执行过程如下图所示:
在这里插入图片描述

(2)、对于 Server Compiler 模式而言

它是专门面向服务端的典型应用,并为服务端的性能配置特别调整过的编译器,也是一个充分优化过的高级编译器,几乎能达到 GNU C++ 编译器使用-O2 参数时的优化强度,它会执行所有的经典的优化动作,如 无用代码消除(Dead Code Elimination)、循环展开(Loop Unrolling)、循环表达式外提(Loop Expression Hoisting)、消除公共子表达式(Common Subexpression Elimination)、常量传播(Constant Propagation)、基本块冲排序(Basic Block Reordering)等,还会实施一些与 Java 语言特性密切相关的优化技术,如范围检查消除(Range Check Elimination)、空值检查消除(Null Check Elimination ,不过并非所有的空值检查消除都是依赖编译器优化的,有一些是在代码运行过程中自动优化 了)等。另外,还可能根据解释器或Client Compiler 提供的性能监控信息,进行一些不稳定的激进优化,如 守护内联(Guarded Inlining)、分支频率预测(Branch Frequency Prediction)等。

Server Compiler 编译器可以充分利用某些处理器架构,如(RISC)上的大寄存器集合。从即时编译的角度来看, Server Compiler 无疑是比较缓慢的,但它的编译速度仍远远超过传统的静态优化编译器,而且它相对于 Client Compiler编译输出的代码质量有所提高,可以减少本地代码的执行时间,从而抵消了额外的编译时间开销,所以也有很多非服务端的应用选择使用 Server 模式的虚拟机运行。

三、总结

这篇详细地介绍了 Java 虚拟机中的即时编译。

主要包括解释器与编译器的优缺点、分层编译模式、编译对象和触发条件以及编译过程。

从 Java 8 开始,Java 虚拟机默认采用分层编译的方式。它将执行分为五个层次,分为为 0 层解释执行,1 层执行没有 profiling 的 C1 代码,2 层执行部分 profiling 的 C1 代码,3 层执行全部 profiling 的 C1 代码,和 4 层执行 C2 代码。

通常情况下,方法会首先被解释执行,然后被 3 层的 C1 编译,最后被 4 层的 C2 编译。

即时编译是由方法调用计数器和循环回边计数器触发的。在使用分层编译的情况下,触发编译的阈值是根据当前待编译的方法数目动态调整的。

OSR 是一种能够在非方法入口处进行解释执行和编译后代码之间切换的技术。OSR 编译可以用来解决单次调用方法包含热循环的性能优化问题。

发布了338 篇原创文章 · 获赞 210 · 访问量 13万+

猜你喜欢

转载自blog.csdn.net/riemann_/article/details/104104967