2.9 配套代码:Lab 2 的实验骨架
对应小节:2.9 Lab 2 这里提供 Lab 2 五个任务的代码骨架与记录模板。任务 3、4 的完整代码在 06 和 07。
一、任务 1:三份基准对比
1.1 被测对象
// src/main/kotlin/lesson02/lab2/Workloads.kt
package lesson02.lab2
import kotlin.random.Random
/** 选一个作为被测对象;建议同时保留全部三个,便于观察不同 workload 的特征 */
object Workloads {
/** ① 纯计算:数组求和 */
fun sum(data: IntArray): Long {
var s = 0L
for (v in data) s += v
return s
}
/** ② 分配密集:字符串拼接 */
fun concat(n: Int): String {
var sb = StringBuilder()
repeat(n) { sb.append(it).append(',') }
return sb.toString()
}
/** ③ 正则匹配(CPU + 分配) */
private val RE = Regex("""\d{3}-\d{4}""")
fun matchAll(text: String): Int = RE.findAll(text).count()
fun randomData(size: Int, seed: Int = 42): IntArray {
val rnd = Random(seed)
return IntArray(size) { rnd.nextInt() }
}
}
1.2 裸计时版本(任务 1 的 ①)
// src/main/kotlin/lesson02/lab2/NaiveTiming.kt
package lesson02.lab2
import lesson02.lab2.Workloads.matchAll
import lesson02.lab2.Workloads.randomData
import lesson02.lab2.Workloads.sum
import kotlin.system.measureNanoTime
private var sink = 0L
/**
* 裸计时:故意保留所有"民间做法",用来观察它有多不可靠。
* 请先猜一下:这个版本和 JMH 差多少倍?
*/
fun main() {
val data = randomData(4096)
val text = (1..2000).joinToString(" ") { "%03d-%04d".format(it % 1000, it) }
println("=== 裸计时(5 轮,观察波动)===")
println("%-8s %14s %14s %14s".format("轮次", "sum(ns/op)", "concat(ns/op)", "regex(ns/op)"))
repeat(5) { round ->
// sum
val sumNs = measureNanoTime {
repeat(100_000) { sink += sum(data) }
} / 100_000.0
// concat
val concatNs = measureNanoTime {
repeat(1_000) { sink += concat(500).length }
} / 1_000.0
// regex
val regexNs = measureNanoTime {
repeat(1_000) { sink += matchAll(text) }
} / 1_000.0
println("%-8d %14.1f %14.1f %14.1f".format(round, sumNs, concatNs, regexNs))
}
println()
println("sink = $sink")
println()
println("要观察的三件事:")
println(" 1. 第一轮是否明显慢? (预热不足)")
println(" 2. 各轮之间的波动有多大? (CV = 标准差/均值)")
println(" 3. concat 的分配去哪了? (裸计时看不到 gc.alloc.rate)")
}
1.3 记录表格(填进实验档案)
## 任务 1 结果
| 项目 | ① 裸计时 | ② JMH | ③ kotlinx-benchmark |
| --- | --- | --- | --- |
| sum (ns/op) | | | |
| concat (ns/op) | | | |
| regex (ns/op) | | | |
| 单位 | ns/op | ops/ms → 换算 | ops/s |
| 波动/误差 | 看 5 轮的 CV | `± Error` | |
| gc.alloc.rate | 无 | | |
| 轮次是否隔离 | ❌ 同一 JVM | ✅ `@Fork(2)` | ✅ |
| 我的可信度评价 | | | |
### 必答问题
1. 三者数字差多少倍?差在哪(预热?隔离?统计口径?)
2. 裸计时的 CV 是多少?超过 10% 说明什么?
3. JMH 的 `Error / Score` 是多少?超过 5% 说明什么?
二、任务 2:三个坏基准
// src/jmh/kotlin/bench/BrokenBenchmarks.kt
package bench
import org.openjdk.jmh.annotations.*
import org.openjdk.jmh.infra.Blackhole
import java.util.concurrent.TimeUnit
import kotlin.random.Random
@State(Scope.Benchmark)
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(2)
open class BrokenBenchmarks {
private lateinit var data: IntArray
private val constData = IntArray(4096) { it } // 规律数据
@Setup(Level.Trial)
fun setup() {
val rnd = Random(42)
data = IntArray(4096) { rnd.nextInt() }
}
// ── ✅ 正确版本(基准线) ──────────────────────────────────
@Benchmark
fun correct(bh: Blackhole) {
var s = 0L
for (v in data) s += v
bh.consume(s)
}
// ── ❌ 坏版本 1:不消费结果 → 死代码消除 ────────────────────
@Benchmark
fun brokenNoConsume() {
var s = 0L
for (v in data) s += v
// 结果丢弃 → JIT 可能删除整个计算
}
// ── ❌ 坏版本 2:常量输入 → 常量折叠 ────────────────────────
@Benchmark
fun brokenConstInput(bh: Blackhole) {
var s = 0L
for (v in constData) s += v
bh.consume(s)
}
// ── ❌ 坏版本 3:setup 在 Invocation 级别 → 开销被算进去 ────
@Setup(Level.Invocation)
fun setupEachInvocation() {
data = IntArray(4096) { Random(it).nextInt() }
}
@Benchmark
fun brokenSetupInInvocation(bh: Blackhole) {
var s = 0L
for (v in data) s += v
bh.consume(s)
}
}
记录表格:
## 任务 2 结果
| 版本 | Score | 相对正确版本 | 原因 |
| --- | --- | --- | --- |
| ✅ correct | | 1.00x | 基准线 |
| ❌ brokenNoConsume | | | |
| ❌ brokenConstInput | | | |
| ❌ brokenSetupInInvocation | | | |
### 必答问题
1. 坏版本 1 为什么可能快几个数量级?怎么验证它是假的?
2. 坏版本 3 是"虚高"还是"虚低"?这说明基准写错会往哪个方向偏?
坏版本 3 的答案很关键:它让数字虚低(变慢)。所以基准写错的方向不总是对自己有利——也可能白白冤枉一段好代码。
三、任务 3、4 的代码位置
| 任务 | 代码文件 |
|---|---|
| 任务 3:并发成本表(6 项) | 06-concurrency-primitives.md |
| 任务 4:调度器饥饿实验 | 07-coroutines-cost-model.md |
任务 3 的记录表格:
## 任务 3 结果(你自己机器上的量级)
| # | 操作 | 结果 | 备注 |
| --- | --- | --- | --- |
| 1 | 无竞争 synchronized | | |
| 2 | 无竞争 CAS | | |
| 3 | 有竞争锁(4 线程) | | |
| 4 | LongAdder(4 线程) | | |
| 5 | 协程挂起 + 恢复 | | |
| 6 | 线程创建 | | |
### 对比结论
- 第 1 vs 第 3:______ 倍(无竞争 vs 有竞争的差距)
- 第 4 vs 第 3:______ 倍(分片的效果)
- 第 5 vs 第 6:______ 个数量级(协程 vs 线程)
- 第 5 项的 gc.alloc.rate:______ (协程挂起有分配吗?)
四、任务 5:实验档案模板
<!-- docs/experiments/E02-jvm-basics/README.md -->
---
id: E02
title: JVM 性能基础实验(预热、陷阱、并发成本、调度器)
date: 2025-xx-xx
chapter: 2
kind: probe
status: done
hypothesis: "裸计时的波动会远超 JMH;协程挂起比线程创建便宜 2 个数量级;阻塞调用放在 Default 上会造成数量级的延迟放大"
variable: "无(机制验证实验)"
control: "各实验内的对照组"
commit: <hash>
jvm_args: "-XX:+PrintCompilation -Xlog:gc*:file=gc.log:time,uptime,level,tags"
tags: [jit, gc, coroutines, lab2]
---
## 1. 假设与预期
(写清每个实验你预期看到什么)
## 2. 环境元数据
(粘 results/env.txt)
## 3. 原始结果
(4 组数据:三份基准、坏基准、并发成本表、调度器饥饿)
## 4. 观察与解释
| 我以为 | 实际是 | 结论 |
| --- | --- | --- |
| | | |
| | | |
| | | |
### 意外发现
## 5. 结论
## 6. 被推翻的假设(同步到假设台账)
## 7. 遗留问题与下一步
五、判断「这个数字可不可信」的检查清单
做完 Lab 2 后,用这五条审视任何性能数字:
- 规模敏感吗? 输入扩大 10 倍,耗时是否随之变化?(防 DCE / 常量折叠)
- 分布敏感吗? 换一种数据分布,结果是否大幅变化?(防分支预测过优)
- 分配可见吗? 声称"创建大量对象"的操作,分配速率是 0 吗?(防逃逸分析消除)
- 预热与测量分离了吗? 有没有独立的预热阶段,且数据未混入统计?
- 独立执行吗? 多次运行/多 fork 的结果一致吗?误差有多大?
五条里有一条答不上来,这个数字就不该进入你的结论。
六、Lab 2 的自检命令
# 三份基准
./gradlew jmh # JMH 结果在 build/reports/jmh/
./gradlew benchmark # kotlinx-benchmark 结果在 build/reports/benchmarks/
# 裸计时与机制实验
java -cp build/classes/kotlin/main lesson02.lab2.NaiveTimingKt
java -XX:+PrintCompilation -cp build/classes/kotlin/main lesson02.ExecutionTiersKt
java -cp build/classes/kotlin/main lesson02.AllocationRateKt
java -cp build/classes/kotlin/main lesson02.PrimitiveCostsKt
java -cp build/classes/kotlin/main lesson02.DispatcherStarvationKt
# 实验档案
ls -la docs/experiments/E02-jvm-basics/
grep -n "E02" docs/experiments/README.md