承接的问题
2.1 把 JMH 的框架讲清楚了,但框架正确不代表测量有效。基准里最常见的失效不是「参数写错」,而是「你以为在测 A,JIT 把 A 删了,你测到了 B」。这类错误有个共同特征:结果好得不真实,而且往往刚好支持写基准的人想证明的结论。
这一节用五个陷阱把这件事拆开:死代码消除、常量折叠、循环展开、预热不足、伪共享未隔离。每个陷阱都给错误写法、正确写法和本机输出;凡是本机不能稳定复现的,就明说「按 JIT 行为推导」,并给出验证命令,不编造数字。
陷阱一:死代码消除
这是最致命也最容易复现的一个。被测方法返回 void,计算结果没人用,JIT 有权把整段计算删掉。
// 错误:循环算完就扔,sum 没有被任何地方使用
@Benchmark
public void sumDropped(Data d) {
int sum = 0;
for (int v : d.values) {
sum += v;
}
}
// 正确:把结果交给 Blackhole
@Benchmark
public void sumBlackholed(Data d, Blackhole bh) {
int sum = 0;
for (int v : d.values) {
sum += v;
}
bh.consume(sum);
}
Data 是 Scope.Thread 状态,values 是 4096 个 int。本机两次独立运行(参数 -wi 2 -i 3 -w 1s -r 1s -f 1,load average 105 与 262):
# 第一次
BlackholeBench.sumBlackholed thrpt 3 742461.401 ± 118226.941 ops/s
BlackholeBench.sumDropped thrpt 3 1311185160.469 ± 1493707963.588 ops/s
# 第二次
BlackholeBench.sumBlackholed thrpt 3 734969.854 ± 116471.124 ops/s
BlackholeBench.sumDropped thrpt 3 1374588235.854 ± 148093380.805 ops/s
两边差 1770 倍与 1870 倍,带 consume 的那一侧两次差 1%,这个现象在本机稳定复现。判定方法也很直接:13.1 亿次/秒 × 4096 次加法 ≈ 每秒 5.4 万亿次加法,单核做不到,所以那个数字必然是「什么都没算」。
注意 sumDropped 的误差项高达 ±14.9 亿,比均值还大——因为每次迭代里剩下的代码量已经少到被分配、调度、计时开销主导,测的根本不是循环。误差项在这里恰好是一个信号:误差比均值大,先去怀疑测量对象是否还存在。
正确写法不只有 bh.consume 一种,返回值也可以:
@Benchmark
public int sumReturned(Data d) {
int sum = 0;
for (int v : d.values) {
sum += v;
}
return sum;
}
JMH 对非 void 返回值的处理就是替你消费。void + Blackhole 和「返回值」在语义上等价,差别只在于你能不能在方法里继续用这个值(比如累加后一次性消费)。
陷阱二:常量折叠
常量折叠有两层,很多人把两层混为一谈。
第一层是 javac。写一个小类,编译后直接看字节码:
public class ConstFold {
static final int A = 3 * 7;
static final String S = "li" + "brary";
public static int folded() {
return A * 2;
}
public static int jitOnly(int x) {
return x * 0 + 42;
}
public static String concat() {
return S + "d";
}
}
javap -c -p 的本机输出:
public static int folded();
Code:
0: bipush 42
2: ireturn
public static int jitOnly(int);
Code:
0: iload_0
1: iconst_0
2: imul
3: bipush 42
5: iadd
6: ireturn
public static java.lang.String concat();
Code:
0: ldc #9 // String libraryd
2: areturn
3 * 7 * 2 被 javac 直接折成 bipush 42,字符串拼接被折成常量池里的一条 libraryd。只要操作数都是编译期常量,字节码里就只剩结果,这类「基准」测到的是 JMH 的调用开销,不是你写的表达式。
x * 0 + 42 是第二层:javac 不折(imul 还在),JIT 会折。所以「编译器常量折叠」这句话在 Java 里必须区分是谁折的。
第二层怎么验证?标准做法是 -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly。本机 JDK 没有带 hsdis,实测输出是:
OpenJDK 64-Bit Server VM warning: PrintAssembly is enabled; turning on DebugNonSafepoints to gain additional output
[0.025s][warning][os] Loading hsdis library failed
本机看不到汇编,所以这一层只给结论与验证方法,不给「本机输出」:JIT 会把 x * 0 规约成 0、把 0 + 42 规约成常数,整个方法体退化成 return 42。要自己确认,把 hsdis 放到 $JAVA_HOME/lib/libhsdis.dylib 后重跑上面的命令,看 jitOnly 的编译结果里是否还有 imul。
工程上的判断规则很简单:如果一个基准的参数或返回值能用计算器算出来,它就不是基准。基准的输入必须来自状态对象、参数化配置或运行时数据,让 JIT 无法在编译期知道答案。
陷阱三:循环展开
循环展开不是 bug,但它会让「用迭代次数换算单次成本」的推理失效。看一对只差一个上界来源的基准:
// 上界是编译期常量:JIT 知道跳多少次
@Benchmark
public int loopFolded() {
int sum = 0;
for (int i = 0; i < 1024; i++) {
sum += i;
}
return sum;
}
// 上界来自实例字段:JIT 不能假设它永远是 1024
@State(Scope.Thread)
public static class Bound {
public int bound = 1024;
}
@Benchmark
public int loopNotFolded(Bound b) {
int sum = 0;
for (int i = 0; i < b.bound; i++) {
sum += i;
}
return sum;
}
本机一次 -wi 3 -i 5 -w 1s -r 1s -f 1 的运行:
Benchmark Mode Cnt Score Error Units
FoldBench.loopFolded thrpt 5 2722635.950 ± 308715.328 ops/s
FoldBench.loopNotFolded thrpt 5 421368.400 ± 92463.306 ops/s
比值 6.46 倍,两条置信区间不重叠。按 1024 次迭代折算:常量上界那一侧每次迭代约 0.36 ns(约一个周期),字段上界那一侧约 2.3 ns。
但这组数字不能当作稳定结论。同一份 loopNotFolded,在本机另外三次运行里分别得到 82 万、297 万、302 万 ops/s,与这次的 42 万相差 7 倍。三次运行的差异不是代码变了,而是 C2 编译是否在测量窗口内完成——本机 load average 在 105 到 262 之间波动,编译线程和基准线程抢同一批核。能稳定复现的只有「常量上界明显更快」,具体倍数不可引用。
-XX:+PrintCompilation 给出了编译时间线的直接证据(真实片段):
175 649 % 3 com.plume.conc.lab.FoldBench::loopNotFolded @ 4 (24 bytes)
175 651 % 4 com.plume.conc.lab.FoldBench::loopNotFolded @ 4 (24 bytes)
% 表示 OSR(栈上替换),3 是 C1、4 是 C2;同一毫秒内方法就升到了 4 级。这也解释了为什么「预热不足」在小方法上很难用秒数来定义。
正确姿势不是避免展开,而是不要用「每次迭代成本」去反推微观指令数。基准只回答「调用一次要多久」,展开是 JIT 的自由。
陷阱四:预热不足
先用一个不依赖 JMH 的探针看编译过程:同一个方法,进程启动后每 10 ms 测一次 ns/op,共 1 秒。本机输出前几桶:
t= 0.000s iters= 5054 ns/op= 1978.84
t= 0.031s iters= 6145 ns/op= 1627.52
t= 0.041s iters= 6262 ns/op= 1596.97
t= 0.051s iters= 6095 ns/op= 1640.91
t= 0.061s iters= 6118 ns/op= 1634.67
第一桶 1978 ns/op,30 ms 后已经稳定在 1600 ns/op 附近,此后观测到的桶都在 1533–1654 之间。这个方法的编译过程在 10–30 ms 内就结束了,所以「预热要 1 秒」在这台机器上并不成立。
那为什么 JMH 默认要预热?因为要躲的不是「编译需要多久」,而是「测量窗口不要和编译期重叠」。一个基准的初始状态包括:类加载、注解生成的脚手架首次执行、@Setup 的对象分配、profile 从零开始累积。这些事件的耗时与机器负载强相关:本节在同一台机器上观察到同一基准 7 倍的跨运行波动,最合理的解释是编译是否在测量窗口内完成;但那三次运行没有同时采集 PrintCompilation,所以这一条是推断,不是实测。
判断预热够不够,不看秒数,看三件事:
| 判据 | 合格 | 不合格 |
|---|---|---|
| 迭代间趋势 | 后几次测量互相在误差内 | 单调上升,如 4.81M → 7.65M → 9.52M |
| 误差/均值 | 小于 20% | 误差项大于均值 |
| PrintCompilation | 目标方法已是 4 级 | 仍是 1/3 级,或反复 made not entrant |
2.1 里 synchronizedDecrement 的三次测量正是「单调上升」,所以那次结果只能看数量级,不能当均值。
陷阱五:伪共享未隔离
伪共享的意思是:两个线程写两个逻辑上无关的变量,但这两个变量落在同一个缓存行里,硬件仍然把它们当成同一块数据在核间搬运。M1 Pro 的缓存行是 128 字节,比 x86 的 64 字节更宽。
第一次尝试用的是数组,看起来没问题:
// 错误:普通数组写
@Benchmark
public void adjacent(Blackhole bh, Slots s, Slot slot) {
long v = ++s.adjacent[slot.index];
bh.consume(v);
}
@Benchmark
public void padded(Blackhole bh, Slots s, Slot slot) {
long v = ++s.padded[slot.index * 16];
bh.consume(v);
}
本机那一组运行的结果:adjacent 6.50 亿 ops/s,padded 6.63 亿 ops/s,两者几乎一模一样。这个数字在物理上不可能——两个核交替写同一缓存行的上限在千万量级。合理推断是数组写被当成了死存储:bh.consume(v) 只保证 v 被消费,不保证写回数组,JIT 把没被读过的存储删了。
改法是把写变成不可消除的 volatile 写,并用 @Group 把两个线程绑到同一个 group 上:
@State(Scope.Group)
public static class Adjacent {
public volatile long a;
public volatile long b;
}
@State(Scope.Group)
public static class Padded {
public volatile long a;
public long p0, p1, p2, p3, p4, p5, p6, p7;
public long p8, p9, p10, p11, p12, p13, p14; // 120 字节
public volatile long b; // 与 a 相隔 128 字节
}
@Benchmark @Group("adjacent") @GroupThreads(1)
public void adjacentA(Adjacent s) { s.a++; }
@Benchmark @Group("adjacent") @GroupThreads(1)
public void adjacentB(Adjacent s) { s.b++; }
@Benchmark @Group("padded") @GroupThreads(1)
public void paddedA(Padded s) { s.a++; }
@Benchmark @Group("padded") @GroupThreads(1)
public void paddedB(Padded s) { s.b++; }
本机实测(两组各 2 线程,参数同上):
Benchmark Mode Cnt Score Error Units
FalseSharingBench.adjacent thrpt 3 59052227.747 ± 4596904.315 ops/s
FalseSharingBench.adjacent:adjacentA thrpt 3 29458634.239 ± 2585193.803 ops/s
FalseSharingBench.adjacent:adjacentB thrpt 3 29593593.508 ± 2234487.243 ops/s
FalseSharingBench.padded thrpt 3 319278153.804 ± 13347088.246 ops/s
FalseSharingBench.padded:paddedA thrpt 3 159641057.543 ± 4379307.395 ops/s
FalseSharingBench.padded:paddedB thrpt 3 159637096.261 ± 9186009.360 ops/s
同一行 5905 万,不同行 3.19 亿,差 5.4 倍,两组置信区间完全不重叠。这个现象在本机稳定复现,因为 volatile 写不可能被消除。
这条数据同时说明了 2.1 里 Scope.Thread 为什么快 16 倍:不是原子的开销,是不共享缓存行。真实系统里如果两个高频字段恰好落在同一行,加锁加原子操作都救不回来,只能改布局。
在 libraryd-conc 里落地
把五个陷阱写成评审脚本,每次新增基准先过一遍。脚本只做静态检查,抓的是「模式」而不是「答案」:
#!/bin/sh
# bench-audit.sh —— 在 conc-lab/src/jmh/java 下运行
set -eu
fail=0
# 1) void 基准里没有 Blackhole 参数,基本上就是死代码消除候选
grep -Rn --include='*.java' -B2 'public void ' . | grep -q 'Blackhole' || {
echo "WARN: void 基准没有出现在任何 Blackhole 上下文里"; fail=1; }
# 2) 基准方法体里出现字面量循环上界,怀疑常量折叠/展开
grep -Rn --include='*.java' 'for (int [a-z]* = 0; [a-z]* < [0-9]\{2,\};' . && fail=1
# 3) 共享可变状态必须是 volatile 或原子类
grep -Rn --include='*.java' -A3 '@State(Scope.Group)' . | grep -E 'public +(long|int) +[a-z]' \
| grep -v volatile && fail=1
# 4) 报告里必须记录负载
grep -Lq 'load average' bench-*.txt 2>/dev/null && { echo "WARN: 报告缺少负载记录"; fail=1; }
exit $fail
脚本只能拦住形态问题,拦不住「测了但没测到」。所以还有一条硬规定:任何写进设计文档的基准数字,必须同时贴出 ±(99.9%) 误差项和 uptime。误差项重叠时,结论只能写「分不出」。
常见错误与边界
| 陷阱 | 症状 | 修复 |
|---|---|---|
| 死代码消除 | 结果好到不真实,误差项大于均值 | 返回值或 bh.consume |
| 常量折叠 | 参数或返回值能用计算器算出 | 输入来自状态对象或运行时 |
| 循环展开 | 用「每次迭代 ns」反推指令数 | 只报「每次调用 ns」 |
| 预热不足 | 迭代间单调上升 | 加迭代,看 PrintCompilation 是否到 4 级 |
| 伪共享未隔离 | 与理论量级差一到两个数量级 | @Group + 128 字节间隔,用 volatile 防消除 |
还有两个边界值得记住。第一,这些陷阱互相掩护:一个既有死代码消除、又有伪共享的基准,可能因为两个错误方向相反,恰好得出一个「合理」的数字。第二,JMH 不是万能的:它防住了死代码消除和常量折叠,但防不住你把不该共享的状态共享了,也防不住机器本身在跑别的负载——本机 load average 105–262,所有绝对值都只能看数量级。
什么时候不要用微基准:当你要回答的是「这个设计在线上会不会炸」时。微基准能证明「A 比 B 慢 5 倍」,不能证明「换成 A 之后 P99 会变好」,因为线上还有队列、GC、网络和限流。下一节讲这条边界。
小结
五个陷阱里,本机稳定复现的是死代码消除(1770–1870 倍)与伪共享(5.4 倍),这两条可以直接当判据用;常量折叠用 javap 证实了 javac 那一层,JIT 那一层因为没有 hsdis 只给了推导与验证方法;循环展开与预热不足受负载影响明显——同一个 loopNotFolded 在本机跑出 7 倍差距,预热探针也只能给出「30 ms 内编译完成」这一个量级结论。诚实地说,后两个的「本机数字」本身就是一个反例:它证明了在没有隔离的机器上,微基准的绝对值有多不可信。
阅读导航:上一节 2.1 JMH 的正确姿势 | 下一节 2.3 从微基准到系统压测
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。