文档目录

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 的现实:三个障碍让它更难更险——
    1. GC:对象会被移动,指针比较(ABA 问题)更棘手,回收时机不可控;
    2. 安全点(safepoint):JVM 需要偶尔停下来做全局操作,无锁代码在安全点附近的行为更难推理;
    3. 内存模型差异: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、火焰图,你会用它们产出一堆看起来专业但系统性偏乐观的数据;
  • 更糟的是,这些数据会给你错误的信心,让你在错误的方向上投入大量时间。

先学会「怎么确保数据是真的」,再学「怎么采集数据」,最后才学「怎么用工具采集数据」。


五、本节小结

  1. C++ 的性能直觉大部分成立(局部性、伪共享、锁竞争、批量、先测量),这是你的优势。
  2. 有 6 条必须修正:预热、GC、无锁、伪共享修复、线程成本、测量中立性。
  3. 最核心的观念转变:在 JVM 上,「不共享」通常优于「聪明地共享」;在后端系统上,「测量会改变被测对象」。
  4. 这就是为什么第 2 章必须排在所有工具之前。

六、自测

  1. 你在 C++ 里习惯用「自旋 + CAS」实现一个高性能计数器。在 JVM 上你会怎么做?为什么?
  2. 一个同事说:「我们的服务用了 500 个线程,所以能处理 500 个并发请求。」这句话在两个层面上有问题,分别是什么?
  3. 「测量本身会改变被测量的对象」——这句话在 C++ 微基准里为什么基本不成立,而在后端压测里却很重要?
  1. 优先考虑不共享:例如用 LongAdder(JDK 内置,内部做了分片以减少竞争),或者用线程本地计数再合并。原因:JVM 上无锁实现受 GC(对象移动、ABA)、安全点、以及更强的 volatile 语义影响,正确性和性能都更难保证;而 LongAdder 这类现成结构已经把这些坑处理好了。(如确实需要自己写,也要先用 JMH 证明现成方案不够快。)
  2. ① 线程数 ≠ 并发处理能力:500 个线程如果都在等 IO,那确实能支撑较高并发,但如果任务是 CPU 密集的,500 个线程会在有限的核上疯狂切换,吞吐反而下降。② 线程数与平台不匹配:如果用的是 Kotlin 协程,Dispatchers.Default 的并行度约等于核数(比如 8),Dispatchers.IO 默认 64——协程的并发数与线程数不是一回事,说「500 个线程」很可能混淆了这两层。③ 更根本的是:并发能力应该由 Little’s Law 算出来(QPS × 延迟),而不是由线程数决定。
  3. 因为 C++ 微基准测的是纯计算:被测对象是一个没有内部状态的函数,观测工具(计时器)对它的影响可以忽略,而且它不会因为「等待」而改变行为。后端压测里,工具同时是流量来源:它发的节奏会被服务端的状态影响(闭环工具在服务端变慢时减少发压),观测行为(日志、指标、采样)也会消耗被测系统的资源。一旦被测量对象会对测量行为做出反应,测量结果就不再中立。