文档目录

2.6 并发原语成本与伪共享

上一节:2.5 内存与 GC | 下一节:2.7 协程的真实开销模型 配套代码:02-jvm-performance-basics/06-concurrency-primitives


一句话结论

无竞争的锁很便宜(几十纳秒),有竞争的锁非常贵(可能几百纳秒到微秒级)——差距是数量级的。 而且加线程数超过某个点后,吞吐会下降:因为上下文切换、缓存失效和锁竞争的成本超过了并行带来的收益。


一、用共享打印机理解并发成本

办公室里有一台打印机,你和同事都要用:

情况 你的体验 技术含义
没人在用,你直接印 走过去就能印,几秒钟 无竞争锁:几十纳秒
同事正在印,你等他打完 排队等待 有竞争锁:排队时间可能很长
你们都想改同一份文档 用「先检查版本,再提交」的方式,冲突就重试 CAS(比较并交换)
你刚坐下准备干活,又被叫去开会,回来还得重新进入状态 效率损失 上下文切换:还要付出「缓存失效」的代价
你和同事的笔记本放在同一个抽屉里,他每次开抽屉你都得停一下 明明没共享数据,却互相干扰 伪共享(false sharing)

注意最后一条:这就是伪共享——你们没有共享任何数据,但因为数据在内存里离得太近,硬件层面的缓存同步把你们绑在了一起。


二、各种操作的成本量级

⚠️ 下面都是量级参考,随 CPU、JDK 版本、竞争程度变化很大。请用配套代码在自己的机器上测一遍——这个练习的价值远大于记住数字。

操作 量级 说明
一次 L1 缓存访问 ~1 ns 基准
无竞争的 synchronized 十几到几十 ns 现代 JVM 的偏向锁/轻量级锁优化得很好
CAS 操作 十几 ns AtomicInteger.incrementAndGet() 无竞争时
有竞争的锁 几百 ns ~ 微秒级 差距是数量级的,且有排队
volatile 写 几十 ns 需要内存屏障
线程创建 几十微秒 比一次普通操作贵 1000 倍
一次上下文切换 1–5 微秒 加上缓存失效的间接成本会更多
协程挂起/恢复 纳秒到数百纳秒 比线程切换便宜几个数量级
伪共享导致的额外延迟 可达百纳秒级 取决于访问模式与竞争强度

从这张表能读出三个结论:

  1. 锁的成本主要不在「加锁」,而在「竞争」。 无竞争锁只要几十纳秒,可以放心用;真正的问题是多人抢。
  2. 线程很贵(创建几十微秒),所以「一万个请求开一万个线程」在任何平台都不成立。这正是协程的价值(下一节)。
  3. 协程不是免费的,但比线程便宜几个数量级。

三、为什么加线程反而变慢

这是一个经典现象:把线程数从 8 加到 32,吞吐不升反降。三个原因:

原因一:上下文切换成本

线程数超过 CPU 核数后,操作系统需要轮流执行它们。每次切换要保存/恢复寄存器、更新调度结构,而且会导致 CPU 缓存失效——新线程要重新把自己的数据加载进缓存。

8 核跑 8 线程:每个线程独占一核,没有切换开销
8 核跑 32 线程:每个核上要轮转 4 个线程,切换频繁,缓存反复失效

原因二:锁竞争加剧

线程越多,抢同一把锁的人越多。而且竞争是非线性的:从 2 个线程抢变成 8 个线程抢,等待时间不只是涨 4 倍。

原因三:伪共享

多个线程访问同一缓存行里的不同变量时,硬件必须保证缓存一致性,导致缓存行在两个核之间来回「弹跳」(cache line ping-pong)。

// ❌ 两个线程分别高频写这两个变量,它们在同一缓存行(通常 64 字节)里
class Counters {
    @Volatile var a = 0L     // 和 b 挨着
    @Volatile var b = 0L
}
// 线程 1 只写 a,线程 2 只写 b —— 但它们互相拖慢

JVM 上的修法(优先顺序很重要):

方法 说明
① 从设计上避免共享(推荐) 分片、线程封闭、不可变对象——把数据真正分开,而不是缓解
② 用现成的分片结构 例如 LongAdder(内部按线程分片,最后合并),比 AtomicLong 在竞争下快得多
② 填充(@Contended) 需要加 JVM 参数 -XX:-RestrictContended,且会增加内存占用

记住第 0 章的那句话:在 JVM 上,「不共享」优于「聪明地共享」。原因就是这里——对象布局由 JVM 决定,你能做的缓解有限;但从设计上消除共享,效果是确定的。


四、安全点(Safepoint):一个 JVM 特有的停顿来源

这是 C++ 里没有的概念。JVM 需要偶尔让所有线程停在「安全点」上,才能做某些全局操作(如 GC 根扫描、偏向锁撤销、去优化)。

安全点带来的两个现象:

现象 说明
安全点停顿 线程必须跑到安全点才能停;如果某个线程在长循环里跑很久不到安全点,所有线程都得等它
安全点偏置 计数循环(for (i in 0 until n))里通常有安全点轮询;但如果循环体内没有安全点轮询点,可能造成「长时间不到安全点」

实际的工程影响:如果你的代码里有超长的无调用循环(比如一次遍历几亿个元素、内部不做任何方法调用),它可能推迟 GC,导致其他线程的延迟尖刺。

缓解:在长循环里定期调用一个「安全点友好」的方法(或让循环体里有方法调用)。但不要过早优化这个——先用 JFR 的 jdk.SafepointBegin 事件确认它真的是问题。


五、数量级直觉:一个实用对照表

给「该用哪种并发方案」一个粗判断:

需求 方案 理由
简单的计数器 LongAdder(竞争高)/ AtomicLong(竞争低) 分片避免伪共享与竞争
保护一小段临界区 synchronized / ReentrantLock 无竞争时很便宜,别怕用
读多写少 不可变对象 + volatile 引用 完全避免锁
大量 IO 并发 协程(不是线程) 线程创建几十微秒,协程纳秒级
CPU 密集并行计算 固定大小的线程池(≈ 核数) 超过核数只会增加切换开销
跨线程共享状态 先问能不能不共享(分片/封闭) 这比任何锁优化都有效

六、本节小结

  1. 无竞争锁很便宜,有竞争的锁很贵——成本差异是数量级的,所以「用了锁」不是问题,「大家抢同一把锁」才是。
  2. 线程创建(几十微秒)和上下文切换(微秒级)都很贵,这是协程存在的理由。
  3. 加线程超过核数后吞吐可能下降:上下文切换、锁竞争、伪共享三件事一起反噬。
  4. 伪共享在 JVM 上依然存在,但优先用「设计上不共享」来解决,而不是填充。
  5. 安全点是 JVM 特有的停顿来源,超长无调用循环可能推迟 GC。
  6. 遇到竞争时的优先顺序:消除共享 → 缩小临界区 → 用现成的分片结构 → 最后才考虑自己写无锁。

七、自测

  1. 一个服务从 8 线程加到 32 线程后,QPS 从 12000 降到 9000,同时 sys CPU 时间和上下文切换次数都大幅上升。请解释原因,并说出你会先查什么。
  2. 两个线程分别高频更新同一个对象的两个不同字段(a 和 b),它们没有逻辑上的共享。为什么性能比「各自更新自己的对象」差很多?怎么修?
  3. 为什么「协程比线程便宜」?请从「创建成本」和「切换成本」两个角度回答,并说明协程的代价是什么。
  1. 原因:线程数远超 CPU 核数(假设 8 核),导致 ① 上下文切换频繁——sys CPU 时间上升正是切换(内核态操作)的典型信号;② 锁竞争加剧——更多线程抢同一批资源,等待时间非线性增长;③ 缓存失效——每个线程被切回来时要重新加载工作集。先查什么:① pidstat -w 看上下文切换次数;② 线程 dump 看有多少线程停在 BLOCKED(锁竞争)或 WAITING(池/队列);③ 是否有共享的可变状态(尤其计数器、缓存、连接池);④ 调小线程数做对照实验,确认拐点在哪。
  2. 因为 伪共享:a 和 b 通常位于同一个缓存行(64 字节),两个核心各自修改自己那份缓存行副本时,缓存一致性协议(MESI)必须让对方的副本失效,导致该缓存行在两个核之间反复「弹跳」。修法(按优先级):① 从设计上分开——让每个线程操作自己独立的对象/数组元素(比如分片计数器,每个线程写自己的槽位,最后合并);② 用 LongAdder 这类已经做了分片的结构;③ 实在需要相邻字段时用 @Contended 填充(需 -XX:-RestrictContended,代价是内存占用增加)。
  3. 创建成本:线程是操作系统级资源(1:1 模型),创建需要几十微秒并分配栈空间(默认约 1 MB 虚拟内存);协程是用户态对象,创建一个协程只是分配一个小对象,量级是纳秒到微秒。切换成本:线程切换要进内核、保存/恢复寄存器与栈,1–5 微秒,且伴随缓存失效;协程切换只是在用户态保存/恢复状态机(Continuation),量级是纳秒到数百纳秒。代价:① 协程有调度器并行度上限(Dispatchers.Default ≈ CPU 核数),超过就排队;② 阻塞调用不能直接放在协程里(会占住调度线程,见下一节);③ 高频挂起会产生 continuation 对象分配,带来额外 GC 压力;④ 调试更难(协程栈不是原生栈)。