文档目录

6.7 陷阱清单:Kotlin 与 C++ 直觉

上一节:6.6 瓶颈模式库(二) | 下一节:6.8 结论的写法 配套代码:06-analysis-and-profiling/07-traps-kotlin-and-cpp


一句话结论

九条 Kotlin 陷阱和六条 C++ 直觉误判,是「明明代码看起来没问题,但性能就是不对」的两大来源。 前者来自语言特性,后者来自你的经验在 JVM 上不成立。


一、九条 Kotlin 陷阱(按危害排序)

❶ 在 Dispatchers.Default 上做阻塞调用 🔴 最严重

// ❌ 占满 Default 的 worker(= CPU 核数)
suspend fun find(id: String) = jdbcTemplate.queryForObject(...)

// ✅ 切到 IO
suspend fun find(id: String): Order = withContext(Dispatchers.IO) {
    jdbcTemplate.queryForObject(...)
}

症状:CPU 低、所有接口一起慢、常规指标正常。 验证:wall 火焰图 + 「RUNNABLE == 核数」。

❷ synchronized 跨越挂起点 🔴

// ❌ 挂起时仍持锁,线程被占用
suspend fun update() {
    synchronized(lock) {
        val data = loadFromDb()      // ← 挂起!
        cache.update(data)
    }
}

// ✅ 用 Mutex(协程感知的锁)
private val mutex = Mutex()
suspend fun update() = mutex.withLock {
    val data = loadFromDb()
    cache.update(data)
}

为什么危险:synchronized 是线程级锁,协程会在不同线程间切换 → 语义混乱、可能死锁。

❸ Dispatchers.IO 当成无限池 🔴

// ❌ IO 默认并行度 64,超出部分排队
launch(Dispatchers.IO) { jdbcQuery() }   // 10 万个这样的协程 → 99936 个排队

症状:CPU 不高但延迟猛涨。 验证:DebugProbes.dumpCoroutines() 看排队数。

❹ runBlocking 在请求路径上 🟠

// ❌ 阻塞当前线程
fun handleRequest(): Response = runBlocking { fetchData() }

// ✅ 全链路 suspend,或确保它在专门的线程池里

允许出现:main、测试、阻塞世界的边界。

❺ 无界缓存 🟠

// ❌ key 无限增长 → OOM
val cache = mutableMapOf<String, Order>()

// ✅ 有界 + TTL
val cache = Caffeine.newBuilder().maximumSize(100_000).expireAfterWrite(10.minutes).build<String, Order>()

❻ GlobalScope / 未结构化并发 🟠

// ❌ 请求结束了,协程还在跑,持有引用
GlobalScope.launch { heavyWork() }

// ✅ 绑定到请求生命周期
coroutineScope { launch { heavyWork() } }

症状:内存缓涨、连接不释放、QPS 下降但 CPU 不高。

❼ 热路径上的 data class 的 copy() 🟡

// ❌ 每次调用分配新对象
orders.map { it.copy(status = "PAID") }

// ✅ 如果只是读,不要 copy;如果要改,考虑可变对象或 builder

验证:alloc 火焰图里看到 copy$default。

❽ 装箱与集合类型选择 🟡

// ❌ List<Int> 会装箱
val list: List<Int> = (1..1000).toList()

// ✅ 热路径用原始类型数组
val array = IntArray(1000) { it }

验证:alloc 火焰图里看到 Integer.valueOf。

❾ Sequence 与 Regex 的误用 🟡

// ❌ 小数据集上 Sequence 的 lambda 开销可能更大
list.asSequence().map { ... }.filter { ... }.toList()

// ❌ 每次调用都编译正则
Regex("""\d{3}-\d{4}""").findAll(text)

// ✅ 提到顶层,只编译一次
private val RE = Regex("""\d{3}-\d{4}""")

验证:cpu 火焰图里看到 Pattern.compile。


二、六条 C++ 直觉误判

❶ 「分配很贵,要尽量少分配」

C++ 的现实:malloc 有锁、有系统调用,确实贵。

JVM 的现实:TLAB 指针碰撞,比一次内存访问还快。贵的是分配速率带来的 GC。

❌ C++ 直觉:优化目标是"零分配"
✅ JVM 现实:优化目标是"降低分配速率 + 缩短对象存活时间"

推论:对象池在 JVM 上通常是负优化(同步成本 > 分配成本),除非是大对象/直接内存。

❷ 「无锁一定更快」

C++ 的现实:无锁数据结构可控、可预测。

JVM 的现实:GC(对象移动、ABA)、安全点、更强的 volatile 语义让无锁更难更险。

❌ C++ 直觉:遇到竞争就上 CAS/自旋
✅ JVM 现实:优先「不共享」(分片 / 线程封闭 / 不可变)

验证方法:如果怀疑锁竞争,先测 LongAdder(分片)与自写无锁的对比——大多数情况下分片更快。

❸ 「伪共享用填充解决」

C++ 的现实:alignas(64) 精确控制布局。

JVM 的现实:对象布局由 JVM 决定(压缩指针、对象头、TLAB),你能控制的很有限。

❌ C++ 直觉:加 padding 解决
✅ JVM 现实:从设计上避免共享(分片),比布局上缓解更可靠

❹ 「手动内联/展开循环能提速」

JVM 的现实:C2 已经做了(包括自动向量化),手写可能破坏优化机会。

例子(第 2 章 2.1 节的实验):4096 个 int 求和只要 230 ns——因为 C2 自动向量化了(约 17.8 个元素/ns)。手写的"优化"版本反而可能更慢。

❺ 「线程越多吞吐越高」

JVM 的现实:线程是 OS 级的(几十微秒创建),超过核数后上下文切换 + 缓存失效反噬。

验证:nvcswch/s 指标——从 3000 涨到 45000 就是信号(第 4 章 4.5)。

❻ 「测量工具是中立的」

C++ 的现实:微基准测纯计算,工具影响可忽略。

JVM/后端的现实:

  • 闭环压测工具会减少发压,隐藏慢样本(协调遗漏)
  • 观测工具本身有开销(JFR 1–2%,高频 profiler 5–15%)
  • 测量改变被测量对象

三、还有三条「从 C++ 带来的好直觉」(要保留)

不要因为上面六条就否定你的经验——这些在 JVM 上完全成立:

直觉 为什么成立
局部性很重要 CPU 缓存层级没变,缓存未命中依然昂贵
锁竞争限制扩展性 只是修法优先级不同(先"不共享")
算法复杂度决定上限 任何平台都逃不过
批量处理提高吞吐 代价同样是延迟
先测量再优化 在 JVM 上更重要(因为更难预测)

四、一张「陷阱 → 症状 → 验证」总表

陷阱 症状 验证命令
Default 上阻塞 CPU 低 + 全局慢 asprof -e wall
synchronized 跨挂起 偶发卡死/延迟尖刺 代码搜索 + lock 火焰图
IO 池当无限用 CPU 不高但延迟涨 协程 dump
runBlocking 线程被占 代码搜索 + 线程 dump
无界缓存 内存单调上升 堆基线趋势
GlobalScope 内存缓涨、连接泄漏 活跃协程数指标
copy() 频繁 分配速率高 alloc 火焰图
装箱 GC 频繁 alloc 看到 Integer.valueOf
Regex 重复编译 CPU 高 cpu 火焰图看到 Pattern.compile
盲目减少分配 代码复杂但无收益 对比 gc.alloc.rate 与 P99
自写无锁 偶发错误或没变快 对比 LongAdder
填充解决伪共享 内存增加但收益小 对比「分片」方案
手动内联 性能反而下降 用 -XX:CompileCommand 对比

五、本节小结

  1. 九条 Kotlin 陷阱中,最严重的三条是:Default 上阻塞调用、synchronized 跨越挂起点、把 Dispatchers.IO 当无限池——它们都会造成「CPU 不高但慢」。
  2. 六条 C++ 直觉误判的核心是:JVM 的分配、锁、布局、线程都与你原来的经验不同。
  3. 最重要的一条直觉修正:「不共享」优于「聪明地共享」。
  4. 但三条 C++ 好直觉仍然成立:局部性、锁竞争限制扩展性、算法复杂度定上限。
  5. 排查时最有用的方法:「症状 → 验证命令」对照表——不要凭印象判断。

六、自测

  1. 一个 Kotlin 服务偶发卡顿,卡顿期间所有接口都慢,CPU 只有 30%。请说出最可能的两个 Kotlin 陷阱,以及各自的验证方法。
  2. 你的 C++ 经验告诉你「应该避免对象分配」。在 JVM 上,这个建议应该怎么修正?
  3. 一个同事说:「为了避免锁竞争,我打算手写一个无锁队列。」你会给出什么建议?
  1. 最可能的两个:① 在 Dispatchers.Default 上做阻塞调用(陷阱 ❶)——8 核的 Default 只有 8 个 worker,被 JDBC/同步 HTTP 占满后,所有使用 Default 的协程排队;② synchronized 跨越挂起点(陷阱 ❷)——挂起时持锁导致其他协程阻塞,且可能造成间歇性死锁。验证方法:① 对第一个:采 wall 火焰图(asprof -e wall),会看到 8 个 Default worker 全停在阻塞调用栈上;同时验证「RUNNABLE 线程数 == CPU 核数」;用 DebugProbes.dumpCoroutines() 看大量协程挂在同一阻塞点;代码搜索 Dispatchers.Default、runBlocking、JDBC 调用。② 对第二个:代码搜索 synchronized 出现在 suspend 函数里;采 lock 火焰图看锁等待;连续多份线程 dump 看是否有 BLOCKED;如果是间歇性问题,观察是否与特定请求路径相关。
  2. 修正为:「在 JVM 上,分配本身很便宜(TLAB 指针碰撞,比一次内存访问还快),真正的成本在 GC。所以优化目标不是『零分配』,而是降低分配速率与缩短对象存活时间。」推论:① 不要为了"少分配"而引入对象池——池的同步成本可能超过分配成本(除大对象/直接内存);② 要看 gc.alloc.rate 和分配火焰图,而不是"数 new 了几次";③ 有些看起来"分配很多"的代码,因为逃逸分析(栈上分配/标量替换)实际上没有堆分配——要实测而不是看代码;④ 用 data class 的 copy() 之类确实会产生分配,但只有当它在热路径上且占 GC 压力大头时才值得优化。
  3. 建议是:先证明现成方案不够快,再考虑自己写。理由:① JVM 上的无锁实现有三个额外障碍——GC 会移动对象(ABA 问题更棘手)、安全点让时序更难推理、JMM 的 volatile 语义比 C++ 更强(很多在 C++ 里成立的精巧设计在 JVM 上要么不合法要么无收益);② 先试现成方案——LongAdder(分片计数器)、ConcurrentHashMap、ConcurrentLinkedQueue 等已经过大量优化与验证;③ 更优先的是"不共享"——分片(按 key 路由到独立资源)、线程封闭、不可变对象,通常比任何锁优化都有效;④ 如果确实要写,必须做两件事:用 JMH 或并发基准证明它比现成方案快(不只是"理论上"),以及在高竞争下做正确性验证。一句话:在 JVM 上,“不共享"通常优于"聪明地共享”。