2619 字
约 8 分钟
13
S05-X11 · JIT 编译:热点探测、内联与方法去优化
2024-12-31
2026-11-08

同一段代码,在 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. 自测

  1. 分层编译的五个层级里,哪一层开始采集完整 profile,为什么 C2 需要它?
  2. 方法调用计数器与回边计数器分别解决什么问题,OSR 为什么存在?
  3. 内联的收益为什么远大于"省一次调用"?
  4. 单态、双态、巨态调用点在 C2 里的处理方式有什么不同,类型守卫的代价是什么?
  5. 去优化会在什么条件下发生,它如何解释"跑一段时间后吞吐下降、重启恢复"?
  6. 为什么直接用 System.nanoTime() 测热点方法会得到错误结论?
S05-X11 · JIT 编译:热点探测、内联与方法去优化
https://www.clxhxhhr.top/posts/5762/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。