JIT、逃逸分析与 Code Cache:热点代码为何先快后慢
服务启动十分钟后吞吐升高,本来像是预热完成;运行几小时后 CPU 却抬升,编译日志出现 CodeCache 接近满,某些调用又回到较慢路径。若只看源码,业务逻辑没有变化;变化发生在 JVM 根据运行画像不断编译、内联、投机优化和去优化的过程中。
解释执行收集 profile,分层编译产生机器码,内联打开后续优化空间,逃逸分析可能消除分配,假设失效又触发 deopt。把这些动作与 Code Cache 放在同一条时间线上,才能判断服务是在正常预热、一次性重编译,还是陷入持续编译风暴。
观察 JIT 前先分清预热、稳态和退化
解释器、分层编译、热点阈值、OSR、内联、投机优化与去优化、逃逸分析、标量替换、锁消除、Code Cache 分区和编译队列共同塑造运行曲线。微基准只能帮助隔离机制,不能直接成为生产性能结论;Graal/JVMCI 与 HotSpot C1/C2 的实现差异也要绑定实际运行时再解释。
实验要有预热、稳定阶段和多轮 fork。观察参数和诊断日志可能改变时序;-XX:+PrintCompilation 等输出必须只用于受控环境,生产优先用低开销 JFR 与 jcmd。
JIT 和代码缓存:CPU 高不一定都在业务代码
Java 服务启动后,解释执行、C1/C2 分层编译、热点探测、方法内联、逃逸分析等都会影响性能曲线。服务刚启动时 CPU 高、延迟抖动,不一定是业务代码变差,也可能是预热不足或 JIT 编译活动密集。运行很久后如果代码缓存打满,热点代码可能无法继续编译,性能也会突然变差。
先看 JIT 和代码缓存状态:
jcmd "$PID" Compiler.codecache
jcmd "$PID" Compiler.codelist | head -n 40
jcmd "$PID" VM.flags | grep -E 'TieredCompilation|ReservedCodeCacheSize|InitialCodeCacheSize|CICompilerCount'可以用这张热路径图判断 CPU 高是不是只该回到业务代码。启动或流量切入早期,解释执行、分层编译和代码缓存写入会带来预热成本;稳定运行后,如果 code cache 接近上限,新的热点方法进不去编译产物区,也会让延迟和 CPU 曲线变差。
如果需要更完整的 CPU 画像,优先用 JFR 短采样:
jcmd "$PID" JFR.start name=profile settings=profile duration=120s filename=/var/log/myapp/app-profile.jfr启动期或压测环境可以临时打开编译日志:
java -XX:+PrintCompilation -jar app.jar把线程、Code Cache 和 JFR 放在一起看:
热点线程是 C1 CompilerThread、C2 CompilerThread,并且服务刚启动或流量刚切入,可能是预热和分层编译。Compiler.codecache 显示代码缓存接近满,可能出现编译受限,延迟曲线会变差。JFR 里热点集中在业务方法、序列化、正则、加密、日志格式化,要回到代码和数据规模排查。
JIT 排障最容易被三个表象带偏:
为了“立刻稳定”盲目关 JIT 或使用 -Xint。这会让服务长期吞吐显著下降,通常只适合极短时间验证问题归因。把启动预热期指标当成稳定期容量。Java 服务要区分冷启动、预热、稳定流量和流量突增。看到 CPU 高只抓一次线程栈,不看 JFR、GC 和编译线程,容易漏掉 JIT、GC 或 native 热点。
延迟敏感服务还要准备预热流量、发布后观察窗口和 JFR 应急采样脚本。排查 CPU 时,代码缓存、编译线程、GC 线程和业务线程都在视野内,不能只盯应用线程。
用预热探针同时观察编译与最终结果
examples/backend-development/jvm/jit-escape-codecache/JitWarmupProbe.java 在热方法里反复创建短命 Point,固定执行五百万次并输出 checksum。它适合观察方法从解释执行进入分层编译,以及在当前 HotSpot 构建下是否出现内联与分配消除线索;它不承诺 Point 一定被标量替换。
$sources = Get-ChildItem examples/backend-development/jvm -Recurse -Filter *.java
javac --release 17 -Xlint:all -Werror -d out/jvm $sources.FullName
java -XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining `
-cp out/jvm example.jvm.jit.JitWarmupProbe 60000
jcmd <pid> Compiler.codecachePrintCompilation 中应能找到 JitWarmupProbe::distance 或其调用者进入不同编译层级,最终 checksum 在相同代码和输入下保持一致。若方法被内联,独立的 distance 编译记录可能很快变成 not entrant,不能据此判断业务逻辑消失。要验证逃逸优化,应使用同版本的 JFR、编译日志或专门基准对比分配率;只看一次运行耗时会把 JVM 启动、编译线程和 OS 调度混进结论。
分层编译不是“解释一次然后永久变成本地代码”
HotSpot 会在解释执行和不同优化层级之间收集调用次数、分支概率、接收者类型等 profile。热点方法可以在方法入口编译,也可以通过 OSR 在长循环尚未返回时切入已编译代码。编译任务进入队列,由编译线程异步完成;因此启动期、突发流量期和稳定期的 CPU/延迟形态不同。
内联是许多后续优化的入口:调用边界消失后,编译器才更容易做常量传播、范围检查消除和逃逸分析。但代码体积、调用点多态程度和预算都会限制内联。看到一个小 getter 没有内联,不应先调全局阈值;先用 JFR 或诊断日志确认拒绝原因,因为扩大内联也会增加编译时间和 Code Cache 压力。
投机优化必须允许去优化
若 profile 表明某个接口调用长期只有一种实现,JIT 可以投机生成快速路径并保留守卫。后来加载第二种实现或分支分布变化,假设失效,执行会回到解释器或较低层级并重新编译。去优化是正确性机制,不一定是故障;但类动态生成、代理形态频繁变化或反复装卸代码可能让系统持续编译和 deopt,表现为 CPU 抖动。
诊断时把编译事件、class load、Code Cache 使用和业务延迟放到同一时间线。只截一张火焰图可能看到编译线程,却不知道它是启动预热还是长期风暴。
逃逸分析不能替代对象所有权设计
对象没有逃出当前编译单元时,JIT 可能做标量替换,让逻辑对象不真正分配;锁也可能在证明仅线程局部后被消除。但反射、未知调用、对象存入字段、返回给外部或诊断模式都可能阻止优化。new 出现在源码里不等于一定进入堆,反过来微基准里没有分配也不等于真实框架调用仍会消除。
验证分配优化应使用 JMH 多 fork、消费结果、防止死代码消除,并用 JFR allocation events 或 GC allocation rate 交叉验证。不要用 System.nanoTime 包一轮循环得出架构结论。
Code Cache 满时先查生成代码来源
Code Cache 保存 JIT 编译代码以及部分 VM stub;现代 HotSpot 可能按 non-method、profiled、non-profiled 分区。大量动态代理、模板生成、脚本、正则和方法形态会扩大已编译代码集合。jcmd PID Compiler.codecache 查看各区 size/used/max_used/free,Compiler.queue 辅助观察积压;不同 JDK 的命令和字段应以 help 为准。
如果日志提示编译被关闭或 CodeCache 已满,增大 ReservedCodeCacheSize 只能缓解容量,不能解释为什么生成了这么多热点代码。先查动态类数量、代理缓存、脚本生命周期和部署后 profile 变化;修复后比较 code cache 增长斜率、编译队列和稳定期吞吐。
nmethod 也有生命周期,已编译不等于永久有效
编译产物进入 Code Cache 后,可以处于可进入、不可再进入、等待清扫等不同阶段。某个投机假设失效时,正在该代码里的栈帧需要安全地去优化,新的调用则不能继续进入旧产物;等活跃栈不再使用后,清扫器才有机会回收空间。编译日志中的 made not entrant 因而不是“JIT 编译失败”,它说明旧 nmethod 已退出新调用入口。
这条生命周期把类加载和 Code Cache 连接起来。动态代理或脚本不断产生新类,会产生更多方法画像和编译产物;即使旧类最终可卸载,旧 nmethod 的失效与回收仍需要时间。若代理类缓存没有边界,表现可能是 loaded class、编译总数和 Code Cache used 一起增长。只扩大代码缓存会延后满载,却不会修复动态代码所有权。
把预热与退化写成可量化发布门禁
预热完成不能只写“等待十分钟”。用固定阶梯流量记录业务吞吐、P99、编译线程 CPU、编译队列、Code Cache used/max、loaded class 和分配率;当连续多个观察窗内吞吐与 P99 收敛、编译队列回落、代码缓存增长斜率趋稳,才进入容量测量。观察窗长度由服务启动行为和 SLO 决定,不从示例照抄。
退化告警也不能只按 Code Cache 使用率。建议组合三个条件:使用量持续接近容量边界、编译队列或编译失败/禁用事件出现、业务 CPU/P99 同时恶化。若代码缓存高位稳定但业务无退化,先评估余量而不是自动重启;若 used 持续增长,则按类名、代理/脚本来源和发布批次追新增代码所有者。
| 阶段 | 必看信号 | 通过判据 |
|---|---|---|
| 冷启动 | 解释执行比例、编译线程 CPU、队列 | 允许短时活跃,但不能挤占就绪探针预算 |
| 预热 | 吞吐/P99、编译总量、Code Cache 增量 | 多个窗口收敛,不再持续陡增 |
| 稳态 | 业务热点、deopt、loaded class、used/max | 画像与负载相符,增长斜率受控 |
| 退化 | not entrant/deopt 频率、队列、CPU | 修复后同负载下异常频率与业务指标同时恢复 |
“先快后慢”要回到同一条时间线
看到“先快后慢”时,先对齐负载、GC、线程和下游,再看编译队列、Code Cache、deoptimization 与热点栈。逃逸分析是实现可能做出的优化,不是源码层保证;不要因为一个微基准显示零分配,就把对象生命周期写进架构契约。若要据此决定预热策略或容量,至少要在带真实 Agent、容器 CPU 限额和线上启动参数的基线压测中复现。
编译活动看不清时,用这三个入口交叉确认
JDK 25 jcmd:Compiler.codecache、Compiler.queue、JFR 启停和运行时命令的影响说明。JDK 25 Diagnostic Tools:怎样用 JFR 把编译、CPU、分配、锁和 GC 放到同一时间线上。
JDK 25 Flight Recorder API Guide:需要自定义事件或持续消费录制数据时的正式入口。
JIT 的优化会随着运行画像变化,因此不能只留一张稳定期火焰图。至少把启动预热、性能正常和退化三个时间窗的编译事件、Code Cache、类加载和业务延迟并排比较。这样看到 deopt 或编译线程活跃时,才能判断它是正常预热、一次性重编译,还是持续消耗 CPU 的编译风暴。
