文档目录

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 节)。