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()的装箱发生在构造阶段,脚本里没统计那一部分——如果要完整对比,需要把构造也包进测量范围。