2.2 JIT 的三大优化与去优化
上一节:2.1 四种执行形态 | 下一节:2.3 微基准四大陷阱 配套代码:02-jvm-performance-basics/02-jit-optimizations-and-deopt
一句话结论
C2 编译器会基于「运行时观察到的现象」做推测性优化——比如「这个调用点永远只有一种类型」。一旦推测被打破,它会撤销优化、退回慢速路径。 这就是「去优化」,也是性能无端抖动的常见来源。
一、先看三大优化(用厨房类比)
接着上一节的餐厅比喻。老师傅(C2)在观察了足够多次之后,会做三件事:
优化一:方法内联(Inlining)—— 把常用步骤直接展开
原来:每次要做「去冰箱拿鸡蛋」这个动作,都要走过去、打开冰箱、拿蛋、关门。厨师会把它写成一个标准动作反复执行。
老师傅的做法:既然每次都这样,那我干脆把这个动作直接展开到主流程里,不用来回跑(省掉「方法调用的开销」),而且展开之后还能看到更大的优化空间(比如发现「拿蛋之后马上要打蛋」,可以合并)。
技术含义:把被调用方法的字节码直接嵌入调用方,消除调用开销,并为后续优化(常量传播、死代码消除)创造条件。
这也是为什么火焰图会「丢帧」:内联之后,中间那个方法在调用栈上就不存在了。
优化二:逃逸分析(Escape Analysis)—— 只在内部用的东西,不用真造一个
原来:厨房要盛一份配菜,得专门洗一个盘子、装盘、用完再洗。
老师傅发现:这个盘子只在厨房内部用,端出去之前就被吃掉了……那我为什么还要真的拿一个盘子?直接在案板上处理完就行。
同理,还有两个衍生优化:
- 锁消除:如果一把锁只在内部用、不会被别的厨师碰到,那就没必要真锁。
- 标量替换:如果一个对象只在内部用,可以把它拆成几个局部变量,连对象都不分配。
技术含义:如果 JIT 能证明一个对象不会「逃逸」出当前方法/线程,就可以把它分配在栈上(甚至拆成标量),从而减少堆分配与 GC 压力。
这解释了一个反直觉的现象:有些看起来「创建了很多对象」的代码,实测非常快——因为那些对象根本没被真正创建。
优化三:基于 profile 的推测优化 —— 按「最常发生的情况」设计流程
老师傅观察到:来这里的客人99% 都点同一道招牌菜。于是他干脆把这道菜的料都提前备好放在手边。
风险:如果有一天来了个点别的菜的客人,他准备的料全用不上,还得临时找——比原来更慢。
技术含义:C2 会记录调用点的类型分布。如果它发现「这个接口调用永远指向同一个实现类」,就会把虚调用(间接跳转)优化成直接调用(甚至内联掉)。这叫 monomorphic(单态) 调用点。
如果后来出现了第二种类型,就叫 bimorphic(双态);出现多种类型就是 megamorphic(巨态),此时 JIT 会放弃优化,退回慢速的虚调用。
二、去优化:优化被撤销的瞬间
什么是去优化
当 C2 的推测被打破时,它必须撤销已经生成的机器码,退回到解释执行或较低优化的版本,然后带着新信息重新编译。
① C2 假设「这个调用点只有 A 类型」→ 生成直接调用 A 的机器码(很快)
② 某次请求带来了 B 类型
③ 假设被打破 → 当前机器码作废(made not entrant)
④ 退回解释执行 / 重新走 C1、C2 编译
⑤ 期间性能明显下降(可能出现毫秒级甚至更长的抖动)
触发去优化的常见原因:
| 原因 | 例子 |
|---|---|
| 类型不稳定(最常见) | 一个 List<T> 里混了多种实现类;一个接口有多个实现,调用点交替使用 |
| 罕见的陷阱(uncommon trap) | 空检查失败、除零、数组越界等「JIT 认为不会发生」的情况 |
| 类加载改变了继承关系 | 运行时加载了一个新类,使原假设失效 |
为什么这对后端特别重要
后端流量里,「99% 走 A 实现,1% 走 B 实现」是很常见的——比如:
- 一个支付接口有「正常支付」和「退款」两种实现,绝大多数是前者;
- 一个缓存有「命中」和「未命中」两条路径;
- 序列化层有「普通对象」和「集合类型」多种分支。
问题在于:那 1% 的 B 路径不仅自己慢,还可能污染整个调用点,把 99% 的 A 路径也拖慢——因为调用点从单态变成了双态/megamorphic。
一个真实的性能现象
实验:同一个方法,调用点的类型数量不同
单态(永远只有 1 种实现): 12 ns/op
双态(2 种实现交替): 18 ns/op (+50%)
巨态(5 种以上实现): 45 ns/op (+275%)
数字来自你机器上的实测(见配套代码)。趋势是一致的:类型越多,越慢,而且可能伴随去优化带来的抖动。
三、这对写代码的三个指导
❶ 避免「为了通用而引入多种实现」
// ❌ 一个调用点,运行时混入 5 种不同实现
interface Serializer { fun serialize(o: Any): String }
val result = serializers[typeOf(o)]!!.serialize(o)
// ✅ 按类型分支,让每条路径各自保持单态
when (o) {
is Order -> orderSerializer.serialize(o) // 这条路径始终只有 OrderSerializer
is User -> userSerializer.serialize(o) // 这条路径始终只有 UserSerializer
}
注意:上面的 when 写法看起来「不优雅」,但它让每个调用点保持单态。这就是「可读性 vs 性能」的一个真实权衡点。
❷ 注意集合里的类型混装
// ❌ 同一个调用点处理多种类型
val items: List<Any> = listOf(order, user, product)
items.forEach { render(it) } // render 的调用点是 megamorphic
// ✅ 分开处理
val orders = list.filterIsInstance<Order>()
val users = list.filterIsInstance<User>()
orders.forEach { renderOrder(it) }
users.forEach { renderUser(it) }
❸ 但不要过早优化
上面两条都很「丑」。只有当火焰图或基准证明这里是瓶颈时才做。多数业务代码的类型不稳定并不会成为瓶颈——因为业务逻辑本身的开销(IO、序列化、数据库)远大于虚调用。
原则:先去测量(第 6 章),确认「虚调用/去优化」真的出现在火焰图上,再考虑改代码。
四、怎么诊断去优化
# 打印编译与去优化事件
java -XX:+PrintCompilation -jar app.jar | grep -i "not entrant"
# 更详细:打印内联决策(需要诊断选项)
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining -jar app.jar
# 专门排查某个方法(禁止内联它,看它是否真是瓶颈)
java -XX:CompileCommand=dontinline,com/example/Service.process -jar app.jar
# 打印去优化的原因
java -XX:+UnlockDiagnosticVMOptions -XX:+TraceDeoptimization -jar app.jar
读法:
made not entrant:编译结果作废(可能因为去优化,也可能因为正常替换)。made zombie:代码彻底不可用,可以回收。PrintInlining输出里的too big、hot method too big:说明方法太大,JIT 不愿意内联它——这是「大方法」的一个隐藏成本。
一个实用技巧:
-XX:CompileCommand=dontinline,<方法>是一个很好的验证工具。如果禁止内联后性能大幅下降,说明这个方法之所以快是因为它被内联了——那么把它的逻辑拆小、保持可内联性就很重要。
五、本节小结
- C2 有三大优化:方法内联(消除调用开销并为后续优化开路)、逃逸分析(栈上分配、锁消除、标量替换)、基于 profile 的推测优化(单态调用点变直接调用)。
- 去优化是推测被打破时的回退,代价是性能抖动与重新编译。
- 类型不稳定的调用点(bimorphic / megamorphic)是最常见的性能陷阱,而且会「1% 的慢路径拖累 99% 的快路径」。
- 内联会让火焰图丢帧——这是读火焰图时必须知道的事。
- 用
-XX:+PrintCompilation、-XX:+PrintInlining、-XX:CompileCommand诊断。 - 先测量再优化:类型不稳定多数时候不是瓶颈,别为此把代码写丑。
六、自测
- 一个接口有 2 个实现类,90% 的请求走 A、10% 走 B。为什么实测性能比「只用 A」时差很多?这个差距和调用比例是线性的吗?
- 「逃逸分析可以减少对象分配」——那么「一个方法里创建 10 万个临时对象」一定很慢吗?为什么?
- 你在火焰图上找不到你确定被调用的那个方法。最可能的原因是什么?这会影响你对火焰图的解读吗?
- 因为调用点从 monomorphic(单态)变成了 bimorphic(双态),JIT 无法再做「直接调用 + 内联」的优化,只能退回虚调用(查虚表、无法内联后续优化)。差距不是线性的:即使 B 只占 1%,调用点也会变成双态,性能损失接近于「整体退化」而非「按比例退化」——这正是这个陷阱可怕的地方(1% 的路径拖累 99% 的路径)。进一步地,如果出现更多类型变成 megamorphic,退化会更严重。
- 不一定。如果这些对象都不逃逸(只在方法内部使用、不被返回、不被存入字段、不被传给未知方法),JIT 可以通过栈上分配 / 标量替换把它们消除掉,堆分配可能接近零。所以「创建了很多对象」和「产生了很大 GC 压力」不是一回事。验证方法:看分配火焰图或
gc.alloc.rate——如果分配速率很低,说明确实被消除了。 - 最可能的原因是方法被内联了——内联后,被调用方法的栈帧不再单独出现,它的代码被合并进了调用方。这对火焰图解读有影响:① 看不到某个方法不代表它没被调用;② 火焰图的「宽度」代表采样数(时间占比),但帧的层次不代表真实的调用层次;③ 想确认某个方法是否被内联,可以用
-XX:+PrintInlining或-XX:CompileCommand=dontinline做对比实验。