2.3 配套代码:微基准四大陷阱的现场复现
对应小节:2.3 微基准四大陷阱 每个陷阱都写「坏版本」和「好版本」,并排跑,让差异自己说话。
一、完整实验代码
// src/main/kotlin/lesson02/MicrobenchmarkTraps.kt
package lesson02
import kotlin.random.Random
// ── 被测函数 ──────────────────────────────────────────────────
fun sumArray(data: IntArray): Long {
var s = 0L
for (v in data) s += v
return s
}
/** 含分支的函数:用来演示分支预测 */
fun countPositive(data: IntArray): Int {
var c = 0
for (v in data) if (v > 0) c++
return c
}
data class OrderDto(val id: Long, val name: String, val amount: Long)
fun makeDto(i: Int) = OrderDto(i.toLong(), "order-$i", (i * 31).toLong())
// ── 全局 sink:防止整个实验被优化掉 ────────────────────────────
private var sink = 0L
fun bench(iterations: Int, label: String, body: () -> Long): Double {
// 粗略预热(真实基准要用 JMH)
repeat(iterations / 4) { sink += body() }
val t0 = System.nanoTime()
repeat(iterations) { sink += body() }
val t1 = System.nanoTime()
val ns = (t1 - t0).toDouble() / iterations
println("%-46s %12.3f ns/op".format(label, ns))
return ns
}
fun main() {
val rnd = Random(42)
val randomData = IntArray(4096) { rnd.nextInt() }
val constLikeData = IntArray(4096) { it } // 规律数据
val mixedSignData = IntArray(4096) { rnd.nextInt(-100, 100) }
val allPositive = IntArray(4096) { rnd.nextInt(1, 100) }
println("═".repeat(76))
println("陷阱一:死代码消除(DCE)")
println("═".repeat(76))
bench(200_000, "❌ 坏版本:不消费返回值") {
sumArray(randomData) // 结果丢弃 → 计算可能被整体删除
0L
}
bench(200_000, "✅ 好版本:消费返回值") {
sumArray(randomData)
}
println("→ 坏版本的数字可能低到物理不可能。判断方法:把数组大小改成 10 倍,看耗时是否变化。\n")
println("═".repeat(76))
println("陷阱二:常量折叠")
println("═".repeat(76))
bench(200_000, "❌ 坏版本:规律数据({ it })") {
sumArray(constLikeData)
}
bench(200_000, "✅ 好版本:运行时随机数据") {
sumArray(randomData)
}
println("→ 规律数据可能被识别出模式(甚至直接套公式),数字与真实负载无关。\n")
println("═".repeat(76))
println("陷阱三:分支预测被喂得太好")
println("═".repeat(76))
bench(200_000, "❌ 坏版本:全部正数(分支永远成立)") {
countPositive(allPositive).toLong()
}
bench(200_000, "✅ 好版本:正负混合(分支随机失败)") {
countPositive(mixedSignData).toLong()
}
println("→ 全正数时分支预测接近 100% 命中,比真实混合流量快得多。\n")
println("═".repeat(76))
println("陷阱四:未消费结果(逃逸分析消除分配)")
println("═".repeat(76))
// 用分配速率来观察,而不是耗时
val before1 = allocatedBytes()
repeat(1_000_000) { makeDto(it) } // 对象不逃逸
val after1 = allocatedBytes()
println("❌ 坏版本(对象不逃逸):分配 %,d 字节".format(after1 - before1))
val list = ArrayList<OrderDto>(1_000_000)
val before2 = allocatedBytes()
repeat(1_000_000) { list.add(makeDto(it)) } // 对象逃逸到集合
val after2 = allocatedBytes()
println("✅ 好版本(对象逃逸到集合):分配 %,d 字节".format(after2 - before2))
sink += list.size
println("→ 不逃逸时逃逸分析可能连对象带分配一起消除,你测不到真实的 GC 压力。")
println()
println("sink = $sink")
}
/** 用 ThreadMXBean 读取当前线程累计分配的字节数(近似值,仅用于对比) */
fun allocatedBytes(): Long =
(java.lang.management.ManagementFactory.getThreadMXBean() as? com.sun.management.ThreadMXBean)
?.getThreadAllocatedBytes(Thread.currentThread().threadId())
?: -1L
关于
allocatedBytes():它用了com.sun.management.ThreadMXBean这个 JDK 内部 API(只用于教学演示)。如果编译报错,可以换成用-prof gc(JMH)或 async-profiler 的alloc事件来观察。
二、预期输出形态
════════════════════════════════════════════════════════════════════════════
陷阱一:死代码消除(DCE)
════════════════════════════════════════════════════════════════════════════
❌ 坏版本:不消费返回值 0.213 ns/op
✅ 好版本:消费返回值 231.480 ns/op
→ 坏版本的数字可能低到物理不可能。
════════════════════════════════════════════════════════════════════════════
陷阱三:分支预测被喂得太好
════════════════════════════════════════════════════════════════════════════
❌ 坏版本:全部正数(分支永远成立) 118.2 ns/op
✅ 好版本:正负混合(分支随机失败) 392.7 ns/op
════════════════════════════════════════════════════════════════════════════
陷阱四:未消费结果(逃逸分析消除分配)
════════════════════════════════════════════════════════════════════════════
❌ 坏版本(对象不逃逸):分配 0 字节
✅ 好版本(对象逃逸到集合):分配 48,000,000 字节
三个最值得注意的数字:
| 观察 | 含义 |
|---|---|
0.213 ns/op |
物理上不可能(一次内存访问要 ~1 ns)→ 计算被删除了 |
| 分支预测:118 → 392 ns(3.3 倍) | 「同一份代码」在两种输入下的差异 |
| 分配:0 → 4800 万字节 | 逃逸分析把第一版的分配完全消除了 |
三、判断「我的基准是不是假的」——三个快速测试
// 测试 1:规模敏感性
// 把输入规模扩大 10 倍,看耗时是否按比例变化。
// 如果完全不变 → 计算被删除了(或常量折叠了)。
// 测试 2:数据分布敏感性
// 用「全部相同」「正负交替」「随机」三种数据各跑一次。
// 如果差异巨大 → 说明结果严重依赖分支预测,你的默认数据可能"太乖"。
// 测试 3:分配敏感性
// 用 `-Xlog:gc` 或 JMH 的 `-prof gc` 看分配速率。
// 如果测一个"创建大量对象"的操作但分配速率是 0 → 逃逸分析把分配消除了。
四、对照表:坏版本 vs 好版本
| 陷阱 | ❌ 坏版本 | ✅ 好版本 | 判断方法 |
|---|---|---|---|
| 死代码消除 | 丢弃返回值 | 累加到全局 sink / Blackhole |
扩大规模看数字是否变 |
| 常量折叠 | IntArray(4096) { it } |
运行时 Random 生成 |
换数据看数字是否变 |
| 分支预测 | 全部同一种输入 | 混合分布的真实数据 | 换分布看差异倍数 |
| 未消费结果 | 对象即时丢弃 | 让结果逃逸(集合/返回) | 看分配速率是否为 0 |
五、动手改造
| 改动 | 观察什么 |
|---|---|
把 constLikeData 改成 IntArray(4096) { 1 } |
求和会被折叠成一个乘法吗?数字会变成多少? |
| 把分支实验的数据改成「前一半正、后一半负」 | 介于「全正」和「随机」之间——这正好解释了「排序后数组更快」的经典现象 |
把 bench 里的预热去掉 |
数字会大幅上升(测的是解释执行/C1) |
用 -Xlog:gc 观察陷阱四的两个版本 |
坏版本完全不触发 GC,好版本会触发——这就是分配速率的影响 |
六、这段代码的局限
bench()不是可信的基准:它没有独立的 fork、预热与测量没有严格分离、没有统计误差。它的用途是演示陷阱,不是测量性能。allocatedBytes()是近似值:它统计的是当前线程的分配(不含其他线程),且在 JDK 内部 API 上。用于对比可以,用于精确测量不行。- 依赖开发机环境:分支配额的差异在不同 CPU 上可能不同(分支预测器能力不同),但趋势一致。
做完这个实验后请记住:这四种陷阱我也能用同一种方式全部避开——用 JMH(第 8 节)。