Skip to content

两个线程各写各的变量,为什么互相拖慢 ​

两个线程,各自对自己的计数器做一亿次自增,没有共享任何变量,也没有锁。两个线程同时跑,每次自增却要 41 纳秒,一个线程单独跑只要 7 纳秒。把两个计数器在内存里挪开一百多个字节,又回到 7 纳秒。

代码层面这两个线程毫无关系,问题出在代码看不见的地方:两个变量在内存里挨着,落在了同一个缓存行里。这种现象叫伪共享(false sharing)。它不影响正确性,只影响速度,而且从代码、线程转储、锁竞争统计里都看不出来。

本文在 JDK 25.0.4 上测量它的大小,说明原因和隔开变量的几种做法,以及什么情况下值得为它动手。实验见文末。

一、测出来的差别 ​

每个线程对自己的 volatile long 做 1 亿次 getAndAdd(通过 VarHandle),比较两个变量位置不同时每次操作的耗时。

单线程写一个字段6.9 ns两线程,相邻字段41.1 ns(偏移 16、24)手工填充,两侧各 56 字节11.1 ns(相距 64 字节)手工填充,两侧各 120 字节7.3 ns@Contended7.2 ns(相距 136 字节)数组相邻元素62.7 ns数组相隔 32 个元素7.1 ns只读线程:单独读 0.57 ns;旁边的字段被另一个线程写时 1.94 ns;隔开之后 0.57 ns普通字段的自增循环:不到 0.0001 ns,循环被编译器合并成一次加法,测到的不是内存写入
图 1 · 实测(JDK 25.0.4,6 核虚拟机,每个线程 1 亿次原子自增,5 轮取中位数):相邻的两个字段每次操作 41 纳秒,是单线程的 5.9 倍;用 @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 在同一个缓存行核 1 · 线程 A只写 a核 2 · 线程 B只写 b头头ab每次写都要把整行抢过来,对方的副本作废a、b 各占一个缓存行核 1 · 线程 A只写 a核 2 · 线程 B只写 b填充填充a填充填充填充填充填充填充填充填充填充填充b填充填充同一行里只要有一个变量被频繁写,旁边所有变量的读写都会受影响,包括只读的代价是空间:@Contended 默认在字段前后各留 128 字节
图 2 · CPU 缓存以缓存行(常见为 64 字节)为单位管理数据。一个核要写某一行,必须先独占它,其他核手里的这一行随之作废。a 和 b 在同一行时,两个线程虽然各写各的变量,这一行却要在两个核之间来回转移,每次写入都可能要等。把两个变量隔到不同的行,两个核各自独占自己的那一行,互不打扰。

两个线程分别写 a 和 b,而 a、b 在同一行里时,这一行就在两个核之间来回转移。每次转移是一次核间的通信,比一次普通的缓存命中慢得多。从硬件的角度看,这和两个线程写同一个变量没有区别,所以叫「伪」共享:逻辑上没有共享,物理上共享了。

只读的线程也会被拖慢。 实验里一个线程只读字段 b,另一个线程写它旁边的 a:

场景读者每次读取
单独读0.57 ns
旁边的字段被另一个线程写1.94 ns
写者的字段用 @Contended 隔开0.57 ns

读者手里的那一行不断被写者作废,每隔一会儿就要重新取一次。一个被所有请求线程读取的配置引用、一个数组的长度字段,如果旁边恰好是一个被频繁写的计数器,读的一方就会莫名其妙地变慢。

三、把变量隔开 ​

手工填充。 在变量前后放若干个无用的 long,把它推到独立的行里。直接在同一个类里声明填充字段不可靠,JVM 可以重新排列同一个类的字段;通常的写法是利用继承,父类的字段排在子类字段之前:

java
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(开关打开与关闭时的字段偏移)、数组元素间距、只读线程与普通字段循环的每次操作耗时(验证记录)

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权