JIT、逃逸分析与 Code Cache
HotSpot 可以用解释器执行字节码,也可以将热点方法编译成机器码。运行中收集的调用次数、类型分布和分支信息,会影响编译时机与优化方式;输入形态变化后,已有机器码还可能失效并被重新编译。
字节码 → 解释执行 / 已有编译代码 → 运行画像
↓
编译任务 → nmethod → Code Cache
↑ ↓
新画像与重编译 ← 假设失效分层编译解释方法如何变热,OSR 解释长循环如何在中途切换执行方式,逃逸分析解释部分对象为何不再分配,Code Cache 则保存这些编译产物。
编译并运行一个热点探针
实验使用 amd64/x86_64 的 Temurin HotSpot 17.0.20+8。从 Eclipse Temurin 下载页取得完整 JDK,而不是只有 java 的运行时;先确认 java、javac 和 jcmd 来自同一个目录:
export JDK17_HOME=/opt/jdk-17
unset JAVA_TOOL_OPTIONS JDK_JAVA_OPTIONS _JAVA_OPTIONS
"$JDK17_HOME/bin/java" -version
"$JDK17_HOME/bin/javac" -version
"$JDK17_HOME/bin/jcmd" -h下载完整实验包,在 Linux amd64/x86_64 的 Bash 中以普通用户解压,进入 jit-escape-codecache。JitLifecycleProbe.java包含同一语义计算、接收者类型变化、临时对象分配与代码缓存四种负载。
LAB_OUT=$(mktemp -d /tmp/jit-escape-codecache.XXXXXX)
"$JDK17_HOME/bin/javac" --release 17 -Xlint:all -Werror \
-d "$LAB_OUT" JitLifecycleProbe.java
PROBE=example.jvm.jit.JitLifecycleProbe
"$JDK17_HOME/bin/java" -cp "$LAB_OUT" "$PROBE" semantic
"$JDK17_HOME/bin/java" -Xint -cp "$LAB_OUT" "$PROBE" semantic两次应输出 mode=semantic checksum=349980083696。解释执行可能明显更慢,耗时不参与正确性判断。若结果不同,先检查源码、版本和参数,暂停后续性能比较。
完整自动检查通过 bash run.sh 启动:
bash run.sh脚本会严格编译源码,然后用默认分层编译与 -Xint 各执行一次相同负载。两次 checksum 必须一致;这是第一条成功结果:解释执行和 JIT 可以改变执行方式,不能改变 Java 程序结果。
自动检查的摘要形态如下;GC 次数是固定运行环境中的观察值:
semantic-checksum=349980083696 (default == -Xint)
compile-levels=dispatch level 3 -> level 4
osr-evidence=% b 3 example.jvm.jit.JitLifecycleProbe::monomorphicLoop @ 13
receiver-shape=single receiver inline -> old level 4 not entrant -> two receivers inline
escape-checksum=34952461919616 gc-with-ea=0 gc-without-ea=135
codecache-layout=non-profiled + profiled + non-nmethods (size/used/max_used/free)
codecache-pressure=8MiB full_count>0 / default capacity full_count=0 checksum=63538187767
PASS jit-escape-codecache-Xbatch、CompileThresholdScaling=0.1、PrintCompilation 和 PrintInlining 只是让教学记录更快、更稳定地出现。生产服务默认由编译线程异步工作,不要为了“稳定”长期使用 -Xint,也不要把实验阈值直接带进启动参数。
分层编译与 OSR
HotSpot 先执行字节码并收集调用次数、循环回边、接收者类型和分支行为。达到策略阈值后,编译任务进入队列;编译线程生成 nmethod 并安装到 Code Cache,后续调用才可能进入这份机器码。这里没有“解释一次,永久变成本地代码”的状态切换。
JDK 17 HotSpot 的编译层级可以先按下表读。策略可以跳级、重编译或停在较低层,表格不是固定流水线:
| level | 本基线中的主要执行形态 | 是否收集供更高层使用的画像 |
|---|---|---|
| 0 | 解释器 | 是 |
| 1 | C1,简单编译 | 否 |
| 2 | C1,有限画像 | 有限 |
| 3 | C1,完整画像 | 是 |
| 4 | C2,优化编译 | 主要消费既有画像 |
运行带编译日志的接收者变化模式:
"$JDK17_HOME/bin/java" -Xbatch -XX:CompileThresholdScaling=0.1 \
-XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining \
'-XX:CompileCommand=dontinline,example.jvm.jit.JitLifecycleProbe::dispatch' \
-cp "$LAB_OUT" "$PROBE" lifecycle 5000000 >"$LAB_OUT/lifecycle.log" 2>&1
grep -E 'phase=|JitLifecycleProbe::dispatch|monomorphicLoop|AddOne::apply|DoubleValue::apply' \
"$LAB_OUT/lifecycle.log"dontinline 只阻止 dispatch 本身被调用方内联,便于观察它独立的编译产物;dispatch 内部仍可内联接收者的 apply。普通调用会留下类似记录:
195 302 b 3 example.jvm.jit.JitLifecycleProbe::dispatch (8 bytes)
196 303 b 4 example.jvm.jit.JitLifecycleProbe::dispatch (8 bytes)从左到右分别可读作相对时间、编译任务编号、标记、level、方法和字节码大小。b 来自实验的 -Xbatch,表示调用线程等待编译完成;它不是线上异步编译记录的必有字段。
长循环还有另一条入口。方法尚未返回时,回边计数已经足够热,JVM 可以为循环体创建 OSR(On-Stack Replacement)入口,让当前栈帧从某个字节码位置切入编译代码:
... % b 3 example.jvm.jit.JitLifecycleProbe::monomorphicLoop @ 13% 表示 OSR,@ 13 是进入点的 bytecode index。它不表示“整个方法已经从入口编译”,也不是所有机器码的属性。普通入口与 OSR 入口必须分别读,才能解释为什么一个长循环在本次调用中途就变快。
线上只看到编译线程忙还不够。短时队列活跃可能是正常预热;只有编译任务持续积压,并与同窗 CPU、延迟或代码形态变化一起出现,才值得继续追查。
内联与逃逸分析
内联先消除调用边界,让 C2 能看到更大的优化范围。常量传播、范围检查消除和逃逸分析因此更有机会发生;但代码大小、调用点形态和编译预算都会限制内联,不能把“小方法必然内联”写成规则。
探针的热路径每次都创建一个逻辑 Point:
private static long distance(int value) {
Point point = new Point(value, value + 1);
return (long) point.x * point.x + (long) point.y * point.y;
}Oracle JDK 17 HotSpot 性能指南把逃逸状态分为三类:
| 状态 | 典型边界 | 生成代码可能采取的动作 |
|---|---|---|
NoEscape | 使用没有逃出当前分析范围 | 可能标量替换,分配不再物化;相关锁也可能消除 |
ArgEscape | 作为参数参与调用,但没有全局逃逸 | 取决于被调方法可见性、内联与分析结果 |
GlobalEscape | 返回、写入静态字段,或进入已逃逸对象 | 通常保留实际分配与对象身份 |
非逃逸对象还需满足标量替换的实现条件。 HotSpot 的标量替换会让字段值直接参与计算,并不等于把对象改成 JVM 规范承诺的“栈上分配”。反射、未知调用、返回对象、写入字段、调用未内联或编译预算不足,都可能让源码中看似短命的对象仍然物化。
在相同 32 MiB Serial 堆与 5000 万次计算下,只切换 DoEscapeAnalysis:
for ea in + -; do
"$JDK17_HOME/bin/java" -Xms32m -Xmx32m -XX:+UseSerialGC \
-Xbatch -XX:CompileThresholdScaling=0.1 "-XX:${ea}DoEscapeAnalysis" \
'-Xlog:gc=info:stderr:none' -cp "$LAB_OUT" "$PROBE" escape 50000000 \
>"$LAB_OUT/ea-$ea.out" 2>"$LAB_OUT/ea-$ea.log"
cat "$LAB_OUT/ea-$ea.out"
grep -c 'Pause Young' "$LAB_OUT/ea-$ea.log" || true
donegrep 没有匹配时输出 0 并退出 1,最后的 true 只用于接受“零次匹配”,不隐藏 Java 的错误;Java 的退出状态和输出仍需检查。固定运行中的对照为:
escape-checksum=34952461919616 gc-with-ea=0 gc-without-ea=135checksum 相同证明语义没有变化;默认路径 0 次 Young GC、关闭逃逸分析后 135 次,证明这个固定构建和代码形状生成了不同的分配路径。它不证明所有 new Point 都会消失,更不能用两次运行耗时替代结论。真实服务还要在相同启动参数和负载下,用分配事件或 allocation rate 交叉验证。
类型变化与去优化
JIT 可以依据运行画像投机优化。例如接口调用长期只看到 AddOne,C2 可以加类型守卫并内联它;后来真正出现 DoubleValue,旧假设不再覆盖当前输入,JVM 必须让执行回到仍然正确的状态。这个过程可能包括 uncommon trap、deoptimization、重建被内联的解释器帧、重新收集画像和再次编译;若先前消除了对象物化,去优化时还可能重建那些逻辑对象。
探针先执行五百万次单接收者调用,再进入双接收者阶段。脚本按日志行号验证以下顺序,而不是搜索几个互不相关的词:
phase=monomorphic
... dispatch level 4 ... AddOne::apply inline (hot)
phase=bimorphic
... dispatch level 4 ... made not entrant
... dispatch level 4 ... AddOne::apply inline (hot)
... DoubleValue::apply inline (hot)这里的 made not entrant 表示旧 nmethod 不再接收新的调用,后面确有一份适配新画像的 level 4 编译。这个结论只属于当前受控探针:其他日志里的同一句话也可能来自层级升级、依赖失效或代码退役,不能简化成“方法被内联,所以 not entrant”。
一次去优化是正确性机制,也可能只是应用进入新稳态前的一次成本。只有同一方法或同一类代码形态持续出现去优化与重编译,编译线程 CPU 长期不回落,并且业务 P99 同窗恶化,才形成“编译风暴”的候选链。
Code Cache 的容量与回收
Code Cache 位于 Java 堆之外,保存 JIT 生成的本地代码和部分 VM 非方法代码。JDK 17 HotSpot 在开启 Tiered Compilation 且 ReservedCodeCacheSize >= 240 MiB 时默认分段;JDK 17 java 文档给出了这一条件:
| Code Heap | 主要内容 | 本基线中的典型来源 |
|---|---|---|
non-nmethods | interpreter、stub、adapter、compiler buffer 等 | VM 非方法代码 |
profiled nmethods | 带画像、预期较短寿的编译方法 | 主要是 level 2/3 |
non-profiled nmethods | 无画像或充分优化的编译方法、本地方法 | 主要是 level 1/4 与 native |
先启动一个完成计算后等待释放信号的 JVM,再检查它的代码缓存。探针最多等待 5 分钟,结束后 PID 失效,不能继续附加。
"$JDK17_HOME/bin/java" -cp "$LAB_OUT" "$PROBE" hold 5000000 \
"$LAB_OUT/release" 300000 >"$LAB_OUT/hold.log" 2>&1 &
LAB_PID=$!
for attempt in $(seq 1 200); do
grep -q 'ready=codecache' "$LAB_OUT/hold.log" && break
kill -0 "$LAB_PID" 2>/dev/null || break
sleep 0.1
done
cat "$LAB_OUT/hold.log"输出 pid 应与 LAB_PID 一致,且包含 ready=codecache。没有 ready 时先检查启动日志。已就绪后再执行:
"$JDK17_HOME/bin/jcmd" "$LAB_PID" help Compiler.codecache
"$JDK17_HOME/bin/jcmd" "$LAB_PID" Compiler.codecache
"$JDK17_HOME/bin/jcmd" "$LAB_PID" Compiler.queue典型输出会为每段给出 size、used、max_used 和 free。Compiler.codecache 与 Compiler.queue 在 JDK 17 jcmd 文档中标为低影响,但仍要求在同一主机、以相同有效用户附加;先查看目标 JDK 的 help,不要把实验命令当成跨版本保证。Compiler.codelist 会枚举全部存活编译方法,目标 JVM 端成本不会因为本地再接一个 head 就消失,因此不应作为第一步。
在 JDK 17 中,in_use 的产物可以接收新调用,也可能正在活跃栈上执行;变为 not_entrant 后不再接收新调用,但已有栈帧仍可能安全执行旧代码。没有活跃使用或所属类已卸载后,产物才可能进入 zombie/unloaded 等可清扫状态;sweeper 最终回收空间,所以生成速率和回收速率共同决定 free 的走势。
JDK 20 的 Remove the Sweeper 变更移除了上述 sweeper 实现。JDK 25 仍需保证活跃代码的执行安全,但不能再把 JDK 17 的 zombie/sweeper 流程画成通用生命周期。跨版本诊断优先比较目标进程的代码占用、编译事件和回收趋势。
Code Cache 高位稳定可能对应稳定的热点集合。若日志已出现 CodeCache is full 或编译被禁用,要区分正常但庞大的热点集合、错误的分段容量、连续空间不足,以及真正生成字节码的代理、模板或脚本实现持续制造并加热新方法;标准 java.util.regex 的普通使用不能被无条件列为“动态代码来源”。只增大 ReservedCodeCacheSize 会延后触顶,只有来源增长已受控而稳态容量确实不足时,它才是容量动作。
公开脚本还在两个独立子进程中给出有界对照:两侧都固定 4000 个隐藏类、相同调用次数并关闭 flushing;8 MiB 侧把三段切为 3/2/3 MiB,真实得到方法区段满、compilation: disabled 和 full_count>0,默认 240 MiB 侧则保持 compilation: enabled、full_count=0,checksum 相同。它只证明这组热方法在两种容量下的编译可用性不同,不把关闭 flushing 或手工切分 Code Heap 当成生产建议。
预热、重编译与持续退化
把证据按冷启动、稳定、变化和退化四个窗口排列,比孤立的一张火焰图或一次 jcmd 更有意义:
| 现象 | 第一组可判定证据 | 动作 | 同负载复测 |
|---|---|---|---|
| 冷启动偏慢,随后收敛 | 编译队列先活跃后回落;level 3/4 产物建立;Code Cache 增长趋稳 | 调整就绪与预热窗口,不关闭 JIT | 相同发布流程下,就绪后 P99 与吞吐稳定 |
| 新接收者后一次抖动 | 目标方法旧 nmethod 退役,随后完成一次新画像编译 | 先判断是否预期流量形态,不把一次 deopt 当事故 | 新稳态中重编译停止,业务指标恢复 |
| 编译活动持续不退 | 相同方法或代码来源反复重编译;编译线程 CPU 与 P99 同窗上升 | 修类型形态抖动、无界代理/脚本生成或生命周期 | 同负载下新增类、重编译速率和 CPU 同时下降 |
Code Cache free 持续下降并出现编译受限 | 三段趋势、编译失败/禁用事件、业务退化同时成立 | 先止住代码来源;确认稳态需求后再调容量 | 不再出现受限事件,free 斜率收敛且 P99 恢复 |
对前面仍在等待的 LAB_PID,可以取得两次快照并记录一个 30 秒窗口。目录位于本次临时目录,文件只对当前用户开放。JFR.start 会立即返回,读取文件前必须等待录制结束。
umask 077
"$JDK17_HOME/bin/jcmd" "$LAB_PID" Compiler.codecache >"$LAB_OUT/codecache-T0.txt"
"$JDK17_HOME/bin/jcmd" "$LAB_PID" Compiler.queue >"$LAB_OUT/queue-T0.txt"
"$JDK17_HOME/bin/jcmd" "$LAB_PID" JFR.start name=jit-window settings=profile \
duration=30s filename="$LAB_OUT/jit-window.jfr" 'jdk.Compilation#threshold=0ms'前面启动的是计算后等待的 JVM,这段窗口通常没有持续的业务编译;它用于练习采集,不能从空事件推断热负载未发生过编译。实际服务应在目标负载开始前启动录制。
至少等待 30 秒,确认 JFR.check 已没有名为 jit-window 的运行中录制,再读取完整文件:
"$JDK17_HOME/bin/jcmd" "$LAB_PID" JFR.check
test -s "$LAB_OUT/jit-window.jfr"
"$JDK17_HOME/bin/jfr" summary "$LAB_OUT/jit-window.jfr"
"$JDK17_HOME/bin/jcmd" "$LAB_PID" Compiler.codecache >"$LAB_OUT/codecache-T1.txt"
"$JDK17_HOME/bin/jcmd" "$LAB_PID" Compiler.queue >"$LAB_OUT/queue-T1.txt"
"$JDK17_HOME/bin/jfr" print --events \
'jdk.Compilation,jdk.Deoptimization,jdk.CompilationFailure,jdk.CodeCacheFull' \
"$LAB_OUT/jit-window.jfr" >"$LAB_OUT/jit-events.txt"队列回落、free 趋稳且没有业务退化,说明更像正常预热。相同方法持续出现 compilation/deoptimization,且 P99 同窗恶化,才进入编译风暴分支;jdk.CodeCacheFull 或 jdk.CompilationFailure 出现,同时 T0→T1 的 free 继续下降,才进入缓存压力分支。settings=profile 比默认持续录制开销更高,30 秒只是有界示例;目标版本的事件是否启用、目录空间和采集影响必须在低峰先演练。
若 Code Cache 正常、编译队列为空,CPU 仍高,就停止在 JIT 结论上继续加参数:转查业务热点、GC、锁、native 或下游。反过来,如果只是一次 made not entrant 而业务没有退化,也不需要“优化掉”JVM 的正确性机制。
启动优化与稳态优化
CDS共享可复用的类数据,主要减少重复启动工作和部分内存开销。JDK 25 的 AOT cache 可复用训练阶段取得的类加载等优化信息,具体使用见 JDK 25 java 命令的 AOT cache 说明。这些机制与运行时 JIT 配合,不应统称为“Java 已不需要预热”。
评估启动优化时,分别测 JVM 启动、服务就绪、首个请求和稳态吞吐。训练用的数据与真实请求差别过大,预热出来的接收者类型和分支也可能不同。比较优化前后保持应用制品、JDK、Agent、CPU 配额和请求分布一致。
性能数字需要独立测量
上面的探针检查计算结果和编译行为,没有给出性能排名。要比较两个实现的吞吐或延迟,使用 OpenJDK JMH建立基准,控制预热、测量迭代、独立 JVM fork、结果消费和输入分布。否则常量折叠、死代码消除、循环整体优化或另一项基准留下的画像,都会改变测到的内容。
微基准仍需回到真实服务负载确认。更快的局部计算可能增加分配,较低的平均耗时也可能伴随更差的尾延迟;业务压测与编译/分配观察应分别保留。
停止探针与保留结果
先结束等待中的 Java 进程,再删除本次临时目录。需要保存 JFR 时,在删除前复制到受控存储。
touch "$LAB_OUT/release"
wait "$LAB_PID"
case "$LAB_OUT" in
/tmp/jit-escape-codecache.*) rm -rf -- "$LAB_OUT" ;;
*) printf '拒绝清理未知路径\n' >&2 ;;
esac权威资料与规范地址
编译参数、运行时诊断和性能基准分别查对应文档,注意区分 JDK 17 实验与后续版本的实现。
| 资料 | 地址 |
|---|---|
| Eclipse Temurin 下载页 | https://adoptium.net/temurin/releases/?version=17 |
| Oracle JDK 17 HotSpot 性能指南 | https://docs.oracle.com/en/java/javase/17/vm/java-hotspot-virtual-machine-performance-enhancements.html |
| JDK 17 java 文档 | https://docs.oracle.com/en/java/javase/17/docs/specs/man/java.html |
| JDK 17 jcmd 文档 | https://docs.oracle.com/en/java/javase/17/docs/specs/man/jcmd.html |
| Remove the Sweeper 变更 | https://mail.openjdk.org/pipermail/jdk-changes/2022-August/019099.html |
| CDS | https://docs.oracle.com/en/java/javase/25/vm/class-data-sharing.html |
| JDK 25 java 命令的 AOT cache 说明 | https://docs.oracle.com/en/java/javase/25/docs/specs/man/java.html |
| OpenJDK JMH | https://github.com/openjdk/jmh |
