2.9 Lab 2:三份基准与一个坏基准
上一节:2.8 JMH 与 kotlinx-benchmark | 下一节:第 3 章 测试分层 配套代码:02-jvm-performance-basics/09-lab2 预计时长:90 分钟
一、这个 Lab 的目标
前八节讲了理论,这个 Lab 让你亲手把每一节的结论验证一遍。
做完之后,你应该对「一个性能数字可不可信」有肌肉记忆——而不是靠回忆规则清单。
二、任务清单
| # | 任务 | 产出 | 时长 |
|---|---|---|---|
| 1 | 同一个算法三种测法对比 | 三组数字 + 差异解释 | 30 min |
| 2 | 故意写一个坏基准 | 荒谬数字 + 原因分析 | 15 min |
| 3 | 测你自己的「并发成本表」 | 6 个操作的量级 | 25 min |
| 4 | 调度器饥饿实验 | 观察到全局卡顿 | 20 min |
| 5 | 建实验档案 | docs/experiments/E02-.../README.md |
— |
三、任务 1:三份基准对比
被测对象:一个「有点计算量」的纯函数。建议用其中一个:
- 对
IntArray求和 / 求最大值 - 字符串拼接 1000 次
Regex匹配一段文本- 对一组数据做排序
三种测法:
| 测法 | 要求 | 预期可信度 |
|---|---|---|
| ① 裸计时 | System.nanoTime() + 循环 |
低(但有信息量) |
| ② JMH | @Benchmark + Blackhole + @Warmup + @Fork(2) |
高 |
| ③ kotlinx-benchmark | 插件配置 + 输出 JSON | 高(且便于 CI) |
必须记录的差异:
| 项目 | ① 裸计时 | ② JMH | ③ kotlinx-benchmark |
|---|---|---|---|
| Score | |||
| 单位 | ns/op | ops/ms 或 ns/op | |
| 误差/波动 | ± Error |
||
| 分配速率 | gc.alloc.rate |
||
| 你认为哪个可信 |
关键问题(写进实验档案):
- 三者的数字差多少倍?差在哪?
- 裸计时的结果稳定吗?如果不稳定,波动幅度是多少?
- JMH 的
± Error相对 Score 占多少?如果超过 5%,说明什么?
四、任务 2:故意写一个坏基准
写三个「坏」的版本,每个只违反一条规则:
// 坏版本 1:不消费结果
@Benchmark
fun brokenNoConsume() {
var s = 0L
for (v in data) s += v // 结果没人用
}
// 坏版本 2:常量输入
private val constData = IntArray(4096) { it } // 编译期可知的规律数据
@Benchmark
fun brokenConstInput(bh: Blackhole) {
var s = 0L
for (v in constData) s += v
bh.consume(s)
}
// 坏版本 3:setup 放在 Invocation 级别
@Setup(Level.Invocation) // ← 每次调用都执行,setup 开销被算进结果
fun setupEachTime() { data = IntArray(4096) { Random(it).nextInt() } }
预期观察:
| 版本 | 预期现象 |
|---|---|
| 坏版本 1 | 比正确版本快几个数量级(计算被删除) |
| 坏版本 2 | 与输入规模无关,或明显快于随机输入 |
| 坏版本 3 | 明显慢于正确版本(多算了 setup 的开销) |
注意坏版本 3 的特殊价值:它揭示了一个反直觉的事实——基准写得不对,既可能让数字虚高,也可能让数字虚低。不只是「作弊」,也可能是「冤枉」。
五、任务 3:测你自己的并发成本表
用 JMH(或 kotlinx-benchmark)测下面六个操作,填出你自己机器上的量级:
| # | 操作 | 实现提示 | 你的结果 |
|---|---|---|---|
| 1 | 无竞争 synchronized |
单线程进入+退出临界区 | |
| 2 | 无竞争 CAS | AtomicLong.incrementAndGet() |
|
| 3 | 有竞争锁(4 线程) | @Threads(4) + 同一把锁 |
|
| 4 | LongAdder(4 线程) |
对比第 3 项 | |
| 5 | 协程挂起 + 恢复 | withContext(Dispatchers.IO) { 1 } |
|
| 6 | 线程创建 | Thread { }.start() 后 join |
必答问题:
- 第 1 项和第 3 项差多少倍?(这就是「无竞争 vs 有竞争」的差距)
- 第 4 项比第 3 项快多少?(这验证了「分片优于竞争」)
- 第 5 项和第 6 项差多少个数量级?(这验证了「协程比线程便宜」)
- 第 5 项有分配吗?从
gc.alloc.rate看。
重要:并发基准极易被干扰。请确保
@Threads与@Fork配置正确,并且跑 3 次看结果是否一致。如果每次差很多,说明你的环境噪声太大——这本身也是一个重要发现(第 5 章会教你量化噪声)。
六、任务 4:调度器饥饿实验
这是本 Lab 最重要的一项。 它让你亲眼看到第 7 节讲的「全局卡顿」。
实验设计:
① 启动 N 个协程(N ≈ CPU 核数),每个在 Dispatchers.Default 上做「快速工作」,
并记录每次工作的耗时。
② 同时启动 N 个协程,也在 Dispatchers.Default 上,但做「阻塞工作」
(Thread.sleep(200) 或 JDBC 查询)。
③ 观察:①的那些「快速工作」的耗时发生了什么变化?
预期现象:
只有快速工作时 : 平均 0.1 ms
混入 N 个阻塞工作后 : 平均 200+ ms ← 涨了 2000 倍
然后做对照实验:把阻塞工作改到 Dispatchers.IO 上,再次观察。
阻塞工作切到 IO 后 : 快速工作恢复到 0.1 ms 左右
必答问题:
- 阻塞工作放在
Default上时,「快速工作」的延迟涨了多少倍? - 切到
IO之后恢复了吗?说明什么? - 在这个实验里,CPU 利用率是多少?(用
top或pidstat看)——这个数字能解释为什么这个故障难以排查。
七、任务 5:实验档案
按 docs/experiments/_TEMPLATE/README.md 建 docs/experiments/E02-jvm-basics/,并更新索引。
必须包含:
-
四组原始数据(贴文本)。
-
「预期 vs 实际」表——至少四行。例如:
我以为 实际是 结论 裸计时和 JMH 应该差不多 裸计时波动 ±40%,JMH 误差 ±2% JMH 的 fork 和预热消除了大部分噪声 协程和线程创建成本差不多 相差约 2 个数量级 协程是用户态对象,线程是内核资源 -
至少三条进假设台账。
八、验收标准
- 三份基准都跑通,数字有对比、有解释。
- 坏基准至少复现出「荒谬数字」一次,并解释清楚原因。
- 并发成本表 6 项都测了,且第 1 vs 3、第 4 vs 3、第 5 vs 6 的对比结论明确。
- 调度器饥饿实验观察到了延迟暴涨,且切到
IO后恢复。 - 实验档案有「预期 vs 实际」表和至少三条假设台账。
- 你能说出**至少三条「我会在什么情况下怀疑这个数字」**的判断依据。
九、常见问题
Q:我的数字和文档里的差很多,是不是做错了? A:大概率没做错。文档里的数字来自另一台机器。你要关注的是一致性和量级:协程是否明显比线程便宜?有竞争是否明显比无竞争慢?如果趋势一致,就说明你做对了。
Q:任务 3 的并发基准每次结果都不一样,波动 50%。
A:这说明你的环境噪声太大(后台进程、CPU 降频、核数不够)。这本身就是第 5 章要解决的问题(噪声底线)。现在的应对:① 关闭后台任务;② 把 @Fork 加到 3 以上;③ 用中位数而不是单次值。同时把这个现象记进档案——它是第 5 章的输入。
Q:调度器饥饿实验里,我把阻塞代码放在 runBlocking 里,结果没观察到卡顿。
A:runBlocking 会阻塞当前线程,如果它在 main 线程上,可能没有占住 Default 的 worker。检查两点:① 阻塞代码是否确实运行在 Dispatchers.Default 上(用 coroutineContext[CoroutineDispatcher] 打印确认);② 阻塞的协程数量是否 ≥ Default 的并行度(否则还有空闲线程,不会饿死)。
Q:我可以跳过任务 3、4 吗? A:不建议。任务 1、2 是「学会正确测量」,任务 3、4 是「建立并发成本的直觉」——后者在后面的限流、线程池、协程并行度调优里会反复用到。如果时间有限,宁可简化任务 1(只做裸计时 + JMH),也要做 3、4。