伪共享与 CPU 缓存行:一个被忽略的性能杀手
两个线程分别修改互不相干的两个变量,代码里没有任何共享,性能却掉了几倍——问题出在它们恰好落在同一条 64 字节缓存行上。这篇从 MESI 协议讲到 @Contended 与 Disruptor 的填充技巧,并说明它和 CAS 的关系。
有两个线程,各自累加自己的计数器:
class Counters {
volatile long a; // 线程 1 只碰这个
volatile long b; // 线程 2 只碰这个
}
从代码上看,a 和 b 之间没有任何共享、没有竞争,锁也不需要。但实测下来,它的吞吐可能比"每个线程操作一个独立对象"的版本慢好几倍。
原因是内存布局:a 和 b 在内存中相邻,大概率落在同一条 64 字节的 CPU 缓存行上。而 CPU 的缓存一致性协议是以缓存行为单位工作的——修改 a 会让持有 b 的那个核心的缓存副本失效。
这就是伪共享(false sharing):变量之间逻辑无关,却在物理上共享了同一个缓存行,由此产生了本不该存在的竞争。
一、缓存行:内存访问的最小单位
CPU 和主存之间有巨大的速度差:一次主存访问要几百个时钟周期,而一次 L1 命中只要 4~5 个周期。
| 层级 | 典型容量 | 典型延迟 |
|---|---|---|
| L1 Cache | 32~64 KB | 约 4~5 周期 |
| L2 Cache | 256 KB~1 MB | 约 10~20 周期 |
| L3 Cache | 数 MB~数十 MB | 约 40~70 周期 |
| 主存 | 数十 GB | 约 200~400 周期 |
硬件把内存访问的最小单位定为缓存行(cache line),x86 上一行是 64 字节,正好装下 8 个 long(或 16 个 int、8 个对象引用)。这意味着:
你读一个
long(8 字节),CPU 实际会拉一整条 64 字节进来;你改一个long,受影响的也是整条 64 字节。
Java 对象在堆上连续存放,对象头占 12~16 字节,剩下字段依次排下去。所以一个只有两三个 long 字段的小对象,完全可能整个躺在一条缓存行里——这是后面所有问题的物理根源。
二、MESI:代价从哪来
多核 CPU 每个核心都有自己的 L1/L2,同一份数据可能在多个核心的缓存里各有一份副本。硬件用缓存一致性协议保证它们不会读到脏数据,x86 上主流的是 MESI:
| 状态 | 全称 | 含义 | 是否有其他核心持有副本 |
|---|---|---|---|
| M | Modified | 本核心改过,与主存不一致,脏 | 否,独此一份 |
| E | Exclusive | 与主存一致,只有本核心持有 | 否 |
| S | Shared | 与主存一致,可能多个核心都持有 | 可能有 |
| I | Invalid | 已失效,不可用 | — |
关键在于从 S 到 M 的转换。假设核心 A 和 B 的缓存里都有同一行,状态都是 S,此时 A 要写:
1. A 发现自己处于 S 状态,不能直接写
2. A 向总线发送 Invalidate 消息
3. B 收到后,把自己那份置为 I(失效)
4. B 回送 Invalidate Acknowledge
5. A 收到全部确认后,状态转 M,才真正写入
一整套总线通信 + 等待响应的流程,比"直接改自己独占的一行"高出一两个数量级。而如果两个核心反复交替写同一行,这条行就会在两边被来回抢夺所有权,不断在 S 和 M 之间翻转——这就是缓存行乒乓(cache line ping-pong),每一次往返都是一次一致性事务,CPU 流水线只能干等。
还有一个细节值得单独记住:写操作在硬件上是「读-改-写」。写一个变量前必须先拿到这条缓存行(Read For Ownership),所以纯写操作也会引发一次内存读取——这正是伪共享代价高的直接原因。
三、伪共享是怎么发生的
回到开头的例子。Counters 对象只有两个 long 字段,加上 12 字节对象头不到 32 字节,整个对象躺在一条缓存行里。线程 1 写 a,线程 2 写 b,逻辑上零交互,但因为同处一行:
| 时刻 | 线程 1(核心 0) | 线程 2(核心 1) | 缓存行状态 |
|---|---|---|---|
| T1 | 写 a |
核心 0: M,核心 1: I | |
| T2 | 写 b → 需要 RFO |
核心 1: M,核心 0: I | |
| T3 | 写 a → 需要 RFO |
核心 0: M,核心 1: I | |
| T4 | 写 b → 需要 RFO |
核心 1: M,核心 0: I |
两边都在忙着抢行。明明没有共享,却被硬件当成了共享——这就是"伪"字的含义。容易踩中的有两类:同一个类里有多个被不同线程更新的字段,以及 volatile long[] 数组里每个线程操作一个下标——数组元素在内存中紧密排列,相邻下标必然同行。
四、量化一下代价
让 4 个线程各自累加自己的变量,对比两种布局:一种是 4 个计数变量紧挨着(Packed),另一种是目标变量前后各填 7 个 long、保证独占一条缓存行(Padded),每线程累加 1 亿次。在 4 核 8 线程的机器上,典型结果大致是这个形状:
| 版本 | 4 线程总耗时 | 相对倍数 | 说明 |
|---|---|---|---|
| 单线程(基线) | 约 0.4s | 1× | 无竞争,参考值 |
| Packed(相邻变量) | 约 12s | 约 30× | 4 个线程互相抢行 |
| Padded(独占缓存行) | 约 1.6s | 约 4× | 接近理想的线性加速 |
注意这里的对比方式:不是"填充比不填充快 30 倍",而是——
- 不填充时,4 个线程总耗时是单线程的 30 倍,也就是说加了 3 个线程反而慢了好几倍;
- 填充之后,4 线程总耗时降到单线程的 4 倍,接近线性扩展。
数字在不同机器上会差很多,但方向是一致的:不填充时加线程几乎不涨吞吐,甚至倒退。加机器、加线程都没用,因为瓶颈根本不在计算量上。
定位伪共享的手段:
| 手段 | 方式 | 看什么 |
|---|---|---|
| cache-to-cache 分析 | perf c2c record + report |
直接标出哪条缓存行、哪个地址在被多个核心争抢 |
| HITM 计数 | perf c2c 里的 HITM 指标 |
HITM = 命中已修改行,是伪共享的强信号 |
| 线程数扩展性测试 | 1/2/4/8 线程分别压测 | 加线程吞吐不涨反降 → 优先怀疑伪共享或锁竞争 |
五、怎么解决
方案一:手工填充
在目标变量前后各塞 7 个 long,把它"顶"到独占的一条缓存行上:
static final class Padded {
volatile long p0, p1, p2, p3, p4, p5, p6; // 前置填充:7 × 8 = 56 字节
volatile long value; // 真正要用的变量
volatile long q0, q1, q2, q3, q4, q5, q6; // 后置填充
}
为什么是 7 个? 一条缓存行 64 字节,一个 long 是 8 字节,64 / 8 = 8,填满一行需要 8 个 long。目标变量自己占 1 个,所以前后填 7 个就能把它"孤立"出来。Linux 内核里到处是 ____cacheline_aligned_in_smp 这样的宏。
隐患: JVM 不保证字段按声明顺序排列——为了减少内存空洞,HotSpot 会重新排列字段(把相同类型、相同大小的排在一起)。所以手写的填充可能被 JVM 打乱而失效。
方案二:@Contended
为了可靠地做这件事,Java 提供了 @Contended 注解,由 JVM 在字段布局阶段插入填充:
import jdk.internal.vm.annotation.Contended;
public class Counters {
@Contended
volatile long a; // JVM 保证这个字段独占缓存行
@Contended
volatile long b;
}
| 项 | 说明 |
|---|---|
| 包名 | JDK 8 在 sun.misc.Contended;JDK 9+ 移到 jdk.internal.vm.annotation.Contended |
| 默认填充宽度 | 128 字节(比 64 更保守,因为部分 CPU 会成对预取缓存行) |
| 调整宽度 | -XX:ContendedPaddingWidth=<n> |
| 用户代码启用 | 需要加 -XX:-RestrictContended,否则注解只在 JDK 内部生效 |
| 加在类上 | 表示整个类前后加填充 |
代价是内存占用变大。一个统计对象被填到 128 字节以上,几十万个这样的对象就是几十 MB 的纯浪费。所以 @Contended 只该加在被高频写入的热点字段上。
**JDK 自己就在用这个机制:**Thread 类里的 threadLocalRandomSeed、threadLocalRandomProbe 等字段都标了 @Contended——它们被每个线程频繁更新,如果挨在一起,所有线程都会互相抢行;LongAdder 的内部类 Cell 也标了 @Contended,因为 LongAdder 的思路本来就是"把计数分散到多个 Cell 上消除竞争",如果这些 Cell 因为内存相邻而共享缓存行,整个设计就白费了。
这也解释了 LongAdder 为什么能在高并发下远胜 AtomicLong:分散热点和隔离缓存行是配套的两件事,缺一个都不成立。
方案三:改数据布局
很多时候不用填充也能解决,直接把布局改掉就行:数组按 threadId 索引改为每线程一个独立对象、多个线程各自更新的统计字段拆到不同类里、用 ThreadLocal 替代手工索引。
"分散热点"和"隔离缓存行"是同一个问题的两面。 只要多个线程高频写的数据在物理上挨着,无论是不是同一个变量,都会撞上缓存一致性协议。
六、Disruptor 的填充技巧
Disruptor 是一个高性能的内存队列。它的 ring buffer 上,每个生产者和每个消费者都各自维护一个 Sequence,记录自己处理到哪儿了。这些 Sequence 会被各自的线程独立更新,但它们在堆上很可能挨在一起——生产者写自己的序号会不断让消费者的序号副本失效。所以 Disruptor 的 Sequence 类用了经典的两侧填充:
class LhsPadding {
protected long p1, p2, p3, p4, p5, p6, p7; // 左侧 7 个 long
}
class Value extends LhsPadding {
protected volatile long value; // 真正被读写的序号
}
class RhsPadding extends Value {
protected long p9, p10, p11, p12, p13, p14, p15; // 右侧 7 个 long
}
public class Sequence extends RhsPadding { /* ... */ }
用继承做成三层,是为了让填充字段在布局上稳定地排在 value 两侧——比写在同一个类里更不容易被 JVM 的字段重排打乱。
除了填充,Disruptor 还有几处"顺着硬件来"的设计:
| 设计 | 解决的问题 |
|---|---|
| 序号两侧填充 | 生产者和消费者的序号互不干扰,各自独占缓存行 |
| 预分配 ring buffer 槽位 | 数组一次性分配好,避免运行时分配和 GC,内存连续利于预取 |
| 序号递增而非删除 | 用 sequence++ 定位槽位,不需要清理旧数据,指针移动即可 |
| 无锁 CAS 发布 | 生产者用 CAS 抢占槽位,消费者各自维护序号,不阻塞彼此 |
| 批量消费 | 一次读取一批可用槽位,减少对序号变量的反复读写,也就减少了一致性流量 |
这些设计是配套的:无锁化消除了锁竞争,缓存行填充消除了隐式的缓存竞争,预分配消除了 GC 干扰。 只做其中一项,收益会大打折扣——比如做了无锁却留下伪共享,性能可能还不如直接加锁。
七、为什么它和 CAS、并发编程强相关
伪共享不是"底层优化的小技巧",它直接决定了上层并发工具的性能上限。
交汇点一:CAS 本身就依赖缓存行独占
CAS 在 x86 上对应 LOCK CMPXCHG 指令。现代 CPU 执行 LOCK 前缀时,不是锁整个内存总线,而是锁住那条缓存行——具体做法就是通过 MESI 拿到该行的独占所有权。所以:
LOCK CMPXCHG的开销 = 拿到缓存行所有权的开销 + 比较交换本身的开销。
| 情形 | 缓存行状态 | CAS 开销 |
|---|---|---|
| 变量被一个核心独占 | 命中 M/E 状态 | 低,几乎只花指令本身的时间 |
| 变量被多个核心反复 CAS | S/M 之间来回翻转 | 高,每次都要一致性事务 |
同一套逻辑也出现在 ConcurrentHashMap 的计数上:JDK 8 的 size() 用 baseCount + CounterCell[] 分散热点,而 Cell 上标了 @Contended。前半句是减少争用,后半句是隔离缓存行,两个机制一起才让高并发计数变得可行。
交汇点二:JMM 的可见性规则,物理基础就是 MESI
volatile 保证可见性,在硬件层的含义是:写时在前后插入内存屏障,确保这条缓存行被正确发布(M 状态)并阻止指令重排;读时确保不从过期的本地副本里读。
MESI 正是"最新的写入"这套语义的物理载体。至于为什么需要内存屏障,则和 CPU 的**写缓冲(store buffer)**有关:为了让写操作不必等待一致性确认,CPU 会把写放进缓冲区里先继续跑,由此产生了内存重排序——这也是 Java 内存模型要定义 happens-before 的根本原因。
Java 内存模型 (happens-before / volatile)
↓ 编译为
内存屏障指令 (LoadLoad / StoreStore / LoadStore / StoreLoad)
↓ 硬件执行
MESI 缓存一致性 + 写缓冲
从 volatile 到 MESI,是同一条语义链的不同层级。
交汇点三:它解释了"为什么加线程不涨吞吐"
性能调优里最常见的困境是:CPU 没跑满、锁也没有竞争,但加线程吞吐就是上不去。
| 现象 | 常见猜测 | 真实原因 |
|---|---|---|
| 加线程吞吐不涨 | 锁竞争 | 可能是伪共享,代码里根本没锁 |
| CPU 使用率高但 TPS 低 | 计算量大 | 可能大量时间花在一致性事务上 |
| 无锁化改造后提升有限 | 无锁化没做好 | 可能热点数据在缓存行上仍然互踩 |
synchronized 换 CAS 反而更慢 |
CAS 不适合该场景 | 可能冲突激烈,CAS 空转 + 抢行双重开销 |
排查顺序建议:先看锁竞争,再看缓存行,最后才考虑算法。
小结
- 硬件的内存访问单位是缓存行,x86 上为 64 字节;读写一个
long也会拉起整行 - 伪共享指逻辑无关的变量落在同一缓存行上,由缓存一致性协议制造出本不存在的竞争
- MESI 四状态(Modified / Exclusive / Shared / Invalid)是代价的来源:从 S 转 M 需要发 Invalidate 并等待所有核心确认;反复交替写同一行会造成缓存行乒乓
- 写操作在硬件上是「RFO + 改」,纯写也要先读一次,这是伪共享代价高的直接原因
- 代价可以量化:多线程写相邻变量时总耗时可达单线程的 30 倍,填充到独占缓存行后降到 4 倍——加线程反而变慢是最典型的信号
- 定位工具是
perf c2c,重点看 HITM 指标和被多核心争抢的缓存行 - 解决有三条路:手工填充(7 个
long,但可能被 JVM 字段重排打乱)、@Contended(官方方案,默认 128 字节,用户代码需要-XX:-RestrictContended)、改数据布局 - JDK 自己在用这套机制:
Thread的随机数种子字段和LongAdder.Cell都标了@Contended - Disruptor 的序号类用两侧各 7 个
long做填充,配合预分配 ring buffer 和无锁序号推进——无锁化与缓存行隔离必须配套 - 它和并发编程的交汇点在于:CAS 的底层是
LOCK指令锁缓存行,所以多线程 CAS 同一个变量本质上是在乒乓缓存行——这就是LongAdder分散热点 +Cell填充要一起做的原因 - JMM 的可见性语义物理上由 MESI 承载:
volatile→ 内存屏障 → 缓存一致性协议,是同一条语义链的三个层级
这类问题的共同特征是沉默:不报错、不告警,监控上只看得到一个"性能不太行"的结果,而代码通读一遍完全挑不出毛病。但只要记住"多个线程高频写的数据不能在物理上挨着"这一条,很多莫名其妙的性能问题就有了排查方向。
留下点什么
评论基于 GitHub Discussions(giscus)。在项目根目录配置.env.local填入 NEXT_PUBLIC_GISCUS_* 四个变量后自动开启。