文档目录

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
你认为哪个可信

关键问题(写进实验档案):

  1. 三者的数字差多少倍?差在哪?
  2. 裸计时的结果稳定吗?如果不稳定,波动幅度是多少?
  3. 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. 第 1 项和第 3 项差多少倍?(这就是「无竞争 vs 有竞争」的差距)
  2. 第 4 项比第 3 项快多少?(这验证了「分片优于竞争」)
  3. 第 5 项和第 6 项差多少个数量级?(这验证了「协程比线程便宜」)
  4. 第 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 左右

必答问题:

  1. 阻塞工作放在 Default 上时,「快速工作」的延迟涨了多少倍?
  2. 切到 IO 之后恢复了吗?说明什么?
  3. 在这个实验里,CPU 利用率是多少?(用 top 或 pidstat 看)——这个数字能解释为什么这个故障难以排查。

七、任务 5:实验档案

按 docs/experiments/_TEMPLATE/README.md 建 docs/experiments/E02-jvm-basics/,并更新索引。

必须包含:

  1. 四组原始数据(贴文本)。

  2. 「预期 vs 实际」表——至少四行。例如:

    我以为 实际是 结论
    裸计时和 JMH 应该差不多 裸计时波动 ±40%,JMH 误差 ±2% JMH 的 fork 和预热消除了大部分噪声
    协程和线程创建成本差不多 相差约 2 个数量级 协程是用户态对象,线程是内核资源
  3. 至少三条进假设台账。


八、验收标准

  • 三份基准都跑通,数字有对比、有解释。
  • 坏基准至少复现出「荒谬数字」一次,并解释清楚原因。
  • 并发成本表 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。