什么是即时编译

本文深入探讨Java虚拟机中的即时编译(JIT)机制,解析HotSpot虚拟机如何运用C1、C2和Graal编译器优化代码执行效率。揭示热点代码探测、分层编译策略及编译触发条件,阐述方法调用计数器与回边计数器的作用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

即时编译(Just In Time,JIT)

在了解什么是即时编译之前,我们需要先弄明白一个问题:Java 虚拟机是怎么运行 Java 字节码的?

下面我将以标准 JDK 中的 HotSpot 虚拟机为例,从虚拟机以及底层硬件两个角度,简单给你讲一讲 Java 虚拟机具体是怎么运行 Java 字节码的。

从虚拟机视角来看,执行 Java 代码首先需要将它编译而成的 class 文件加载到 Java 虚拟机中。加载后的 Java 类会被存放于方法区(Method Area)中。实际运行时,虚拟机会执行方法区内的代码。

在运行过程中,每当调用进入一个 Java 方法,Java 虚拟机会在当前线程的 Java 方法栈中生成一个栈帧,用以存放局部变量以及字节码的操作数。这个栈帧的大小是提前计算好的,而且 Java 虚拟机不要求栈帧在内存空间里连续分布。

对于方法区、栈帧、局部变量表等概念不懂的读者可以看我的另一片文章《java 运行时数据区》

当退出当前执行的方法时,不管是正常返回还是异常返回,Java 虚拟机均会弹出当前线程的当前栈帧,并将之舍弃。

从硬件视角来看,Java 字节码无法直接执行。因此,Java 虚拟机需要将字节码翻译成机器码

在 HotSpot 里面,上述翻译过程有两种形式:第一种是解释执行,即逐条将字节码翻译成机器码并执行;第二种是即时编译(Just-In-Time compilation,JIT),即将一个方法中包含的所有字节码编译成机器码后再执行。

即时编译后的代码也会被保存到方法区中,当这个方法再次被调用时,就省去了解释的过程,直接执行即时编译缓存的机器码,以达成提升性能的目的。

到这里要明白三个概念:

编译执行: 通过编译器,将高级语言代码编译成对应平台的机器码文件,交于对应平台执行。机器码就是直接能被计算机理解的代码。

解释执行: 通过解释器,在代码执行时逐条翻译成机器码,不做保存。

即时编译(JIT): 将热点代码编译成与本地平台相关的机器码,并保存到内存。

即时编译

Java 程序最初都是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“ 热点代码 ”(Hot Spot Code),为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成本地机器码,并以各种手段尽可能地进行代码优化,这个过程就叫即时编译,运行时完成这个任务的后端编译器被称为即时编译器

既然编译后的机器码效率高,为什么不把全部代码提前编译

至于为什么不采用提前编译(Ahead Of Time,AOT)直接编译的方法,在峰值性能差不多的这个前提下,线下编译和即时编译就是两种选项,各有优缺点。JVM这样做,主要也是看重字节码的可移植性,而牺牲了启动性能(程序需要 JIT 预热才能达到最高性能)。

另外呢,现代工程语言实现都是抄来抄去的。JVM也引入了AOT编译,在线下将Java代码编译成可链接库。

如果你只想了解什么是即时编译看到这里就可以了,接下来的内容是一些扩展。

即时编译器

HotSpot 内置了三个即时编译器,其中有两个编译器存在已久、分别被称为“客户端编译器”(Client Compiler)和“服务端编译器”(Server Compiler),或者简称为 C1 编译器和 C2 编译器,C2 相较于 C1 编译出的机器码优化程度更高,但是耗时也更长。第三个是 JDK 10 才出现的、长期目标是替代 C2 的 Graal 编译器。Graal 是一个实验性质的即时编译器,可以通过参数 -XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler 启用,并且替换 C2。

在 Java 7 以前,我们需要根据程序的特性选择对应的即时编译器。对于执行时间较短的,或者对启动性能有要求的程序,我们采用编译效率较快的 C1,对应参数 -client。

对于执行时间较长的,或者对峰值性能有要求的程序,我们采用生成代码执行效率较快的 C2,对应参数 -server。

用 C1 获取更高的编译速度,用 C2 获取更好的编译质量。

由于即时编译器编译本地代码需要占用程序运行时间,通常要编译出优化程度越高的代码,所花费的时间也就越长;而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,这对解释执行阶段的速度也有所影响。为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot 虚拟机在编译子系统中加入了分层编译的功能。

分层编译可以简单理解为:对于热点代码使用 C1 编译器,对于热点代码中的热点代码使用 C2 编译获取更高地性能。

分层编译将 Java 虚拟机的执行状态分为了五个层次,详见本文附录

Java 8 默认开启了分层编译。不管是开启还是关闭分层编译,原本用来选择即时编译器的参数 -client 和 -server 都是无效的。当关闭分层编译的情况下,Java 虚拟机将直接采用 C2。

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

混合模式

目前主流的 Java 虚拟机,譬如 HotSpot、OpenJ9 等,内部都同时包含解释器与编译器,解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候解释器可以首先发挥作用省去编译的时间立即运行。当程序启动后,随着时间的偏移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。当程序运行环境中内存资源限制较大,可以使用解释执行节约内存,反之可以使用编译执行来提升效率。

你也可以使用参数 -Xint 强制虚拟机运行于“解释模式”(Interpreted Mode),这时候编译器完全不介入工作,全部代码都使用解释方式执行。另外,也可以使用参数 -Xcomp 强制虚拟机运行于“编译模式”(compiled mode),这时候将优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程。可以通过 -version 命令的输出结果显示这三种模式,内容如下,请注意加粗部分:

$ java -version
java version “1.8.0_212”
Java™ SE Runtime Environment (build 1.8.0_212-b10)
Java HotSpot™ 64-Bit Server VM (build 25.212-b10, mixed mode)

$ java -Xint -version
java version “1.8.0_212”
Java™ SE Runtime Environment (build 1.8.0_212-b10)
Java HotSpot™ 64-Bit Server VM (build 25.212-b10, interpreted mode)

$ java -Xcomp -version
java version “1.8.0_212”
Java™ SE Runtime Environment (build 1.8.0_212-b10)
Java HotSpot™ 64-Bit Server VM (build 25.212-b10, compiled mode)

同时,解释器还可以作为编译器激进优化时后备的“逃生门”(如果情况允许,HotSpot 虚拟机中也会采用不进行激进优化的客户端编译器 C1 充当“逃生门”的角色),让编译器根据概率选择一些不能保证所有情况都正确,但大多数时候都能提升运行速度的优化手段,当激进优化的假设不成立,如加载了新类以后,类型继承结构出现变化、出现“罕见陷阱”(Uncommon Trap)时可以通过逆优化(Deoptimization)退回到解释状态继续执行,因此在整个 Java 虚拟机执行架构里,解释器与编译器经常是相辅相成地配合工作,其交互关系如下图:

在这里插入图片描述

ps:讲真,以上最后一小段我也还没理解是什么意思。。

编译对象与触发条件

我们提到了在运行过程中会被即时编译器编译的目标是"热点代码",这里所指的热点代码主要由两类,包括:

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

对于这两种情况,编译的目标对象都是整个方法体,而不会是单独的循环体。第一种由于是依靠方法调用触发的编译,那编译器理所当然地会以整个方法作为编译对象,这种编译也是虚拟机中标准的即时编译方式。而对于后一种情况,尽管编译动作是由循环体所触发的,热点只是方法的一部分,但编译器依然必须以整个方法作为编译对象,只是执行入口(从方法的第几条字节码指令开始执行)会稍有不同,编译时会传入执行入口点字节码序号(Byte Code Index,BCI)。这种编译方式因为编译发生在方法执行的过程中,因此别很形象的称为“栈上替换”(On Stack Replacement,OSR),即方法的栈帧还在栈上,方法就被替换了。

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

那么多少次才算“多次”呢Java 虚拟机又是如何统计次数的?解决了这两个问题,也就解答了即时编译被触发的条件。

要知道某段代码是不是热点代码,是不是要触发即时编译,这个行为称为“热点探测”(Hot Spot Code Detection),其实进行热点探测并不一定要知道方法具体被调用了多少次,主流的判定方式有两种,分别为:

  • 基于采样的热点探测(Sample Based Hot Spot Code Detection)。这种方法会周期性的检查各个线程的调用栈顶,如果发现某个(或某些)方法经常出现在栈顶,那么这个方法就是”热点方法“。基于采样的热点探测的好处是实现简单高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热点,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。
  • 基于计数器的热点探测(Counter Based Hot Spot Code Detection)。这种方法会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。这种统计方法实现起来要麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系。但是它的统计结果相对来说更加精确严谨。

HotSpot 虚拟机中使用了基于计数器的热点探测,为了实现热点计数,HotSpot 为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter,“回边”的意思就是指在循环边界往回跳转)。当虚拟机运行参数确定的情况下,这两个计数器都有一个明确的阈值,计数器阈值一旦溢出,就会触发即时编译。

方法调用计数器:这个计数器就是用于统计方法被调用的次数,他的默认阈值在 C1 模式下是 1500 次,在 C2 模式下是 10000 次,这个阈值可以通过虚拟机参数 -XX:CompileThreshold 来人为设定。

当启用分层编译时,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 为循环回边次数。

如果没有做任何设置,执行引擎默认不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被即时编译器编译完成。当编译工作完成后,这个方法的调用入口地址就会被系统自动改成新值,下一次调用该方法就会使用已编译的版本了,整个即时编译的交互过程如下:

在这里插入图片描述

在默认设置下,方法调用计数器统计的并不是方法被调用的解决次数,而是一个相对的执行频率,即一段时间之内方法的被调用次数。当超过一定的时间限制,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那该方法的调用计数器就会被减少一半,这个过程被称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time),进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数 -XX:-UseCounterDecay 来关闭热度衰减,另外还可以使用 -XX:CounterHalfLifeTime 参数设置半衰周期的时间,单位是秒。

现在我们再来看另一个计数器——回边计数器,它的作用是统计一个方法中循环体代码执行的次数(准确的说,应当是回边的次数而不是循环次数,因为并非所有的循环都是回边,如空循环实际上就可以视为自己跳转到自己的过程,因此并不算控制流向后跳转,也不会被回边计数器统计),在字节码中遇到控制流向后跳转的指令就称为“回边”,很显然建立回边计数器统计的目的就是为了触发栈上的替换编译。

关于回边计数器的阈值,虽然 HotSpot 虚拟机也提供了一个类似于方法调用计数器阈值 -XX:CompileThreshold 的参数 -XX:BackEdgeThreshold 供用户设置,但是当前的 HotSpot 虚拟机实际上并未使用此参数,我们必须设置另外一个参数 -XX:OnStackReplacePercentage 来间接调整回边计数器的阈值,其计算公式有如下两种:

  1. 虚拟机运行在 C1 模式下,回边计数器阈值计算公式为:方法调用计数器阈值(-XX:CompileThreshold)乘以 OSR 比率(-XX:OnStackReplacePercentage)除以 100,其中 -XX:OnStackReplacePercentage 默认值为933,如果都是默认值,那 C1 模式的回边计数器的阈值是 13995;
  2. 虚拟机运行在 C2 模式下,回边计数器阈值的计算公式为:方法调用计数器阈值(-XX:CompileThreshold)乘以 (OSR 比率(-XX:OnStackReplacePercentage)减去解释器监控比率(-XX:InterpreterProfilePercentage)的差值)除以 100。其中 -XX:OnStackReplacePercentage 默认值为 140,-XX:InterpreterProfilePercentage 默认值为 33,如果都取默认值,那 C2 回边计数器的阈值为 10700.

当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有的话,它将优先执行已编译的代码,否则就把回边计数器的值加一,然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。当超过阈值时,将会提交一个栈上替换编译请求,并且把回边计数器的值稍微降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果,整个过程如下:

在这里插入图片描述

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

附录

分层编译将 Java 虚拟机的执行状态分为了五个层次

  1. 解释执行;
  2. 执行不带 profiling 的 C1 代码;
  3. 执行仅带方法调用次数以及循环回边执行次数 profiling 的 C1 代码;
  4. 执行带所有 profiling 的 C1 代码;
  5. 执行 C2 代码。

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

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

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

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

在这里插入图片描述

这里我列举了 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 层的执行时间。

选择用 1 层的 C1 编译。由于这是一个终止状态,因此 Java 虚拟机不会继续用 4 层的 C2 编译。

在 C1 忙碌的情况下,Java 虚拟机在解释执行过程中对程序进行 profiling,而后直接由 4 层的 C2 编译。在 C2 忙碌的情况下,方法会被 2 层的 C1 编译,然后再被 3 层的 C1 编译,以减少方法在 3 层的执行时间。

参考 《深入拆解Java虚拟机》、《深入理解Java虚拟机》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值