文档目录

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 倍。 所以任何基准都必须回答两个问题:

  1. 我消费了结果吗?(防陷阱一、四)
  2. 我预热了吗?预热多久?(防形态差异)

第 4 节会讲怎么用数据判断「预热够了」。


七、正确姿势:交给 JMH

上面所有陷阱,JMH 都有标准解法:

陷阱 JMH 的对策
死代码消除 Blackhole.consume():以 JIT 无法消除的方式使用结果
常量折叠 @State + @Setup:把输入变成运行时字段,JIT 无法假定其值
分支预测过优 由你提供真实分布的数据(JMH 无法替你解决,但会通过多次迭代暴露不稳定)
未消费结果 同上,Blackhole 强制结果被使用
形态问题 @Warmup / @Measurement / @Fork:预热与测量分离,每轮独立 JVM

这就是为什么不要自己写基准。 第 8 节会详细讲 JMH 的用法。

但是:即使有了 JMH,你仍然要知道这四条规则在防什么——否则你会写出「用了 JMH 但依然被优化掉」的基准(比如把 Blackhole 用错位置、输入放在 @Setup(Level.Invocation) 里导致开销被算进去)。


八、本节小结

  1. 四大陷阱:死代码消除、常量折叠、分支预测过优、未消费结果。
  2. 它们的共同点是:让数字变得好看,却不报错——这是最危险的一类错误。
  3. 判断方法:改变输入规模/分布,看数字是否随之变化。不变化的数字一定是假的。
  4. 除了四个陷阱,还有「测的是哪个执行形态」的问题——预热也是基准的一部分。
  5. JMH 提供了标准防护(Blackhole、@State、@Fork),但你必须理解每条规则在防什么。

九、自测

  1. 你写了一个基准,测出某方法耗时 0.3 ns/op。列出三个可能的原因,以及各自的验证方法。
  2. 为什么「把输入数组排序后,求和循环变快了近一倍」?这个现象对基准设计有什么启发?
  3. 你要测「把一个对象序列化成 JSON 的成本」。基准里你在循环内创建对象并立即序列化、丢弃结果。这个基准可能有什么问题?怎么修?
  1. ① 死代码消除:结果没被消费,整个计算被删除。验证:把输入规模扩大 10 倍,看耗时是否变化——不变则说明被删了;改法是用 Blackhole 消费结果。② 常量折叠:输入在编译期可确定,结果被提前算好。验证:把输入改成运行时随机值,看数字是否变化。③ 测量的是空操作或极短操作:例如被测方法本身被内联成了一条指令。验证:用 -XX:+PrintCompilation / -XX:+PrintInlining 看编译结果,或用 -XX:CompileCommand=dontinline 对比。另外还有一种可能:计时循环的开销计算错误(比如把「每百万次的总耗时」当成了「单次耗时」)。
  2. 因为分支预测。未排序时数据正负交替、方向随机,分支预测器频繁失败,每次失败要清空流水线(约 10–20 个周期);排序后所有负数在前、正数在后,分支模式变成「一直走这条路」,预测几乎 100% 命中。对基准设计的启发:① 测试含分支的代码时,输入分布必须接近真实(混合类型、混合正负、混合大小);② 用同一种输入反复跑是基准的固有偏差——这正是 JMH 用 @State 提供多组输入 + 多轮迭代的原因;③ 反过来,如果线上数据本身就是有序或高度规律的,那么「喂得好的基准」反而更接近真实。
  3. 问题有两层:① 对象不逃逸,逃逸分析可能连对象带序列化一起优化掉(尤其序列化结果被丢弃时),测出的成本远低于真实;② 即使没被完全优化掉,也测不出真实的 GC 压力——而线上正是 GC 压力的一部分。修法:把序列化结果累加到一个会被真正使用的结构里(或 Blackhole.consume),保证结果逃逸;同时把「对象创建」和「序列化」分开测,分别观察 gc.alloc.rate;并且用真实结构的对象(字段多、嵌套深)而不是两个字段的 DTO。