0.9 从 C++ 到 JVM:哪些直觉必须修正
上一节:0.8 性能工程闭环 | 下一节:0.10 Lab 0:先被骗一次
一句话结论
你从 C++ 并发里练出来的直觉大部分仍然有效,但有 6 条在 JVM 上会给出错误结论。这一节逐条说清:哪些要保留、哪些要推翻、为什么。
一、先说好消息:哪些直觉完全成立
| 你的直觉 | 在 JVM 上是否成立 | 说明 |
|---|---|---|
| 减少内存访问、提高局部性有帮助 | ✅ 成立 | CPU 缓存层级没变,缓存未命中依然昂贵 |
| 伪共享(false sharing)会拖慢性能 | ✅ 成立 | 只是修复手段不同(见下) |
| 锁竞争会限制扩展性 | ✅ 成立 | 且 JVM 上更容易被 GC 与安全点放大 |
| 上下文切换有成本 | ✅ 成立 | 且协程的价值正是把切换成本降下来 |
| 批量处理能提高吞吐 | ✅ 成立 | 代价同样是延迟(见 0.2) |
| 先测量再优化 | ✅ 成立 | 而且比 C++ 更重要(因为 JVM 更难预测) |
| 算法复杂度决定上限 | ✅ 成立 | 任何平台都逃不过 |
所以不要把这一节理解成「C++ 经验没用」——恰恰相反,你的优势在于「知道到底在发生什么」,这是很多只会调参的人不具备的。
二、必须修正的 6 条直觉
❶ 「代码写出来,性能就定了」
- C++ 的现实:编译产物固定,同一段代码第一次调用和第一万次调用性能基本一致。
- JVM 的现实:代码要经历 解释执行 → C1 编译 → C2 编译 三个阶段,性能随调用次数变化。C2 还会根据运行时的类型分布做激进优化(比如把虚调用变成直接调用),一旦类型变了就**去优化(deoptimization)**退回重来。
后果:不预热的测量毫无意义;一次压测的前 30 秒和后 5 分钟是两个不同的系统。
行动:任何性能数据都必须说明「预热了多久」,且预热数据不计入统计。(第 2 章详述)
❷ 「内存由我管理,释放时机确定」
- C++ 的现实:RAII / 析构函数,对象什么时候释放是可预期的。
- JVM 的现实:GC 决定什么时候回收,且回收有停顿。停顿会直接体现在延迟分布的尾部。
后果:
- 你不能靠「及时释放」来控制内存峰值,只能靠减少分配和缩短对象生命周期。
- 真正的关键指标不是「用了多少内存」,而是分配速率(MB/s)——它决定了 GC 的频率。
- 因此「零分配」不是目标(TLAB 分配其实非常快),减少存活对象与晋升才是目标。
行动:把「分配速率」加入你的常规观测指标;用分配火焰图找分配热点。(第 2、6 章)
❸ 「无锁 / 自旋是高性能捷径」
- C++ 的现实:无锁数据结构可控、可预测,是常见的高级优化手段。
- JVM 的现实:三个障碍让它更难更险——
- GC:对象会被移动,指针比较(ABA 问题)更棘手,回收时机不可控;
- 安全点(safepoint):JVM 需要偶尔停下来做全局操作,无锁代码在安全点附近的行为更难推理;
- 内存模型差异:JMM 的
volatile语义比 C++ 的memory_order_relaxed强得多,很多在 C++ 里成立的精巧设计在 Java 内存模型下要么不合法,要么没有收益。
后果:在 JVM 上,「不共享」通常优于「聪明地共享」。分片(按 key 路由到独立资源)、线程封闭(每个线程/协程独立状态)、不可变对象,往往比精心设计的无锁结构更快也更安全。
行动:遇到竞争时的优先顺序是:① 消除共享 → ② 缩小临界区 → ③ 用现成的并发容器 → ④ 最后才考虑自己写无锁。
❹ 「伪共享用填充(padding)解决」
- C++ 的现实:用
alignas(64)手动控制内存布局,效果确定。 - JVM 的现实:对象在堆里的布局由 JVM 决定(还有压缩指针、TLAB、对象头),你能控制的很有限。
@Contended可以做到类似填充,但需要额外的 JVM 参数解除限制,而且会增加内存占用。
后果:JVM 上更可靠的做法是从设计上避免共享,而不是从布局上缓解共享。
行动:优先考虑「有没有必要让它们在同一块内存上」,而不是「怎么把它们分开」。
❺ 「线程越多,吞吐越高」
- C++ 的现实:你也知道线程有成本,但线程数通常受限于任务划分。
- JVM 的现实:线程是操作系统级的(1:1 模型),创建开销在几十微秒量级,切换在微秒量级。更关键的是:线程数超过某一点后,上下文切换、缓存失效、锁竞争会反噬,吞吐反而下降。
后果:这就是 Kotlin 协程的价值——把「线程」和「并发任务」解耦。但协程也不是免费的:它有调度器的并行度上限(Dispatchers.Default 约等于 CPU 核数,Dispatchers.IO 默认 64),超过上限的协程要排队。
行动:区分「CPU 密集型」和「IO 密集型」任务,用不同的调度器,并给并行度设上限(第 2、7 章)。
❻ 「测量工具是中立的」
- C++ 的现实:微基准测纯计算,工具基本不影响被测对象。
- JVM/后端系统的现实:压测工具既是测量仪器也是流量来源,它的行为会被被测系统影响——
- 闭环压测工具会在服务端变慢时减少发压,导致慢样本被隐藏(0.6 协调遗漏);
- 观测工具本身有开销(JFR 约 1%,某些剖析模式更高);
- 日志刷盘、指标采集、探针请求都会消耗被测系统的资源。
后果:测量本身会改变被测量的对象。 这是从「测代码」转向「测系统」时最需要重新建立的认识。
行动:记录观测工具的开销;关键结论用两种工具交叉验证;生产环境优先用低开销的观测方式。
三、一张总表
| # | 你的 C++ 直觉 | JVM 上的现实 | 你要做的调整 |
|---|---|---|---|
| 1 | 性能编译后就固定 | 随预热阶段变化,还有去优化 | 必须预热,报告里写清预热时长 |
| 2 | 手动管理内存,析构确定 | GC 决定回收,有停顿 | 关注分配速率,而非内存占用 |
| 3 | 无锁是高性能捷径 | GC/安全点/内存模型让无锁更险 | 优先「不共享」,而非「聪明地共享」 |
| 4 | 填充解决伪共享 | 对象布局不可控 | 从设计上分离,而非布局上缓解 |
| 5 | 线程越多吞吐越高 | 线程昂贵,且有调度器上限 | 用协程解耦,并给并行度设上限 |
| 6 | 测量工具中立 | 测量会改变被测对象 | 交叉验证,记录观测开销 |
四、为什么这一节决定了第 2 章的位置
本书的章节顺序是:
第 0 章 观念 → 第 1 章 目标 → 第 2 章 JVM 与测量 → 第 3 章 分层 → 第 4 章 工具 …
注意 第 2 章(JVM 与测量)排在所有工具之前。原因就是本节的内容:
- 如果你在第 2 章之前就学会了 k6、Grafana、火焰图,你会用它们产出一堆看起来专业但系统性偏乐观的数据;
- 更糟的是,这些数据会给你错误的信心,让你在错误的方向上投入大量时间。
先学会「怎么确保数据是真的」,再学「怎么采集数据」,最后才学「怎么用工具采集数据」。
五、本节小结
- C++ 的性能直觉大部分成立(局部性、伪共享、锁竞争、批量、先测量),这是你的优势。
- 有 6 条必须修正:预热、GC、无锁、伪共享修复、线程成本、测量中立性。
- 最核心的观念转变:在 JVM 上,「不共享」通常优于「聪明地共享」;在后端系统上,「测量会改变被测对象」。
- 这就是为什么第 2 章必须排在所有工具之前。
六、自测
- 你在 C++ 里习惯用「自旋 + CAS」实现一个高性能计数器。在 JVM 上你会怎么做?为什么?
- 一个同事说:「我们的服务用了 500 个线程,所以能处理 500 个并发请求。」这句话在两个层面上有问题,分别是什么?
- 「测量本身会改变被测量的对象」——这句话在 C++ 微基准里为什么基本不成立,而在后端压测里却很重要?
- 优先考虑不共享:例如用
LongAdder(JDK 内置,内部做了分片以减少竞争),或者用线程本地计数再合并。原因:JVM 上无锁实现受 GC(对象移动、ABA)、安全点、以及更强的volatile语义影响,正确性和性能都更难保证;而LongAdder这类现成结构已经把这些坑处理好了。(如确实需要自己写,也要先用 JMH 证明现成方案不够快。) - ① 线程数 ≠ 并发处理能力:500 个线程如果都在等 IO,那确实能支撑较高并发,但如果任务是 CPU 密集的,500 个线程会在有限的核上疯狂切换,吞吐反而下降。② 线程数与平台不匹配:如果用的是 Kotlin 协程,
Dispatchers.Default的并行度约等于核数(比如 8),Dispatchers.IO默认 64——协程的并发数与线程数不是一回事,说「500 个线程」很可能混淆了这两层。③ 更根本的是:并发能力应该由 Little’s Law 算出来(QPS × 延迟),而不是由线程数决定。 - 因为 C++ 微基准测的是纯计算:被测对象是一个没有内部状态的函数,观测工具(计时器)对它的影响可以忽略,而且它不会因为「等待」而改变行为。后端压测里,工具同时是流量来源:它发的节奏会被服务端的状态影响(闭环工具在服务端变慢时减少发压),观测行为(日志、指标、采样)也会消耗被测系统的资源。一旦被测量对象会对测量行为做出反应,测量结果就不再中立。