文档目录

6.7 配套代码:陷阱的反例、正例与验证

对应小节:6.7 陷阱清单:Kotlin 与 C++ 直觉 九条 Kotlin 陷阱的可运行对照代码,以及三个验证实验。

一、九条陷阱的反例与正例

❶ 在 Dispatchers.Default 上做阻塞调用

// ❌ 反例:阻塞调用占满 Default 的 worker(= CPU 核数)
suspend fun findBad(id: Long): Order? =
    jdbcTemplate.queryForObject("select * from orders where id = ?", Order::class.java, id)

// ✅ 正例 1:切到 IO
suspend fun findGood(id: Long): Order? = withContext(Dispatchers.IO) {
    jdbcTemplate.queryForObject("select * from orders where id = ?", Order::class.java, id)
}

// ✅ 正例 2:异步驱动(根本不阻塞)
suspend fun findBest(id: Long): Order? =
    r2dbcClient.sql("select * from orders where id = :id")
        .bind("id", id)
        .map { row -> Order(row.get("id", Long::class.java)!!) }
        .awaitOneOrNull()

// ✅ 正例 3:即使切到 IO,也要限制并发(保护下游)
private val dbLimiter = Semaphore(64)
suspend fun findLimited(id: Long): Order? = dbLimiter.withPermit {
    withContext(Dispatchers.IO) {
        jdbcTemplate.queryForObject("select * from orders where id = ?", Order::class.java, id)
    }
}

❷ synchronized 跨越挂起点

// ❌ 反例:挂起时仍持锁
class OrderServiceBad {
    private val lock = Any()
    private val cache = mutableMapOf<Long, Order>()

    suspend fun get(id: Long): Order {
        synchronized(lock) {
            cache[id]?.let { return it }
            val order = loadFromDb(id)        // ← 挂起!但 monitor 没释放
            cache[id] = order
            return order
        }
    }
    private suspend fun loadFromDb(id: Long): Order = TODO()
}

// ✅ 正例:用 Mutex(协程感知)
class OrderServiceGood {
    private val mutex = Mutex()
    private val cache = mutableMapOf<Long, Order>()

    suspend fun get(id: Long): Order = mutex.withLock {
        cache[id] ?: loadFromDb(id).also { cache[id] = it }
    }
    private suspend fun loadFromDb(id: Long): Order = TODO()
}

❸ Dispatchers.IO 当成无限池

// ❌ 反例:10 万个协程抢 64 个 IO 线程
suspend fun handleAllBad(ids: List<Long>) = coroutineScope {
    ids.map { id -> async(Dispatchers.IO) { jdbcQuery(id) } }.awaitAll()
}

// ✅ 正例:用 Semaphore 或 limitedParallelism 显式限制
private val limiter = Semaphore(64)
suspend fun handleAllGood(ids: List<Long>) = coroutineScope {
    ids.map { id ->
        async { limiter.withPermit { withContext(Dispatchers.IO) { jdbcQuery(id) } } }
    }.awaitAll()
}

// ✅ 或用 limitedParallelism(Kotlin 1.6+)
private val limitedIo = Dispatchers.IO.limitedParallelism(64)

❹ runBlocking 在请求路径上

// ❌ 反例
fun handleBad(): Response = runBlocking { fetchData() }

// ✅ 正例:全链路 suspend
suspend fun handleGood(): Response = fetchData()

// ⚠️ 允许的用法:main / 测试 / 阻塞世界的边界
fun main() = runBlocking { startServer() }

❺ 无界缓存

// ❌ 反例:key 无限增长
class CacheBad {
    private val map = mutableMapOf<String, Order>()
    fun get(k: String) = map[k]
    fun put(k: String, v: Order) { map[k] = v }
}

// ✅ 正例:有界 + TTL + 抖动
private val cacheGood = Caffeine.newBuilder()
    .maximumSize(100_000)                                  // 有界
    .expireAfter(object : Expiry<String, Order> {           // TTL + 抖动
        private val base = Duration.ofMinutes(10).toMillis()
        override fun expireAfterCreate(k: String, v: Order, now: Long) =
            base + (Math.random() * base * 0.2).toLong()
        override fun expireAfterUpdate(k: String, v: Order, now: Long, dur: Long) =
            expireAfterCreate(k, v, now)
        override fun expireAfterRead(k: String, v: Order, now: Long, dur: Long) = dur
    })
    .recordStats()                                         // 可观测
    .build<String, Order>()

❻ GlobalScope / 未结构化并发

// ❌ 反例:请求结束后协程还在跑
fun handleBad() {
    GlobalScope.launch { heavyBackgroundWork() }
}

// ✅ 正例:绑定到请求生命周期
suspend fun handleGood() = coroutineScope {
    launch { heavyBackgroundWork() }    // 父作用域取消时它也取消
}

// ✅ 如果确实要后台任务,用受控的作用域 + 明确的取消策略
private val backgroundScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

❼ 热路径上的 copy()

data class Order(val id: Long, val status: String, val amount: Long, val items: List<Item>)
data class Item(val name: String, val qty: Int)

// ❌ 反例:热路径上频繁 copy
fun markAllPaidBad(orders: List<Order>) = orders.map { it.copy(status = "PAID") }

// ✅ 正例 1:如果只是展示,用视图对象
fun toView(orders: List<Order>) = orders.map { OrderView(it.id, it.status) }

// ✅ 正例 2:批量更新用一次性查询,不要逐条 copy
fun markAllPaidGood(ids: List<Long>) {
    // update orders set status = 'PAID' where id = any(?)
}

❽ 装箱与集合类型

// ❌ 反例:热路径上用包装类型
fun sumBad(values: List<Int>): Long {
    var s = 0L
    for (v in values) s += v          // v 是 Int(对象)→ 装箱
    return s
}

// ✅ 正例:原始类型数组
fun sumGood(values: IntArray): Long {
    var s = 0L
    for (v in values) s += v          // 无装箱
    return s
}

❾ Sequence 与 Regex

// ❌ 反例 1:小数据集用 Sequence(lambda 开销更大)
fun processBad(items: List<String>) =
    items.asSequence().map { it.trim() }.filter { it.isNotEmpty() }.toList()

// ✅ 正例:小数据集直接用 List 操作
fun processGood(items: List<String>) =
    items.map { it.trim() }.filter { it.isNotEmpty() }

// ❌ 反例 2:每次调用都编译正则
fun extractBad(text: String): Int {
    val re = Regex("""\d{3}-\d{4}""")      // 每次编译!
    return re.findAll(text).count()
}

// ✅ 正例:提到顶层,只编译一次
private val PHONE_RE = Regex("""\d{3}-\d{4}""")
fun extractGood(text: String): Int = PHONE_RE.findAll(text).count()

二、验证实验:用数据证明陷阱的影响

实验一:调度器污染(最有说服力)

// src/main/kotlin/experiments/DispatcherPollutionDemo.kt
package experiments

import kotlinx.coroutines.*
import kotlin.system.measureNanoTime

private var sink = 0L

suspend fun fastWork(): Double {
    val ns = measureNanoTime {
        var s = 0L
        repeat(10_000) { s += it }
        sink += s
    }
    return ns / 1_000_000.0
}

fun median(xs: List<Double>): Double = xs.sorted()[xs.size / 2]

fun main() = runBlocking {
    val cores = Runtime.getRuntime().availableProcessors()
    println("CPU 核数 = $cores,Dispatchers.Default 并行度 ≈ $cores")
    println()

    suspend fun runFast(n: Int) = coroutineScope {
        (0 until n).map { async(Dispatchers.Default) { fastWork() } }.awaitAll()
    }

    // 基线
    runFast(cores * 4)
    val baseline = runFast(cores * 4)
    println("基线(只有快速工作)    : 中位 %8.3f ms".format(median(baseline)))

    // 阻塞在 Default
    val blockers = List(cores) { launch(Dispatchers.Default) { Thread.sleep(1000) } }
    delay(100)
    val polluted = runFast(cores * 4)
    blockers.forEach { it.cancel() }
    println("阻塞在 Default          : 中位 %8.3f ms  ← 放大 %.0f 倍".format(
        median(polluted), median(polluted) / median(baseline)))

    delay(300)

    // 阻塞在 IO
    val ioBlockers = List(cores) { launch(Dispatchers.IO) { Thread.sleep(1000) } }
    delay(100)
    val healthy = runFast(cores * 4)
    ioBlockers.forEach { it.cancel() }
    println("阻塞在 IO               : 中位 %8.3f ms  ← 恢复正常".format(median(healthy)))

    println()
    println("sink = $sink")
    println()
    println("结论:同样是阻塞调用,放在 Default 上会让【所有】使用 Default 的协程排队。")
}

预期:阻塞在 Default 时,快速工作的延迟从 0.08 ms 涨到几百毫秒(放大几千倍),而 CPU 利用率很低。

实验二:装箱与 copy() 的分配成本

// src/main/kotlin/experiments/AllocationTrapsDemo.kt
package experiments

import java.lang.management.ManagementFactory

private val threadBean = ManagementFactory.getThreadMXBean() as com.sun.management.ThreadMXBean

fun allocMb(): Double =
    threadBean.getThreadAllocatedBytes(Thread.currentThread().threadId()) / 1024.0 / 1024.0

data class Order(val id: Long, val status: String, val amount: Long)

fun main() {
    val n = 1_000_000

    // ① List<Int> 装箱
    val boxed: List<Int> = (1..n).toList()
    var before = allocMb()
    var s = 0L
    for (v in boxed) s += v
    println("List<Int> 求和    : 分配 %8.1f MB  (sum=$s)".format(allocMb() - before))

    // ② IntArray 无装箱
    val primitive = IntArray(n) { it }
    before = allocMb()
    s = 0L
    for (v in primitive) s += v
    println("IntArray 求和     : 分配 %8.1f MB  (sum=$s)".format(allocMb() - before))

    // ③ copy()
    val orders = List(100_000) { Order(it.toLong(), "NEW", 100) }
    before = allocMb()
    val copied = orders.map { it.copy(status = "PAID") }
    println("copy() × 10 万    : 分配 %8.1f MB  (size=${copied.size})".format(allocMb() - before))

    // ④ 直接构造视图
    data class View(val id: Long, val status: String)
    before = allocMb()
    val views = orders.map { View(it.id, "PAID") }
    println("构造 View × 10 万 : 分配 %8.1f MB  (size=${views.size})".format(allocMb() - before))

    println()
    println("注意:")
    println("  ① List<Int> 的装箱发生在构造阶段(上面的分配统计只算了遍历)")
    println("  ② copy() 与构造 View 的分配量相近 —— 优化点不在'少分配',")
    println("     而在于【能不能不构造】(比如直接批量 UPDATE)")
}

实验三:正则重复编译 vs 预编译

// src/main/kotlin/experiments/RegexDemo.kt
package experiments

import kotlin.system.measureNanoTime

private val PRE = Regex("""\d{3}-\d{4}""")

fun main() {
    val text = (1..2000).joinToString(" ") { "%03d-%04d".format(it % 1000, it) }
    val rounds = 1000

    // 冷启动:每次编译
    val bad = measureNanoTime {
        repeat(rounds) {
            val re = Regex("""\d{3}-\d{4}""")     // ← 每次编译
            re.findAll(text).count()
        }
    } / rounds / 1000.0

    // 预编译:只编译一次
    val good = measureNanoTime {
        repeat(rounds) {
            PRE.findAll(text).count()
        }
    } / rounds / 1000.0

    println("每次编译正则 : %8.1f us".format(bad))
    println("预编译正则   : %8.1f us".format(good))
    println("差距         : %.1f 倍".format(bad / good))
    println()
    println("这就是 Lab 6 的 /slow-cpu 埋雷的原理 —— 它在热路径上重复编译正则。")
}

三、一键运行所有验证

#!/usr/bin/env bash
# tools/verify-traps.sh
#
# 依次运行三个验证实验,输出结果并保存。
set -uo pipefail

DIR="docs/experiments/E06-traps/results"
mkdir -p "$DIR"

echo "═══════════════════════════════════════════════════════════════"
echo "Kotlin 陷阱验证实验"
echo "═══════════════════════════════════════════════════════════════"
echo

for DEMO in DispatcherPollutionDemo AllocationTrapsDemo RegexDemo; do
  echo "───────── $DEMO ─────────"
  java -cp build/classes/kotlin/main "experiments.$DEMO" 2>&1 | tee "$DIR/$DEMO.txt"
  echo
done

echo "✅ 结果已保存 → $DIR"
echo
echo "把这些结果填进实验档案的「原始结果」章节。"

四、动手改造

改动 观察什么
把 DispatcherPollutionDemo 的阻塞协程数从 cores 改成 cores/2 卡顿明显变小——说明必须占满才饿死
把 Thread.sleep 改成 delay 完全没有卡顿——因为 delay 是真挂起,会释放线程
在 AllocationTrapsDemo 里把构造阶段的分配也算进去 List<Int> 的分配会大得多(装箱在构造时就发生了)
把 RegexDemo 的 rounds 从 1000 改成 100 差距变小(因为编译开销被摊薄)
用 -Xmx 限制堆再跑分配实验 触发更多 GC,P99 更差——印证"分配速率决定 GC"

五、这段代码的局限

  • 这些实验的数字来自开发机:只用于机制验证与量级对比,不能用于工程决策。
  • AllocationTrapsDemo 用 JDK 内部 API(ThreadMXBean.getThreadAllocatedBytes):只能统计当前线程,且 API 非标准。
  • DispatcherPollutionDemo 的 runBlocking 引入额外开销:绝对值不可信,看放大倍数。
  • 装箱实验的分配统计不完整:(1..n).toList() 的装箱发生在构造阶段,脚本里没统计那一部分——如果要完整对比,需要把构造也包进测量范围。