2.3 微基准四大陷阱
上一节:2.2 JIT 的三大优化与去优化 | 下一节:2.4 预热、稳态与冷启动 配套代码:02-jvm-performance-basics/03-microbenchmark-traps
一句话结论
你写的基准测试,最大的对手不是机器噪声,而是优化器。 它会把你的计算整个删掉、提前算好、或者把你的分支预测喂得太好。四个陷阱里,任何一个都会让你的数字变成废纸。
一、陷阱一:死代码消除(DCE)—— 算了但没人用,那就不算
现象:测出 0.2 ns/op 这种物理上不可能的数字。
原因:计算的结果没有被使用,JIT 判断「这个计算没有副作用」,于是把整个循环删掉了。你测的是一个空循环。
// ❌ 结果没人用 → 整个计算被删除
repeat(100_000) {
sumArray(data) // 返回值丢掉了
}
// ✅ 结果被消费 → 计算保留
var sink = 0L
repeat(100_000) {
sink += sumArray(data)
}
println(sink) // 确保 sink 真的被使用,否则它也可能被优化掉
为什么难发现:代码看起来「明明在算」,而且跑得飞快——你会以为是好事。
判断方法:把数据规模改成 10 倍,如果耗时完全不变,说明计算被删了。
二、陷阱二:常量折叠 —— 输入是已知的,那就提前算好
现象:数字看起来合理,但与输入规模无关。
原因:输入在编译期就能确定(比如字面量、const),JIT 直接在编译时把结果算出来。
// ❌ 输入是编译期常量 → 结果被提前算好
val data = IntArray(4096) { it } // 规律数据,JIT 能推导
repeat(100_000) { sink += sumArray(data) }
// ✅ 输入在运行时才确定
val rnd = Random(System.nanoTime()) // 真正的运行时随机
val data = IntArray(4096) { rnd.nextInt() }
注意:即使数据是「运行时生成的」,如果生成方式是 { it } 或 { it * 2 } 这类规律模式,JIT 仍可能利用这个规律(比如识别出等差数列直接套公式)。
正确做法:用 Random 生成,或用从文件/网络读取的真实数据。
三、陷阱三:分支预测喂得太好 —— 真实数据没这么乖
现象:基准里的代码比线上快很多,尤其是含条件分支的代码。
原因:基准里用同一种输入反复测试,CPU 的分支预测器能达到接近 100% 的命中率。而真实流量是混合的,分支预测会频繁失败(每次失败代价约 10–20 个时钟周期)。
// ❌ 所有输入都走同一个分支
val data = IntArray(4096) { 1 } // 全是同一个值
if (v > 0) { ... } else { ... } // 分支预测 100% 命中
// ✅ 混合分布,接近真实流量
val data = IntArray(4096) { rnd.nextInt(-100, 100) } // 正负混合
经典案例(StackOverflow 上最有名的性能问题之一):对数组求和时,排序后的数组比未排序的快一倍——因为排序后分支预测几乎不失败。这不是段子,是真实测量结果。
判断方法:用「正负混合」「大小混合」「类型混合」的数据重跑,如果性能明显下降,说明原来的数字是被喂出来的。
四、陷阱四:未消费结果(逃逸分析把分配也消掉了)
现象:明明创建了对象,但分配速率是 0,gc.alloc.rate 显示没有任何 GC 压力。
原因:如果对象不逃逸(只在方法内部使用),逃逸分析会把它栈上分配甚至标量替换,堆上根本没有分配。
这为什么是陷阱:你要测的可能正是「分配对象 + 序列化」的成本,结果 JIT 把你的成本大头抹掉了。
// ❌ 对象不逃逸 → 分配被消除,测不到真实的分配/GC 成本
repeat(1_000_000) {
val dto = OrderDto(id = it, name = "x") // 立即被丢弃
}
// ✅ 让结果逃逸出去 → 分配真实发生
val results = ArrayList<OrderDto>(1_000_000)
repeat(1_000_000) {
results.add(OrderDto(id = it, name = "x"))
}
这也是 Blackhole 存在的原因:JMH 的 Blackhole.consume() 会以一种 JIT 无法优化掉的方式「使用」你的结果。
五、四个陷阱的对照表
| 陷阱 | 现象 | 根本原因 | 修法 |
|---|---|---|---|
| 死代码消除 | 数字低到物理不可能 | 结果没人用 | 消费结果(或 Blackhole) |
| 常量折叠 | 与输入规模无关 | 输入编译期已知 | 运行时随机输入 |
| 分支预测过优 | 比线上快很多 | 输入太单一 | 混合分布数据 |
| 未消费结果 | 分配速率为 0 | 逃逸分析消除分配 | 让结果逃逸(或 Blackhole) |
六、一个更隐蔽的问题:你测的是「哪个形态」
四个陷阱都躲过了,还有一个大坑等着你:你测的是解释执行、C1 还是 C2?
同一份代码,不同预热情况下的结果:
完全没预热 : 1180 ns/op
预热 100 次 : 612 ns/op
预热 10 万次 : 232 ns/op
相差 5 倍。 所以任何基准都必须回答两个问题:
- 我消费了结果吗?(防陷阱一、四)
- 我预热了吗?预热多久?(防形态差异)
第 4 节会讲怎么用数据判断「预热够了」。
七、正确姿势:交给 JMH
上面所有陷阱,JMH 都有标准解法:
| 陷阱 | JMH 的对策 |
|---|---|
| 死代码消除 | Blackhole.consume():以 JIT 无法消除的方式使用结果 |
| 常量折叠 | @State + @Setup:把输入变成运行时字段,JIT 无法假定其值 |
| 分支预测过优 | 由你提供真实分布的数据(JMH 无法替你解决,但会通过多次迭代暴露不稳定) |
| 未消费结果 | 同上,Blackhole 强制结果被使用 |
| 形态问题 | @Warmup / @Measurement / @Fork:预热与测量分离,每轮独立 JVM |
这就是为什么不要自己写基准。 第 8 节会详细讲 JMH 的用法。
但是:即使有了 JMH,你仍然要知道这四条规则在防什么——否则你会写出「用了 JMH 但依然被优化掉」的基准(比如把
Blackhole用错位置、输入放在@Setup(Level.Invocation)里导致开销被算进去)。
八、本节小结
- 四大陷阱:死代码消除、常量折叠、分支预测过优、未消费结果。
- 它们的共同点是:让数字变得好看,却不报错——这是最危险的一类错误。
- 判断方法:改变输入规模/分布,看数字是否随之变化。不变化的数字一定是假的。
- 除了四个陷阱,还有「测的是哪个执行形态」的问题——预热也是基准的一部分。
- JMH 提供了标准防护(
Blackhole、@State、@Fork),但你必须理解每条规则在防什么。
九、自测
- 你写了一个基准,测出某方法耗时 0.3 ns/op。列出三个可能的原因,以及各自的验证方法。
- 为什么「把输入数组排序后,求和循环变快了近一倍」?这个现象对基准设计有什么启发?
- 你要测「把一个对象序列化成 JSON 的成本」。基准里你在循环内创建对象并立即序列化、丢弃结果。这个基准可能有什么问题?怎么修?
- ① 死代码消除:结果没被消费,整个计算被删除。验证:把输入规模扩大 10 倍,看耗时是否变化——不变则说明被删了;改法是用
Blackhole消费结果。② 常量折叠:输入在编译期可确定,结果被提前算好。验证:把输入改成运行时随机值,看数字是否变化。③ 测量的是空操作或极短操作:例如被测方法本身被内联成了一条指令。验证:用-XX:+PrintCompilation/-XX:+PrintInlining看编译结果,或用-XX:CompileCommand=dontinline对比。另外还有一种可能:计时循环的开销计算错误(比如把「每百万次的总耗时」当成了「单次耗时」)。 - 因为分支预测。未排序时数据正负交替、方向随机,分支预测器频繁失败,每次失败要清空流水线(约 10–20 个周期);排序后所有负数在前、正数在后,分支模式变成「一直走这条路」,预测几乎 100% 命中。对基准设计的启发:① 测试含分支的代码时,输入分布必须接近真实(混合类型、混合正负、混合大小);② 用同一种输入反复跑是基准的固有偏差——这正是 JMH 用
@State提供多组输入 + 多轮迭代的原因;③ 反过来,如果线上数据本身就是有序或高度规律的,那么「喂得好的基准」反而更接近真实。 - 问题有两层:① 对象不逃逸,逃逸分析可能连对象带序列化一起优化掉(尤其序列化结果被丢弃时),测出的成本远低于真实;② 即使没被完全优化掉,也测不出真实的 GC 压力——而线上正是 GC 压力的一部分。修法:把序列化结果累加到一个会被真正使用的结构里(或
Blackhole.consume),保证结果逃逸;同时把「对象创建」和「序列化」分开测,分别观察gc.alloc.rate;并且用真实结构的对象(字段多、嵌套深)而不是两个字段的 DTO。