跳到主要内容
约 7 分钟3 张卡

分层编译

分层编译让 HotSpot 先快速执行代码,再为热点投入更多优化成本。本文从解释执行和热点探测讲起,依次介绍编译层级、运行时画像、编译资源与预热。即时编译器(JIT)会在程序运行期间,把字节码编译成本机机器码。

1. 面试核心​

先记住这几条,再读正文
  • 解释与编译共存。 javac 将源码编译为字节码,HotSpot 在同一进程中既能解释字节码,也能执行 JIT 生成的机器码。卡 · 解释执行
  • 热点与 OSR。 HotSpot 用方法调用计数和循环回边计数探测热点,OSR 能将正在执行的循环切换到编译代码。卡 · 热点探测
  • 编译层级。 HotSpot 的 0 级是解释器,1—3 级是 C1,4 级是 C2。卡 · 分层编译的层级
  • 机器码存储。 JIT 机器码存放在 Code Cache,编译任务由编译线程处理。
  • 预热目的。 预热让真实负载形成稳定的类型与分支画像,帮助 JIT 优化常用路径并降低启动后的请求延迟。

2. 解释执行​

解释器让方法能直接从字节码开始执行,应用无需等待整套代码编译完成。它在执行时还会收集调用次数、分支方向和接收者类型。这些运行时统计称为画像,用于指导后续编译。

解释执行适合承担以下三类工作:

  • 执行只运行一次的启动逻辑或很少进入的错误路径。
  • 收集方法实际怎样运行的画像,为优化提供依据。
  • 在后台编译尚未完成时,继续推进应用程序。

解释器需要反复解析和执行字节码指令,单次执行成本高于对应的优化机器码。但如果启动前先编译所有方法,又会为大量冷代码付出编译成本。HotSpot 因此根据执行热度选择方法,让反复执行的热点摊薄编译开销。

3. 热点探测​

热点既可以来自反复调用,也可以来自一次调用中的长循环。HotSpot 分别统计方法调用次数和循环回边次数。回边就是循环体执行后,跳回前面继续迭代的控制流。

即使方法只调用一次,内部循环执行足够多次,也能成为热点。OSR(On-Stack Replacement,栈上替换)允许当前执行在循环结束前切到编译后的循环入口。这样,剩余迭代就能使用优化机器码。普通编译入口则供后续方法调用使用。

编译时机取决于 JDK 版本、编译层级和 JVM 参数,也会受到编译队列压力的影响。因此,“调用一万次就编译”无法概括分层编译的实际策略。

4. 分层编译的层级​

分层编译使用 C1 和 C2 两种编译器,分别承担不同成本的优化。C1 编译较快,可以生成带画像采集逻辑的机器码。C2 编译耗时更长,会利用已有画像做更深入的优化,以提高热点代码的峰值性能。

下表以 OpenJDK 21 HotSpot 为准,列出0—4 级的执行方式和画像差异:

层级执行方式画像
0解释器按策略收集画像
1C1 优化代码不收集画像
2C1 优化代码调用与回边计数
3C1 优化代码完整画像
4C2 优化代码使用已有画像优化

典型路径是 0 → 3 → 4:先解释执行,再用 C1 收集完整画像,最后交给 C2 优化。C2 队列拥堵时,可以先进入 2 级,简单方法也可以停留在 1 级。层级按策略跳转,无需逐级经过 0—4。

可以用以下命令查看编译记录:

java -XX:+PrintCompilation -jar app.jar

阅读日志时,重点看编译版本的状态:

  • 方法名说明哪段代码被编译了。
  • % 标识这次是 OSR 编译,用于切换正在执行的循环。
  • 层级数字说明生成的是哪一级机器码。
  • not entrant 表示该版本停止接受新调用。

旧版本被高层级版本替换时,也会出现 not entrant,需要结合相邻事件判断原因。一条记录只能证明方法曾生成过该版本,请求之后使用哪个版本还要继续观察。

5. 运行时画像​

源码描述了允许怎样调用,运行时画像则告诉 JIT 实际怎样调用。画像会记录方法接收者的类型、分支方向和执行次数。接收者就是被调用方法所属的对象。

比如,一个方法通过 PriceRule 接口计算订单价格。源码只给出接口类型,画像却能显示这个调用点长期只接收某一种实现。JIT 就可以为这个常见类型生成带检查的直接调用或内联路径。同一段源码的输入分布不同,得到的优化结果也会不同。

类型变化会影响已有优化假设,具体处理见 方法内联与去优化。

6. 编译线程与 Code Cache​

编译本身也需要 CPU 和内存,所以热点出现后还要等待编译资源。默认分层模式下,任务由后台编译线程处理,生成的机器码存放在 Code Cache 中。它是 JVM 用来保存编译代码等内容的内存区域。

应用启动或流量突增时,JIT、类加载、GC 和业务请求会同时竞争 CPU。编译队列积压会延迟热点代码的优化,Code Cache 空间不足也会限制编译。启用 -XX:+UseCodeCacheFlushing 时,清理出空间后可以恢复编译。

可以用下面两个命令分别查看排队任务和代码缓存:

jcmd <pid> Compiler.queue
jcmd <pid> Compiler.codecache

Compiler.queue 显示等待编译的任务,Compiler.codecache 显示代码缓存的使用情况。需要把编译行为与请求延迟对应起来时,可查看 JFR 的编译、Code Cache 和去优化事件。JFR 即 Java Flight Recorder,用于记录 JVM 运行事件。

在 Linux 上,可用 jcmd <pid> Compiler.perfmap 为 perf 工具生成地址映射文件。这样,性能采样中的机器码地址就能对应到 Java 方法。诊断命令的可用性和输出格式需要在目标 JDK 中确认。

7. 预热​

预热的目的是让服务进入可比较的稳定状态。它需要覆盖真实请求类型和分支比例,因为反复执行一种固定输入,只会形成该输入的画像。预热不足时,最初一批请求还会承担类初始化和编译等待等成本,延迟也会偏高。

判断是否达到稳态时,需要观察:

  • 主要类是否已经完成加载和初始化。
  • 常用代码是否已经编译到合适层级。
  • 缓存和连接池是否进入稳定状态。
  • GC 周期和存活工作集是否处于可比较的范围。

存活工作集指回收后仍需保留的业务对象。即使 JIT 已经稳定,缓存持续增长或 GC 周期差异仍会影响测量。

测量前应明确目标是冷启动、预热过程还是稳态性能。冷启动测试要保留首次执行成本;稳态测试则应将预热与正式统计分开。否则,丢弃最初请求会掩盖启动成本,把冷启动混入稳态均值又会改变比较结果。

基准中的预热配置见 JMH。

8. 常见问题​

8.1 解释器与 JIT 的执行分工​

HotSpot 可以在同一进程中使用解释器和 JIT。源码先由 javac 编译成字节码,运行时再按热度选择解释执行或已编译的机器码版本。

8.2 强制编译模式​

8.3 同一方法的多次编译​

9 · 面试题卡片​

9.1 Java 是解释执行还是编译执行​

出现公司:去哪儿、拼多多

考察重点:区分源码编译、解释执行和即时编译,说明它们怎样平衡启动成本与热点性能。

相关内容:解释执行。

参考回答

HotSpot 同时支持解释执行和 JIT 编译执行,两者可以在同一进程中共存。

javac 先把源码编译成字节码,解释器随后执行字节码并采集运行画像。热点代码再经 C1、C2 等层级编译为机器码,长循环还可通过 OSR 在执行途中切换。

解释器省去了冷代码的前置编译成本,JIT 则利用真实画像优化反复执行的热点。这使服务能兼顾启动速度与峰值性能。

9.2 HotSpot 怎样判断热点代码​

出现公司:去哪儿、京东数科

考察重点:说明方法调用和循环回边怎样反映热度,以及 OSR 为何能优化一次调用中的长循环。

相关内容:热点探测。

参考回答

HotSpot 根据方法调用次数和循环回边次数等信息识别热点。

回边记录循环继续迭代的次数,所以方法只调用一次,内部长循环也能触发 OSR。当前执行可以在方法返回前切换到编译代码,让剩余迭代受益。

编译策略还会考虑当前层级、已有画像和队列压力。因此,触发阈值应结合目标 JDK 与参数解释,而非固定为某个调用次数。

9.3 HotSpot 的 0—4 级分别是什么,方法必须逐级升级吗?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:区分 C1 各层级收集的画像,并解释队列压力和方法特点怎样影响层级转换。

相关内容:分层编译的层级。

参考回答

0 级是解释器,1—3 级属于 C1,4 级属于 C2,方法无需逐级升级。

C1 的 1 级不收集画像,2 级收集调用和回边计数,3 级收集完整画像。C2 再利用已有画像做更深入的优化。

典型路径是 0 → 3 → 4。如果 C2 队列拥堵,可以先进入 2 级;简单方法也可以停留在 1 级。层级转换由编译策略根据运行情况决定。

10. 参考资料​