同一段代码,在 JMH 里跑出每操作 30ns,搬进生产后却变成 300ns;或者反过来,一个服务跑了两小时突然吞吐掉一档,重启立刻恢复。这两种现象往往指向同一套机制:JIT 的编译决策与去优化。不理解热点探测和类型守卫,就只能把"性能抖动"归因于"玄学"。
1. 解释器与编译器的分工
JVM 里同时存在解释器、C1(client 编译器)、C2(server 编译器)。默认分层编译让它们协作:
| 层 | 执行者 | 采集 profile | 编译开销 | 典型用途 |
|---|---|---|---|---|
| 0 | 解释器 | 采集(计数、类型) | 无 | 冷代码、启动初期 |
| 1 | C1(无 profile) | 否 | 低 | 极简方法、bootstrapping |
| 2 | C1 + 有限 profile | 是 | 低 | 长驻但热度不高 |
| 3 | C1 + 完整 profile | 是 | 中 | 为 C2 预热、过渡层 |
| 4 | C2 | 否(用已有 profile) | 高 | 真正热点 |
分层编译的价值在于先用便宜编译顶住,再拿 profile 换峰值。-XX:+PrintCompilation 里每个方法后面那个数字就是当前层:
$ java -XX:+PrintCompilation -cp /tmp/out Bench
典型输出形态(列宽固定):
145 42 3 com.example.Hot::work (24 bytes)
168 43 4 com.example.Hot::work (24 bytes)
210 44 3 com.example.Hot::loop (58 bytes)
212 44 4 com.example.Hot::loop (58 bytes) made not entrant
读法:第 1 列时间戳(ms,相对启动)、第 2 列编译 ID、第 3 列层级、第 4 列 % 表示 OSR(回边触发)、方法名与字节码大小。made not entrant 是去优化或替换的标记,看到它就说明之前的编译版本被废弃了。
2. 热点探测:两个计数器
| 计数器 | 增长条件 | 默认阈值参数 | 触发动作 |
|---|---|---|---|
方法调用计数器 InvocationCounter |
每次进入方法 | -XX:CompileThreshold(非分层 default 10000) |
请求该层编译 |
回边计数器 BackEdgeCounter |
循环跳回 | 由 -XX:OnStackReplacePercentage 推导 |
触发 OSR(栈上替换) |
分层编译下这两个阈值由层级内部决定,手动设 -XX:CompileThreshold 在 JDK 17 上基本被分层逻辑接管,想复现老式行为要关分层。
两个计数器有一个共同点:有衰减。计数不是单调累加的,一段时间不热就会衰减,所以"启动时很热、后来沉寂"的方法会被降级对待。OSR 的存在是为了解决"长循环在方法里跑不完、等不到方法返回"的问题——它能在循环回边处把解释执行的栈帧直接替换成编译版本。
$ java -XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation -XX:+LogCompilation -cp /tmp/out Bench
# 生成 hotspot_pid<pid>.log,用 JITWatch 或 -XX:+PrintIdeal 查看
3. 内联:JIT 最大的一笔杠杆
内联不是"省一次方法调用"这么简单。它把被调方法的字节码展开进调用者后,C2 才可能做常量传播、死代码消除、逃逸分析、循环展开——没有内联,这些优化几乎全部失效。
触发条件(C2 侧):
| 条件 | 相关参数 | 默认量级 | 说明 |
|---|---|---|---|
| 方法体字节码大小 | -XX:MaxInlineSize |
35 | 冷调用点的硬上限 |
| 热点调用点 | -XX:FreqInlineSize |
325 | 热调用点可放宽 |
| 调用点数量 | 内部启发式 | —— | 同一方法被太多地方调用会降级 |
| 类型 profile | -XX:TypeProfileLevel 等 |
单态优先 | 单态/双态可内联,巨态不内联 |
| 内联深度 | -XX:MaxInlineLevel |
9 | 防止无限嵌套 |
$ java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining -cp /tmp/out Bench
JDK 17 输出截取,形态如下:
com.example.Hot::dispatch (18 bytes)
@ 4 com.example.Fast::run (12 bytes) inline (hot)
@ 9 com.example.Slow::run (512 bytes) failed to inline: callee is too large
@ 14 com.example.Iface::call (8 bytes) virtual call, not monomorphic (2 types)
三行分别对应三种决策:inline (hot) 成功;callee is too large 因体积放弃;not monomorphic 因类型多样放弃。这份日志是排查"为什么这个热方法没被内联"的唯一可靠入口。
4. 虚方法内联与类型守卫
虚调用理论上要到运行期才定目标,但 profile 记录了"这个调用点历史上只出现过 Fast 类型",C2 就敢假设它永远是 Fast,插入一个类型守卫后内联:
| profile 状态 | 观测到的类型数 | 决策 |
|---|---|---|
| monomorphic | 1 | 直接内联 + 守卫 |
| bimorphic | 2 | 两个分支都内联 + 类型判断 |
| megamorphic | ≥3(启发式阈值) | 放弃内联,走 vtable 查表 |
守卫的代价是一次类型比较,收益是整条链上的优化。但代价的另一面是假设可能失效:只要某次调用传入了第三种实现,守卫失败,JVM 必须把已编译的代码作废——这就是去优化。
5. 去优化:性能抖动的机械解释
| 触发原因 | 现场特征 | 后果 |
|---|---|---|
| 类型 profile 变化 | not monomorphic 后守卫失败 |
退回解释或重编译,吞吐瞬降 |
类尚未加载(unloaded) |
uncommon trap 指向未解析类 | 先走慢路径,类加载后再编译 |
| 守卫假设失效 | made not entrant |
旧编译产物废弃 |
null 检查 / 除零等隐式假设 |
隐式异常路径 | 走 uncommon trap |
| 依赖的类被卸载 | 少见,与类加载器相关 | 级联去优化 |
$ java -XX:+UnlockDiagnosticVMOptions -XX:+TraceDeoptimization -cp /tmp/out Bench | head -40
典型输出形态会给出方法、字节码位置与原因(reason=constraint、reason=unloaded 等)。工程上的判断方法很朴素:如果一次发布或一次流量变化后抖动开始,且抖动前后调用方的实现类集合变了,先怀疑去优化,而不是先怀疑 GC。
$ java -XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining -cp /tmp/out Bench | grep -E 'not entrant|not monomorphic'
6. 什么代码会阻止内联
| 情况 | 原因 | 规避 |
|---|---|---|
| 方法体过大 | 超过 FreqInlineSize |
拆分、提取热点路径 |
| 异常处理器过多 | try/catch 使控制流复杂、体积膨胀 |
把异常处理挪到外层 |
| 深递归 | 无法展开 | 改迭代或手动分层 |
巨型 switch |
跳转表膨胀 | 拆成策略类(但要考虑类型数) |
| 调用点巨态 | profile 无法定目标 | 用接口 + 少量实现 |
synchronized 方法 |
旧版本膨胀锁逻辑(JDK 17 已有优化) | 缩小临界区 |
调试期的两个开关:
# 对指定方法强制内联 / 禁止内联
$ java -XX:CompileCommand=inline,com.example.Hot::work -cp /tmp/out Bench
$ java -XX:CompileCommand=dontinline,com.example.Slow::run -cp /tmp/out Bench
$ java -XX:CompileCommand=print,com.example.Hot::* -cp /tmp/out Bench
$ java -XX:CompileCommand=exclude,com.example.Legacy::* -cp /tmp/out Bench
@ForceInline / @DontInline 是 jdk.internal.vm.annotation 里的注解,属于 JDK 内部 API;JDK 17 上业务代码直接用需要 --add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED 才能在编译期通过。诊断场景优先用 CompileCommand,不必碰内部 API。
7. 常见坑
| 坑 | 现象 | 真实原因 | 正确做法 |
|---|---|---|---|
| 拿 microbench 结果当生产表现 | 单线程纳秒级,线上差十倍 | 单次运行不采集 profile、无 GC 压力、无争用 | 用 JMH,@Fork、@Warmup 齐全 |
裸写 System.nanoTime() 计时 |
结果接近 0 | 死代码消除、常量折叠把被测代码删了 | JMH 的 Blackhole |
| 只调一次方法就测性能 | 全在解释执行 | 未达热点阈值 | 预热到 made not entrant 稳定 |
-Xint 当"排除 JIT 干扰" |
慢几十倍,结论不可用 | 关了编译器等于测解释器 | 用 -XX:-TieredCompilation 或分层对照 |
把 made not entrant 当故障 |
日志里一堆 | 正常替换/去优化 | 看是否伴随吞吐下降再判断 |
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 1)
@Fork(2)
public class HotBench {
@Benchmark
public int work() { return new Hot().dispatch(1); }
}
8. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 日志里方法一直在 3 层与 4 层之间跳 | 去优化后重编译、profile 不稳 | 查类型多样性;必要时收窄实现类 |
| 热点方法始终停在层 1 | 被判定为不适合 C2 | 看 PrintInlining 与方法体积;拆方法 |
| 吞吐每几小时掉一档后恢复 | 去优化 + 重新预热 | TraceDeoptimization 抓原因 |
| 编译线程占用 CPU 明显 | 大量类/方法被反复编译 | -XX:CICompilerCount 与代码缓存;查动态生成类 |
CodeCache is full |
代码缓存被填满,编译停止 | -XX:ReservedCodeCacheSize;排查动态生成类 |
最后一条容易被忽略:代码缓存满了以后 JVM 会关掉 JIT,服务退化成解释执行,表现为"越来越慢但不报错"。JDK 17 默认分层下代码缓存会分段管理,仍要看 -XX:+PrintCodeCache 的占用:
$ java -XX:+PrintCodeCache -cp /tmp/out Bench
9. 边界
| 场景 | JIT 细节是否重要 | 理由 |
|---|---|---|
| 短生命周期批处理(秒级) | 不重要 | 还没预热完就结束,AOT/CDS 更关键 |
| 长驻高吞吐服务 | 重要 | 峰值由 C2 与内联质量决定 |
| 延迟敏感的 p99 | 重要 | 去优化瞬间的抖动会打到尾延迟 |
| 业务逻辑正确性 | 不相关 | 编译决策不改语义 |
| 排查"跑久了变慢" | 重要 | 代码缓存与去优化是常见根因 |
10. 自测
- 分层编译的五个层级里,哪一层开始采集完整 profile,为什么 C2 需要它?
- 方法调用计数器与回边计数器分别解决什么问题,OSR 为什么存在?
- 内联的收益为什么远大于"省一次调用"?
- 单态、双态、巨态调用点在 C2 里的处理方式有什么不同,类型守卫的代价是什么?
- 去优化会在什么条件下发生,它如何解释"跑一段时间后吞吐下降、重启恢复"?
- 为什么直接用
System.nanoTime()测热点方法会得到错误结论?