分层编译
分层编译让 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 | 解释器 | 按策略收集画像 |
| 1 | C1 优化代码 | 不收集画像 |
| 2 | C1 优化代码 | 调用与回边计数 |
| 3 | C1 优化代码 | 完整画像 |
| 4 | C2 优化代码 | 使用已有画像优化 |
典型路径是 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 强制编译模式
-Xcomp 在方法首次调用时要求编译,适合实验和诊断编译路径。提前编译会增加启动成本,而且此时类型和分支画像还不充分,优化质量也会受到影响。因此,是否改善业务性能,需要单独测量。
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 级。层级转换由编译策略根据运行情况决定。