什么是伪共享?如何避免?
结论先行:伪共享(false sharing)指多个线程修改位于同一个缓存行(cache line,通常 64 字节)里的不同变量。虽然逻辑上互不相干,但缓存一致性协议以"行"为单位同步,一个线程的写会让另一个线程的缓存副本失效,导致缓存行在核心之间来回弹跳,性能骤降。
1. 问题长什么样
一个缓存行 = 64 字节
[ x (8B) ][ y (8B) ][ 其余 48 字节 ]
▲ ▲
T1 写 x T2 写 y
两者在同一行 → 一方的写会让另一方的缓存副本失效
→ 缓存行在核心之间来回弹跳 → 反复从内存重载 → 性能下降数倍
对比一下真共享(true sharing):多个线程读写同一个变量,那是业务上必须的同步,无法避免。伪共享则是"本可以避免的意外"——变量根本无关,只是恰好挤在了一起。
2. 三种解决手段
| 手段 | 做法 | 说明 |
|---|---|---|
| 手动填充 | 在变量前后补 7 个 long(56 字节) | 经典做法,可移植性最好 |
@Contended | JDK 内部注解,自动填充 | 默认只对 JDK 内部类生效,需 -XX:-RestrictContended |
| 改变布局 | 把热变量拆到不同对象/数组槽位 | 从设计上避免同行 |
3. 代码:填充前后对比
// 反面示例:两个变量极可能落在同一缓存行
final class Counter {
volatile long a = 0; // 8 字节
volatile long b = 0; // 紧邻,同一行
}
// 正面示例:各自独占缓存行
final class PaddedCounter {
volatile long a = 0;
long p1, p2, p3, p4, p5, p6, p7; // 7 × 8 = 56 字节填充
volatile long b = 0;
long q1, q2, q3, q4, q5, q6, q7;
}
JDK 8 之后也可以直接用注解(注意它是内部 API,且默认受限):
import jdk.internal.vm.annotation.Contended;
@Contended
static final class Cell {
volatile long value;
}
// 启动参数:--add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED
// 或者 -XX:-RestrictContended 让注解对用户类生效
4. 框架里真实的应用
| 场景 | 做法 | 效果 |
|---|---|---|
LongAdder.Cell | @Contended 填充 | 保证每个 Cell 独占缓存行,分段才有意义 |
Thread 的字段 | 内部填充 | 避免不同线程的字段互相干扰 |
Disruptor 的 Sequence | 手动填充 7 个 long | 无锁队列能跑到千万级 ops/s 的关键之一 |
ConcurrentHashMap 的 CounterCell | 继承 @Contended 填充类 | 高并发 size 统计不退化 |
LongAdder 是最典型的例子:既然设计目标是"把热点打散到多个 Cell",如果 Cell 之间还共享缓存行,等于白拆——所以必须填充。
5. 常见追问
追问一:为什么是 64 字节?
主流 x86/ARM 的缓存行大小是 64 字节。部分 CPU 支持 128 字节预取,所以某些高性能库会填充 128 字节。写代码时通常按 64 字节保守处理,或直接用 @Contended 让 JVM 决定。
追问二:填充会不会浪费内存? 会。每个对象多 56~120 字节。所以只在确定的高频竞争点上用,普通业务对象填充只会增加 GC 压力。
追问三:怎么知道发生了伪共享?
可以用 perf c2c(cache-to-cache)分析缓存行弹跳;或者做对照实验:加填充前后跑同一段压测,吞吐差异明显(常见 2~5 倍)就说明命中了。
追问四:伪共享和缓存行对齐是一回事吗? 相关但不同:对齐是"让变量落在缓存行边界上",填充是"让变量独占一整行"。要彻底避免伪共享,两者都要做——填充保证不共享,对齐保证不跨行。
追问五:所有场景都值得优化伪共享吗? 不值得。只有当多个核心在极短时间内反复写同一缓存行的不同变量时才明显。普通业务代码里,一次数据库调用的耗时远超这点开销。
小结
伪共享的本质是"缓存一致性以行为单位,而你的变量以字节为单位"。三种解法:填充、@Contended、改布局。判断要不要做只有一个标准——这个字段是不是每秒被多个核心写几百万次。