目录

一、什么是伪共享

二、为什么会出现伪共享

三、如何避免伪共享

1. 伪共享的传统解决方案

2. JDK8中的解决方案

3. 解决原理

四、知识拓展

1. 缓存行

2. CPU 的三级缓存

3. 缓存关联性

4. MESI 协议


一、什么是伪共享

为了解决计算机系统中主内存与CPU之间运行速度差问题,会在CPU与主内存之间添加一级或者多级

高速缓冲存储器(Cache)。这个 Cache 一般是被集成到 CPU 内部的,所以也叫 CPU Cache,如图

所示是两级 Cache 结构。

在 Cache 内部是按行存储的,其中每一行称为一个 Cache 行。

Cache 行是 Cache 与主内存进行数据交换的单位,Cache 行的大小一般为 2 的幂次数字节。

当 CPU 访问某个变量时,首先会去看 CPU Cache 内是否有该变量,如果有则直接从中获取,否则

就去主内存里面获取该变量,然后把该变量所在内存区域的一个Cache行大小的内存复制到 Cache

中。

由于存放到 Cache 行的是内存块而不是单个变量,所以可能会把多个变量存放到一个Cache 行中。

当多个线程同时修改一个缓存行里面的多个变量时,由于同时只能有一个线程操作缓存行,所以相比

将每个变量放到一个缓存行,性能会有所下降,这就是伪共享,如图所示。

在该图中,变量x和 y 同时被放到了CPU 的一级和二级缓存,当线程1 使用 CPU1对变量x进行更新

时,首先会修改 CPU1 的一级缓存变量x所在的缓存行,这时候在缓存一致性协议下,CPU2中变量x

对应的缓存行失效。

那么线程2 在写入变量x时就只能去二级缓存里查找,这就破坏了一级缓存。

而一级缓存比二级缓存更快,这也说明了多个线程不可能同时去修改自己所使用的 CPU 中相同缓存

行里面的变量。更坏的情况是,如果 CPU 只有一级缓存,则会导致频繁地访问主内存。

总结成一句话:CPU 缓存系统中是以缓存行(cache line)为单位存储的。目前主流的 CPU Cache

的 CacheLine 大小都是 64 Bytes。在多线程情况下,如果需要修改“共享同一个缓存行的变量”,就

会无意中影响彼此的性能,这就是伪共享(False Sharing)。

二、为什么会出现伪共享

伪共享的产生是因为多个变量被放入了一个缓存行中,并且多个线程同时去写入缓存行中不同的变

量。那么为何多个变量会被放入一个缓存行呢?

其实是因为缓存与内存交换数据的单位就是缓存行,当 CPU 要访问的变量没有在缓存中找到时,根

据程序运行的局部性原理,会把该变量所在内存中大小为缓存行的内存放入缓存行。

long a;
long b;
long c;
long d;

如上代码声明了四个long变量,假设缓存行的大小为 32 字节,那么当 CPU 访问变量 a 时,发现该

变量没有在缓存中,就会去主内存把变量 a 以及内存地址附近的 b、c、d放入缓存行。

也就是地址连续的多个变量才有可能会被放到一个缓存行中。

当创建数组时,数组里面的多个元素就会被放入同一个缓存行。

那么在单线程下多个变量被放入同一个缓存行对性能有影响吗?

其实在正常情况下单线程访问时将数组元素放入一个或者多个缓存行对代码执行是有利的,因为数据

都在缓存中,代码执行会更快,请对比下面代码的执行。

因为数组内数组元素的内存地址是连续的,当访问数组第一个元素时,会把第一个元素后的若干元素

一块放入缓存行,这样顺序访问数组元素时会在缓存中直接命中,因而就不会去主内存读取了,后续

访问也是这样。

也就是说,当顺序访问数组里面元素时,如果当前元素在缓存没有命中,那么会从主内存一下子读取

后续若干个元素到缓存,也就是一次内存访问可以让后面多次访问直接在缓存中命中。

如果我们是跳跃式访问数组元素的,不是顺序的,这破坏了程序访问的局部性原则,并且缓存是有容

量控制的,当缓存满了时会根据一定淘汰算法替换缓存行,这会导致从内存置换过来的缓存行的元素

还没等到被读取就被替换掉了。

所以在单个线程下顺序修改一个缓存行中的多个变量,会充分利用程序运行的局部性原则,从而加速

了程序的运行。

而在多线程下并发修改一个缓存行中的多个变量时就会竞争缓存行,从而降低程序运行性能。

三、如何避免伪共享

1. 伪共享的传统解决方案

在JDK8之前一般都是通过字节填充的方式来避免该问题,也就是创建一个变量时使用填充字段填充

该变量所在的缓存行,这样就避免了将多个变量存放在同一个缓存行中,例如,下面代码。

public final static class FilledLong{
    public volatile long value = 0L;
    public long p1,p2,p3,p4,p5,p6;
}

假如缓存行为 64 字节,那么我们在 FilledLong类里面填充了6 个 long类型的变量,每个 long 类型

变量占用 8 字节,加上 value 变量的 8 字节总共 56 字节。

另外,这里 FilledLong是一个类对象,而类对象的字节码的对象头占用 8 字节,所以一个

FilledLong 对象实际会占用 64 字节的内存,这正好可以放入一个缓存行。

2. JDK8中的解决方案

Java 8 中已经提供了官方的解决方案,Java 8 中新增了一个注解:@sun.misc.Contended,用来解

决伪共享问题。

加上这个注解的类会自动补齐缓存行,需要注意的是此注解默认是无效的,需要在 jvm 启动时设置

-XX:-RestrictContended 才会生效。

将上面代码修改为如下。

@sun.misc.Contended
public final static class VolatileLong {
    public volatile long value = 0L;
    //public long p1, p2, p3, p4, p5, p6;

在这里注解用来修饰类,当然也可以修饰变量,比如在Thread类中。

Thread 类里面这三个变量默认被初始化为 0,这三个变量会在ThreadLocalRandom 类中使用,后

面章节会专门讲解 ThreadLocalRandom 的实现原理。

默认情况下,@Contended 注解只用于Java 核心类,比如 rt 包下的类。

如果用户类路径下的类需要使用这个注解,则需要添加 JVM参数:-XX:-RestrictContended。

填充的宽度默认为 128,要自定义宽度则可以设置-XX:ContendedPaddingWidth 参数。

3. 解决原理

通过以上两种解决方案分析:为了避免由于 false sharing 导致 Cache Line 从 L1,L2,L3 到主存之间

重复载入,我们可以使用数据填充的方式来避免,即单个数据填充满一个CacheLine。这本质是一种

空间换时间的做法。

四、知识拓展

1. 缓存行

由于共享变量在 CPU 缓存中的存储是以缓存行为单位,一个缓存行可以存储多个变量(存满当前缓

存行的字节数);而CPU对缓存的修改又是以缓存行为最小单位的,那么就会出现上诉的伪共享问

题。

Cache Line 可以简单的理解为 CPU Cache 中的最小缓存单位,今天的 CPU 不再是按字节访问内

存,而是以 64字节为单位的块(chunk)拿取,称为一个缓存行(cache line)。当你读一个特定的内存

地址,整个缓存行将从主存换入缓存,并且访问同一个缓存行内的其它值的开销是很小的。

2. CPU 的三级缓存

由于 CPU 的速度远远大于内存速度,所以 CPU 设计者们就给 CPU 加上了缓存(CPU Cache)。

以免运算被内存速度拖累。(就像我们写代码把共享数据做Cache不想被DB存取速度拖累一样),

CPU Cache

分成了三个级别:L1,L2,L3。越靠近CPU的缓存越快也越小。所 以L1 缓存很小但很快,并且紧

靠着在使用它的 CPU 内核。L2 大一些,也慢一些,并且仍然只能被一个单独的 CPU 核使用。L3 在

现代多核机器中更普遍,仍然更大,更慢,并且被单个插槽上的所有 CPU 核共享。最后,你拥有一

块主存,由全部插槽上的所有 CPU 核共享。

当 CPU 执行运算的时候,它先去L1查找所需的数据,再去L2,然后是L3,最后如果这些缓存中都没

有,所需的数据就要去主内存拿。走得越远,运算耗费的时间就越长。所以如果你在做一些很频繁的

事,你要确保数据在L1缓存中。

3. 缓存关联性

目前常用的缓存设计是N路组关联(N-Way Set Associative Cache),他的原理是把一个缓存按照N个

CacheLine 作为一组(Set),缓存按组划为等分。每个内存块能够被映射到相对应的set中的任意一

个缓存行中。比如一个16路缓存,16个 Cache Line 作为一个Set,每个内存块能够被映射到相对应

的 Set 中的16个 CacheLine 中的任意一个。

一般地,具有一定相同低bit位地址的内存块将共享同一个Set。

下图为一个2-Way的Cache。由图中可以看到 Main Memory 中的 Index 0,2,4 都映射在Way0的不同

CacheLine 中,Index 1,3,5都映射在Way1的不同 CacheLine 中。

4. MESI 协议

多核 CPU 都有自己的专有缓存(一般为L1,L2),以及同一个 CPU 插槽之间的核共享的缓存(一

般为L3)。

不同核心的CPU缓存中难免会加载同样的数据,那么如何保证数据的一致性呢,就是 MESI 协议了。

在 MESI 协议中,每个 Cache line 有4个状态,可用 2 个 bit 表示,它们分别是:

  • M(Modified):这行数据有效,数据被修改了,和内存中的数据不一致,数据只存在于本 Cache 中;
  • E(Exclusive):这行数据有效,数据和内存中的数据一致,数据只存在于本 Cache 中;
  • S(Shared):这行数据有效,数据和内存中的数据一致,数据存在于很多 Cache 中;
  • I(Invalid):这行数据无效。

那么,假设有一个变量i=3(应该是包括变量i的缓存块,块大小为缓存行大小);已经加载到多核

(a,b,c)的缓存中,此时该缓存行的状态为S;此时其中的一个核a改变了变量i的值,那么在核a中

的当前缓存行的状态将变为M,b,c核中的当前缓存行状态将变为I。

如下图:

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐