微基准报告说新实现快 3 倍,改上线后 P99 纹丝不动。两边都没错,只是测的不是同一个东西:基准测的是一段被 JIT 优化到极限的纯计算,线上跑的是这段计算夹在 IO、锁、分配与 GC 之间的一小片。
对架构决策来说,问题不是"要不要做基准",而是这个数字能外推到哪一层决策:可用于算法级选型,不能用于定容量;能否决候选,不能承诺 SLA。
1. 三个断点:为什么"快 3 倍"到线上归零
| 断点 | 基准里 | 线上 | 倍数去哪了 |
|---|---|---|---|
| 计算占比 | 被测方法是全部 | 只占全链路 5%–15% | 被权重稀释 |
| 优化状态 | 单方法反复编译,C2 到顶 | 混合调用、类型守卫失效 | 测的是编译器最理想时 |
| 并发形态 | 单线程无争用 | 有锁、队列、切换 | 争用吃掉全部收益 |
这是可算的:设方法占端到端时间为 p,自身加速 s 倍,端到端加速为 1/((1-p)+p/s)。p=0.1、s=3 时端到端约 1.1 倍——与"毫无变化"一致。看到 N 倍,先问 p 是多少。
2. 失真的五个来源
这五条不是操作不规范,是语义层面的测量偏移:被测的东西已不是你以为的那个。
| 来源 | 机制 | 表面现象 | 判据 |
|---|---|---|---|
| 预热不足 | 前若干轮跑在解释器或 C1 | 多跑几次差数倍 | 随迭代数单调改善 = 未到稳态 |
| 常量折叠 | 输入是常量,C2 直接算出结果 | 耗时接近 0 | 换随机输入后突变 |
| 死代码消除 | 结果无人使用,整段被删 | 加更多工作反而更快 | 用 Blackhole 后差异出现 |
| 栈上替换 | 长循环中途被替换编译 | 分位诡异、曲线中段跳变 | 单次超长 + 结果双峰 |
| 测量开销 | nanoTime 本身有成本 |
纳秒操作测出微秒 | 空方法基准不为 0 |
第五条要先量化:跑一个空基准,得分就是这套装置的本底噪声,小于该量级的差异不构成证据。这是唯一能把"差异"和"噪声"分开的锚点。
@Benchmark public void empty() {} // 先跑它,得到测量底噪
OSR 让长循环在运行中被编译,副作用是前半段跑在旧版本、后半段跑在新版本,单次到毫秒级时测的就是混合体。JMH 的对策是切成固定批次、让替换只发生在批次边界附近——这是"自己写循环计时"几乎必然测歪的原因。
3. 稳态多久才来
解释执行 → C1 → C2 的路径见分层编译那篇,这里只要记住:"多久之后才是稳态"没有统一答案。
| 阶段 | 触发依据 | 典型量级(按 JDK 17 默认参数核对) | 决策含义 |
|---|---|---|---|
| 解释执行 | 无条件 | 首个调用 | 冷启动路径 |
| C1 编译 | 调用计数(千次量级) | 毫秒到秒 | 短命进程可能停在 C1 |
| C2 编译 | 调用 + 回边计数、队列深度 | 秒到数十秒 | 基准必须跑到这之后 |
| 稳态 | 队列排空、去优化停止 | 无固定值 | 只能靠日志确认 |
它取决于编译线程数、方法总量、去优化次数与队列积压。做法不是照抄"预热 20 秒",而是按日志核对:
java -Xlog:jit+compilation=info:file=jit.log:time,level,tags -jar app.jar
grep -c 'made not entrant' jit.log # 去优化次数,高说明优化假设反复失效
grep 'using c2' jit.log | tail -5 # 最后进入 C2 的时间点
判据:日志尾部不再出现新的 using c2,且 made not entrant 停止增长。JMH 默认预热是秒级,常小于稳态到达时间——"JMH 也测不准"多半是预热窗口不够。
4. JMH 解决什么,不解决什么
| 问题 | 手段 | 是否解决 |
|---|---|---|
| 死代码消除 | Blackhole.consume |
解决 |
| 常量折叠 | @State + 随机输入 |
解决(输入真随机) |
| 预热与稳态 | @Warmup + 多 fork |
解决(预热够长) |
| 测量噪声 | 多轮统计 + 置信区间 | 解决 |
| 内存分配压力 | 只测吞吐,不模拟堆 | 不解决 |
| GC 影响 | -prof gc 报分配速率 |
部分 |
| 真实数据分布 | 取决于你喂什么 | 不解决 |
| 并发争用 | 能加压,不还原调用比例 | 部分 |
@State(Scope.Thread)
public class MapBench {
private Map<String, String> map;
private String[] keys;
@Setup(Level.Trial)
public void setup() {
map = new HashMap<>();
keys = new String[1024];
for (int i = 0; i < keys.length; i++) {
keys[i] = UUID.randomUUID().toString(); // 随机键,防常量折叠
map.put(keys[i], "v");
}
}
@Benchmark @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS)
public String get(Blackhole bh) {
return map.get(keys[ThreadLocalRandom.current().nextInt(keys.length)]);
}
}
Level.Trial 与 Level.Invocation 选错会直接改变结论:前者把构建成本放在测量之外,后者乘进每次调用——那时测的是构建成本,不是查询成本。
5. 指标选择
| 指标 | 适合回答 | 不适合 | 报告要求 |
|---|---|---|---|
| 平均耗时 | 单位操作典型成本 | 尾部体验 | 必须带标准差 |
| 分位 | 抖动与尾延迟 | 容量换算 | 给直方图,单点分位被分桶掩盖 |
| 吞吐 ops/s | 单位资源处理量 | 单次延迟 | 写明线程数与批次 |
| 单次延迟含排队 | 用户感知 | 代码优劣 | 在接近真实的并发度下测 |
最常犯的错是拿吞吐回答延迟:吞吐高只说明单位时间完成得多,完全可以在吞吐峰值时 P99 很难看。
6. 基准该在哪跑
| 维度 | 要求 | 不满足时的偏移方向 |
|---|---|---|
| CPU 型号代际 | 与生产同代 | 代际 IPC 差异可盖过被测差异 |
| JDK 版本 | 完全一致含小版本 | 编译器与 GC 默认值随版本变 |
| JVM 参数 | 同 GC、同堆 | 小堆下的分配成本与大堆不同 |
| 核数 | 一致或标注 | 影响编译线程与内联预算 |
| 频率策略 | 关节能降频 | 变频使方差大于差异 |
| 邻居负载 | 独占 | 噪声吃小差异 |
cpupower frequency-set -g performance # 固定频率
taskset -c 2-5 java -jar benchmarks.jar -f 2 -wi 5 -i 5 -w 2s -r 2s
java -version 2>&1 | head -3 # 版本必须记进报告
没有"通用基准机":结论适用范围严格等于测量环境范围。跨机器比较两个实现,等于把两个未知偏移量相减。
7. 负向优化
| 现象 | 机制 | 处理 |
|---|---|---|
| 加缓存后单次更慢 | 未命中时多一次查找与驱逐 | 先测命中率 |
| 提取小方法后变慢 | 内联预算被挤占,热点不再内联 | 看内联日志,合并而非拆分 |
| 改并行后单请求更慢 | 拆分与合并成本高于收益 | 低于阈值的任务别拆 |
| 调参后短暂变快又回落 | 只是推迟了去优化 | 观察 made not entrant 趋势 |
识别负向优化的关键是看改动是否移动了成本位置:把 CPU 成本换成内存成本、把计算换成分配,都会在基准里更快、在线上更慢。
8. 能回答与不能回答
| 能回答 | 不能回答 |
|---|---|
| 两算法在同一输入规模下的常数因子差异 | 端到端延迟 |
| 同语义数据结构的选型 | 系统容量与实例数 |
| 某段代码是否值得优化(占 p 的比例) | 加机器能否解决问题 |
| 重构是否让该函数变快 | 用户体验是否改善 |
判据:主语是"这段代码"还是"这个系统"——前者可基准,后者只能画像加压测。
9. 外推的三种错误
- 线性外推:
100ns × QPS = CPU 占用,忽略 GC、锁、队列、序列化,通常低估一个数量级以上。 - 比例外推:
快 3 倍所以机器省 2/3,只在 p≈1 且无线程池排队时成立。 - 绝对外推:把基准纳秒数当线上延迟的一部分,而线上延迟由排队主导。
三者共同点是把稳态单线程数字当成并发系统期望值。修正方式是换工具:容量问题用压测,瓶颈问题用画像。
10. 何时该画像而不是基准
| 情境 | 基准 | 持续画像 | 理由 |
|---|---|---|---|
| 候选算法选型 | 合适 | 不需要 | 无现网负载可采 |
| P99 劣化定位热点 | 不合适 | 合适 | 真实分布只在现网 |
| 优化后确认收益 | 对照必需 | 验证必需 | 问题不同 |
| 容量规划 | 不合适 | 不合适 | 要的是排队模型 |
| 依赖升级前后 | 可选 | 合适 | 差异常在交互处 |
现网有真实负载时,一次短窗口采样比一周基准更有信息量——它测的是真实的 p 和调用比例。
回归化是另一笔账:进 CI 就是每次合并都跑,成本是固定机器时长加不稳定的判定阈值。可行做法是只对已确认的关键路径跑短基准,阈值设成"退化超过某个百分比才失败",并允许人工重设基线:
java -jar benchmarks.jar '.*OrderPath.*' -f 1 -wi 3 -i 3 -w 1s -r 1s -rf json -rff bench.json
# 与基线比对;阈值按本机方差定
共享 runner 的方差常大于被测差异,这类门禁在没有专用机器的团队里会先变成噪声源,再被关掉。
11. 排错:六种"数字不可信"
| 现象 | 原因 | 处理 |
|---|---|---|
| 多跑几次差几倍 | 预热不足或未 fork 独立 JVM | 加 @Fork、延长预热、看编译日志 |
| 耗时接近 0 | 死代码消除或常量折叠 | 加 Blackhole、随机输入 |
| 结果双峰 | OSR 混合两种产物 | 缩短粒度,改批次 |
| 加功能反而更快 | 工作量被部分消除 | 校验副作用是否真发生 |
| 本机复现不出数字 | 环境不同 | 记录环境,同环境内比较 |
| 基准稳定线上抖动 | 线上有 GC 与争用 | 改画像 + -prof gc 看分配速率 |
12. 边界:不必做基准的情况
| 情境 | 更合适的做法 | 理由 |
|---|---|---|
| 瓶颈在 IO 或锁 | 线程栈 + 锁 profile | 被测代码不在关键路径 |
| 差异小于噪声 | 不下结论 | 噪声内差异不构成依据 |
| 一次性选型决策 | 任务型基准或 PoC | 微基准不代表集成后表现 |
| 调用极少 | 直接读代码 | 收益小于维护成本 |
| 结论会改架构 | 压测 + 原型 | 架构决策要系统级证据 |
13. 自测
- 微基准快 3 倍、线上无变化,先算哪个量才能判断这现象是否正常?
- 空基准的得分在整套测量里起什么作用?
- 为什么"预热 20 秒"不是可以照抄的参数?
Level.Trial与Level.Invocation选错会测到什么?- 吞吐提升与 P99 恶化同时出现,两个数字各自约束什么?
- 什么信号说明该关掉基准、改用现网画像?