两个线程各写各的变量,为什么互相拖慢
两个线程,各自对自己的计数器做一亿次自增,没有共享任何变量,也没有锁。两个线程同时跑,每次自增却要 41 纳秒,一个线程单独跑只要 7 纳秒。把两个计数器在内存里挪开一百多个字节,又回到 7 纳秒。
代码层面这两个线程毫无关系,问题出在代码看不见的地方:两个变量在内存里挨着,落在了同一个缓存行里。这种现象叫伪共享(false sharing)。它不影响正确性,只影响速度,而且从代码、线程转储、锁竞争统计里都看不出来。
本文在 JDK 25.0.4 上测量它的大小,说明原因和隔开变量的几种做法,以及什么情况下值得为它动手。实验见文末。
一、测出来的差别
每个线程对自己的 volatile long 做 1 亿次 getAndAdd(通过 VarHandle),比较两个变量位置不同时每次操作的耗时。
@Contended 或足够宽的手工填充隔开之后回到 7 纳秒,与单线程相同。数组的相邻元素同样如此。倍数随运行与硬件波动,脚本只断言相邻字段比隔开的慢 3 倍以上。几个要点:
- 相邻字段(对象内偏移 16 和 24)是单线程的 5.9 倍。两个线程一起跑,完成同样的工作量花的时间比一个线程依次做完两份还长。
@Contended与两侧各 120 字节的手工填充,两个线程都和单线程一样快。- 数组的相邻元素和相邻字段是同一回事:
AtomicLongArray的第 16、17 个元素 62.7 纳秒,第 16、48 个元素 7.1 纳秒。「每个线程一个槽位」的统计数组正是这种布局。 - 两侧各 56 字节的填充让两个字段相距 64 字节,结果是 11.1 纳秒,比完全隔开慢约一半。这一点在第三节再说。
这是计时实验,倍数会随运行和硬件变化。实验脚本只断言「相邻字段比隔开的慢 3 倍以上」,图中是归档的那一次运行的数字。
二、缓存行是共享的最小单位
CPU 的缓存不按变量管理数据,而是按固定大小的块,叫缓存行,常见的大小是 64 字节。读一个 long,它所在的整行都会被装进这个核的缓存。
多个核的缓存里可以同时有同一行的副本,只要大家都只读。一旦某个核要写这一行里的任何一个字节,它必须先取得这一行的独占权,其他核手里的副本随之作废;别的核下次再访问这一行(哪怕访问的是行里的另一个变量),要重新从拥有它的核那里取过来。
a 和 b 在同一行时,两个线程虽然各写各的变量,这一行却要在两个核之间来回转移,每次写入都可能要等。把两个变量隔到不同的行,两个核各自独占自己的那一行,互不打扰。两个线程分别写 a 和 b,而 a、b 在同一行里时,这一行就在两个核之间来回转移。每次转移是一次核间的通信,比一次普通的缓存命中慢得多。从硬件的角度看,这和两个线程写同一个变量没有区别,所以叫「伪」共享:逻辑上没有共享,物理上共享了。
只读的线程也会被拖慢。 实验里一个线程只读字段 b,另一个线程写它旁边的 a:
| 场景 | 读者每次读取 |
|---|---|
| 单独读 | 0.57 ns |
| 旁边的字段被另一个线程写 | 1.94 ns |
写者的字段用 @Contended 隔开 | 0.57 ns |
读者手里的那一行不断被写者作废,每隔一会儿就要重新取一次。一个被所有请求线程读取的配置引用、一个数组的长度字段,如果旁边恰好是一个被频繁写的计数器,读的一方就会莫名其妙地变慢。
三、把变量隔开
手工填充。 在变量前后放若干个无用的 long,把它推到独立的行里。直接在同一个类里声明填充字段不可靠,JVM 可以重新排列同一个类的字段;通常的写法是利用继承,父类的字段排在子类字段之前:
class PadLeft { long p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, p15; }
class Value extends PadLeft { volatile long value; }
class Counter extends Value { long q1, q2, q3, q4, q5, q6, q7, q8, q9, q10, q11, q12, q13, q14, q15; }@Contended。 JDK 内部用 jdk.internal.vm.annotation.Contended 标注需要隔离的字段或类,JVM 在布局时自动在前后留出空白。LongAdder 的每个槽(Striped64.Cell)、Thread 里线程本地随机数的几个字段、ForkJoinPool 的工作队列、ConcurrentHashMap 的计数槽都用了它。应用代码要用,有两道门槛:
- 这个注解在
java.base的内部包里,编译和运行都要加--add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED; - JVM 默认只认 JDK 自己的类上的
@Contended(-XX:+RestrictContended)。不加-XX:-RestrictContended时,实验里标了注解的两个字段偏移仍然是 16 和 24,和没标一样,而且没有任何警告。
打开开关之后,两个字段的偏移是 144 和 280。-XX:ContendedPaddingWidth 的默认值是 128,是常见缓存行大小的两倍。
为什么不是 64 字节。 缓存行的大小不是处处相同,而且同一台机器上不同的接口报告的数值也可能不一致:这次实验的宿主机(Apple M1 Max)报告 128 字节,容器里的 getconf 报告 64。实验里用数组做了一次扫描,16 个不同的起始位置上,相距 64 字节的元素对没有一对出现相邻字段那样的大幅变慢;但手工填充 56 字节(两个字段相距 64 字节)的那一组确实比填充 120 字节的慢。一种常见的解释是处理器的相邻行预取会把旁边那一行也牵连进来,这一点没有在本实验中确认。可以确定的是:按 64 字节刚好对齐的填充不一定够,JDK 选择 128 是留了余量的。
直接用 JDK 已经隔离好的结构。 多线程累加计数用 LongAdder(见 volatile、CAS 与 LongAdder),不需要自己处理布局。大多数业务代码遇到的伪共享都可以这样绕开。
改数据的组织方式。 每个线程把结果先累加在自己的局部变量里,结束时再写回共享数组一次;「每线程一个槽」的数组改成每线程一个独立对象并隔离。局部变量在栈上或寄存器里,根本不经过共享的缓存行,这往往比填充更彻底。
四、什么时候值得管
伪共享的代价只在三个条件同时成立时才显著:变量被多个线程访问,其中至少一个在写,而且写得非常频繁(每秒百万次以上)。典型的位置:
- 多线程共用的计数器、统计数组、直方图的桶;
- 队列的头指针和尾指针:生产者写尾、消费者写头,两者相邻时互相干扰;
- 线程池或事件循环里每个工作线程的状态对象,连续分配后在堆上相邻。
反过来,下面这些情况不用管:
- 一次请求只写共享变量几次。实验里每次操作只有一个自增,没有任何别的工作,这是伪共享影响的上限;真实请求里几十微秒的业务逻辑会把这几十纳秒淹没。
- 写操作本身已经被锁保护。锁的开销远大于缓存行转移。
- 对象很多、访问分散。给每个对象加 256 字节的填充,换来的可能是缓存里能放下的对象变少,整体更慢。
填充是用内存换时间,只该加在少数几个确认过的热点上。
五、确认它存在
伪共享没有直接的应用层指标,确认要靠对照和硬件层面的工具。
先排除测量本身的问题。 实验里还有一组普通(非 volatile)字段的对照:两个线程各自执行 plain.a++ 一亿次,测出来每次不到 0.0001 纳秒。JIT 发现循环里的写入没有别人需要看到,把一亿次自增合并成了一次加法,内存只写了一次(这类优化见 JIT 与逃逸分析)。在这种代码上比较「相邻」与「隔开」,结论会是伪共享不存在。微基准里被测的写入必须是真实发生的(volatile、原子类,或者用 JMH 的 Blackhole),否则测到的不是内存访问。
做对照实验。 给怀疑的字段加上隔离(@Contended 或手工填充),在同样的负载下比较吞吐。这是最直接的证据,也是唯一能回答「值不值得」的办法。
用硬件计数器。 Linux 上 perf c2c 专门用来找缓存行争用:它采样内存访问,报告哪些缓存行被多个核以「一个写、其他读或写」的方式访问,以及对应的代码位置。它依赖硬件的采样支持,虚拟机和容器里通常不可用,本文的实验环境也用不了,需要在物理机上运行。
看字段布局。 用 JOL(Java Object Layout)打印对象的字段偏移,确认两个热点字段是否真的在 64 或 128 字节之内,以及隔离措施是否生效。前面「加了注解但没开开关」的情况,只有看布局才能发现。
配套实验
- codesphere-labs/java/false-sharing:JDK 25.0.4 上相邻字段、两种宽度的手工填充、
@Contended(开关打开与关闭时的字段偏移)、数组元素间距、只读线程与普通字段循环的每次操作耗时(验证记录)
参考资料