同一段代码,第一次跑 200ms,预热十分钟后跑 2ms。不是缓存起效,是 JVM 在后台把字节码重写成了机器码。它没有开关,但有明确的触发条件、产物形态和失效路径——搞清楚这三点,-XX:+PrintCompilation 那些列才读得懂。
按"执行层级 → 触发条件 → 中间表示 → 去优化 → 代码缓存"拆。
1. 三种执行引擎与它们的分工
HotSpot 里同时存在解释器、C1(Client Compiler)、C2(Server Compiler)。三者不是替代关系,是接力关系。
| 引擎 | 编译耗时 | 生成代码质量 | 典型角色 |
|---|---|---|---|
| 解释器 | 无 | 极低(逐条派发) | 冷代码、启动期、去优化回退落点 |
| C1 | 快(毫秒级) | 中等,带基础优化 | 尽早接管热点,收集 profile |
| C2 | 慢(数十毫秒到数百毫秒) | 高(激进内联、循环优化) | 长时间运行后的稳定峰值 |
对应三个运行模式:
$ java -Xint -jar app.jar # 纯解释,不编译
$ java -Xcomp -jar app.jar # 首次调用就编译(调试用,破坏 profile)
$ java -jar app.jar # 默认 -Xmixed,分层编译
-Xcomp 在生产上只会更慢:没有 profile 的 C2 只能靠静态推测内联,猜错就去优化,来回震荡。默认的 -Xmixed 才是推荐形态,JDK 8 起分层编译默认开启。
2. 五个编译级别
分层编译(Tiered Compilation)把"解释 → C1 → C2"细分成 5 级,用 -XX:TieredStopAtLevel=N 可以截断:
| level | 编译器 | 带 profile | 用途 |
|---|---|---|---|
| 0 | 解释器 | — | 冷启动 |
| 1 | C1 | 否 | 极简 C1,无 profile(仅在特殊场景直接用) |
| 2 | C1 | 是(仅方法调用计数) | 有限 profile |
| 3 | C1 | 是(全量 profile) | 默认的第一站,为 C2 攒数据 |
| 4 | C2 | 是 | 峰值性能 |
默认路径是 0 → 3 → 4。level 2 只在小方法、或 C2 队列积压时作为折中出现在日志里。
$ java -XX:+PrintCompilation -jar app.jar 2>&1 | head -12
127 38 3 com.example.Demo::compute (24 bytes)
131 39 % 4 com.example.Demo::compute @ 6 (24 bytes)
158 40 4 java.lang.String::hashCode (55 bytes)
PrintCompilation 的列含义必须记牢,否则整段日志没法读:
| 列 | 含义 | 判读要点 |
|---|---|---|
| 首列 | 距 JVM 启动的毫秒数 | 观察"何时开始编译" |
| 第二列 | 编译任务 ID | 同 ID 多次出现 = 重编译 |
% |
发生了 OSR(栈上替换) | 说明是循环体热,方法整体还没到阈值 |
| 第三列数字 | 目标 level | 3 → 4 是正常晋升 |
made not entrant |
该版本代码被标记淘汰 | 通常伴随去优化 |
made zombie |
代码已无人引用,等待回收 | 代码缓存压力信号 |
3. 触发条件:两个计数器
编译不是按时间触发,是按计数触发。每个方法有两块计数:
# 关键参数(默认值按 JDK 17 核对,不同版本可能有偏移)
-XX:Tier3InvocationThreshold=200 # level 3 的方法调用阈值
-XX:Tier3BackEdgeThreshold=60000 # level 3 的回边阈值
-XX:Tier4InvocationThreshold=5000 # level 4 的方法调用阈值
-XX:Tier4BackEdgeThreshold=40000 # level 4 的回边阈值
-XX:CompileThreshold=10000 # 关闭分层编译时的单一阈值
| 计数器 | 何时加一 | 代表什么 |
|---|---|---|
| 方法调用计数器 | 每次 invoke* 进入方法 |
这个方法被反复调用 |
| 回边计数器(back edge) | 循环体每次跳回循环头 | 单个方法内部跑得久 |
计数器衰减排容易被忽略:方法调用计数每过一段时间减半(与 -XX:CounterHalfLifeTime 相关)。被调用一万次但分布很散的方法可能永远到不了阈值——"热点方法没被编译"的常见答案。
循环长的方法走另一条路:不等整体编译,直接在循环体入口做栈上替换(OSR),用编译版本替换正在解释的帧。日志里的 % 就是它。
4. C2 的中间表示:Sea-of-Nodes
C2 不是"字节码到汇编"的直译器。它先把字节码转成一种叫 Sea-of-Nodes 的图 IR:节点是运算(Add、Load、Phi、Region、If),边只表示数据依赖和调度依赖,没有显式的控制流顺序。
好处是优化可以纯图变换:常量折叠把 Add 节点换成常量;公共子表达式消除找结构相同的节点;内联拿被调方法的图替换 Call 节点。坏处是图规模会爆,调试输出极难读。
$ java -XX:+UnlockDiagnosticVMOptions -XX:+PrintIdeal -XX:+PrintCompilation \
-XX:CompileCommand=print,Demo::compute -jar app.jar
| 想看的产物 | 参数 | 输出形态 |
|---|---|---|
| 内联决策 | -XX:+PrintInlining |
树状缩进,"inline (hot)" / "too big" |
| 高级 IR(Ideal 图) | -XX:+PrintIdeal |
节点编号 + 依赖连线的文本表示 |
| 机器码反汇编 | -XX:+PrintOptoAssembly |
需 debug/慢速调试构建 |
| 最终机器码 | -XX:+PrintAssembly + hsdis |
真实汇编,需放置 libhsdis |
| 内联失败原因统计 | -XX:+UnlockDiagnosticVMOptions -XX:+LogCompilation |
XML 日志,配 JITWatch 读 |
内联是收益最大的一步:把跨方法调用变成同一张图里的节点,逃逸分析、标量替换才有可能发生。-XX:MaxInlineSize(默认 35 字节码)与 -XX:FreqInlineSize(默认 325)是两个门槛——热点放宽到 325,冷方法只有 35。所以同一方法在单测里"没内联",压测里内联了。
5. 去优化:编译假设被打破之后
C2 会基于 profile 做推测优化,最典型的是"这个接口调用点上永远只出现过 ImplA",于是直接内联 ImplA 的实现。一旦真的来了 ImplB,编译代码没法处理——它必须退回去。
机制是 uncommon trap:编译产物里埋一个检查点,条件不满足就跳到一段"去优化桩",把当前栈帧重建回解释器能理解的状态,从该字节码位置继续解释执行。
$ java -XX:+UnlockDiagnosticVMOptions -XX:+TraceDeoptimization -jar app.jar
DEOPT PACKING thread 0x00007f… pc=0x00007f…
Unpacking frame 0 (interpreted) ...
DEOPT UNPACKING thread 0x00007f… pc=0x00007f… mode 0
| 现象 | 机制 | 是否正常 |
|---|---|---|
同一个方法反复 3 → 4 → made not entrant |
类型 profile 不稳定,反复去优化 | 异常,通常是多态调用点过杂 |
| 压测初期吞吐抖动 | 热代码在解释 / C1 / C2 间迁移 | 正常预热成本 |
uncommon trap 数量随请求量线性增长 |
每次都撞同一个类型假设 | 需要看是不是接口实现被频繁切换 |
| 去优化后性能不回升 | 重编译又用同样假设,循环 | 用 -XX:-TieredCompilation 或调整 profile 收集 |
判断标准:去优化本身正常,成规模的反复去优化才是问题。-XX:+LogCompilation 的 XML 里每个 <uncommon_trap> 都带原因码,配 JITWatch 能看出哪一行、因为哪个类型假设失败。
6. 代码缓存:编译产物也要地方放
编译出来的机器码存在一段叫 code cache 的本地内存里,默认大小按平台和版本不同,通常是 240MB 量级(-XX:+PrintFlagsFinal | grep ReservedCodeCacheSize 核对本机值)。
| 段 | 存放内容 | 满了会怎样 |
|---|---|---|
| non-method | 编译桩、适配器、解释器入口 | 直接 CodeCache is full |
| profiled | C1(level 2/3)产物 | 晋升 C2 失败 |
| non-profiled | C2(level 4)产物 | 热点无法升到峰值 |
$ java -XX:ReservedCodeCacheSize=256m -XX:+UseCodeCacheFlushing \
-XX:+PrintCodeCache -jar app.jar
UseCodeCacheFlushing 在 JDK 8 后默认开启,会把 "zombie" 代码清掉换空间。代价是被清掉的方法下次调用要重新编译,于是出现"性能周期性掉坑"。日志里 made zombie 频繁出现,就该加大 ReservedCodeCacheSize,而不是继续依赖 flush。
7. 排错:六种"编译不生效"
| 现象 | 原因 | 处理 |
|---|---|---|
PrintCompilation 一个方法都没有 |
跑的是 -Xint,或在容器里被 JAVA_TOOL_OPTIONS 覆盖 |
jcmd <pid> VM.flags 看真实生效参数 |
| 方法调了几万次仍不编译 | 计数器按半衰期衰减,调用太分散 | 看 -XX:CounterHalfLifeTime,或确认是否真热点 |
| 日志只有 level 3,永不进 4 | C2 编译队列积压 / 代码缓存不足 | 看 CompileQueue 与 PrintCodeCache |
CompileThresholdScaling 被设成小数 |
容器环境按 CPU 配额自动缩放阈值 | 直接看 VM.flags 里的实际值,别按默认值推算 |
内联日志全 too big |
方法体超过 FreqInlineSize |
拆方法,或谨慎调 -XX:FreqInlineSize |
| 峰值性能比 C1 还差 | 推测优化猜错,反复去优化 | 用 TraceDeoptimization 定位,收窄多态调用点 |
补充第一条:容器 CPU limit 会让 JVM 自动缩放编译阈值(-XX:CompileThresholdScaling 随可用 CPU 变化),"同样的代码在物理机和容器里编译时机不同"是设计行为。任何阈值推断都必须先读 VM.flags。
8. 边界:JIT 管不到的地方
| 场景 | JIT 的实际能力 | 更该做的事 |
|---|---|---|
| 启动阶段耗时 | 基本无用(还在解释执行) | AppCDS、类数据共享、减少初始加载 |
| 一次性脚本 / Serverless 短生命周期 | 编译成本 > 收益 | 考虑 AOT(GraalVM native-image)、CRaC |
| native 方法内部耗时 | 完全看不到 | perf、async-profiler(含 native 模式) |
| GC 停顿 | 与 JIT 无关 | GC 日志 + GC 参数 |
| 纯 IO 等待 | 编译不改变等待 | 线程模型、连接池、批量 |
另一个维度是 JVM 实现差异。HotSpot 用 C1/C2 + Sea-of-Nodes;OpenJ9(内部代号 J9)用共享类缓存 + 自己的 JIT,启动和内存占用通常更省,峰值吞吐视场景而定;GraalVM 的 JIT 用 Java 写的 Graal 编译器,native-image 则走 AOT 完全绕开运行时编译。跨 JVM 对比必须按实现和版本分别核对,"JIT 都差不多"是错的。
一句话收束:JIT 优化的是"已经热了的纯计算代码",它不改变算法复杂度,也不消除 IO 与锁的代价。
9. 自测
- 分层编译默认走的是哪条级别路径?为什么不是
0 → 1 → 4? - 日志里的
%表示什么?它对应哪个计数器? - 一个方法被调用了几万次却没被编译,最可能的原因是什么?
MaxInlineSize和FreqInlineSize分别作用于什么方法?uncommon trap触发后,JVM 从哪一层继续执行?- 容器里编译阈值和物理机不同,先查哪个命令确认实际值?