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 对比 |
五、本节小结
- 九条 Kotlin 陷阱中,最严重的三条是:Default 上阻塞调用、
synchronized跨越挂起点、把Dispatchers.IO当无限池——它们都会造成「CPU 不高但慢」。 - 六条 C++ 直觉误判的核心是:JVM 的分配、锁、布局、线程都与你原来的经验不同。
- 最重要的一条直觉修正:「不共享」优于「聪明地共享」。
- 但三条 C++ 好直觉仍然成立:局部性、锁竞争限制扩展性、算法复杂度定上限。
- 排查时最有用的方法:「症状 → 验证命令」对照表——不要凭印象判断。
六、自测
- 一个 Kotlin 服务偶发卡顿,卡顿期间所有接口都慢,CPU 只有 30%。请说出最可能的两个 Kotlin 陷阱,以及各自的验证方法。
- 你的 C++ 经验告诉你「应该避免对象分配」。在 JVM 上,这个建议应该怎么修正?
- 一个同事说:「为了避免锁竞争,我打算手写一个无锁队列。」你会给出什么建议?
- 最可能的两个:① 在
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;如果是间歇性问题,观察是否与特定请求路径相关。 - 修正为:「在 JVM 上,分配本身很便宜(TLAB 指针碰撞,比一次内存访问还快),真正的成本在 GC。所以优化目标不是『零分配』,而是降低分配速率与缩短对象存活时间。」推论:① 不要为了"少分配"而引入对象池——池的同步成本可能超过分配成本(除大对象/直接内存);② 要看
gc.alloc.rate和分配火焰图,而不是"数 new 了几次";③ 有些看起来"分配很多"的代码,因为逃逸分析(栈上分配/标量替换)实际上没有堆分配——要实测而不是看代码;④ 用data class的copy()之类确实会产生分配,但只有当它在热路径上且占 GC 压力大头时才值得优化。 - 建议是:先证明现成方案不够快,再考虑自己写。理由:① JVM 上的无锁实现有三个额外障碍——GC 会移动对象(ABA 问题更棘手)、安全点让时序更难推理、JMM 的
volatile语义比 C++ 更强(很多在 C++ 里成立的精巧设计在 JVM 上要么不合法要么无收益);② 先试现成方案——LongAdder(分片计数器)、ConcurrentHashMap、ConcurrentLinkedQueue等已经过大量优化与验证;③ 更优先的是"不共享"——分片(按 key 路由到独立资源)、线程封闭、不可变对象,通常比任何锁优化都有效;④ 如果确实要写,必须做两件事:用 JMH 或并发基准证明它比现成方案快(不只是"理论上"),以及在高竞争下做正确性验证。一句话:在 JVM 上,“不共享"通常优于"聪明地共享”。