在看《并发编程的艺术》的时候,在「volatile的使用优化」中提到追加字节能够优化性能,当时看的似懂非懂的。如今看到《并发编程之美》中的「伪共享」,对其原理又有了新的理解。接下来我们来聊聊伪共享。

聊聊伪共享

我认为伪共享概念有点抽象,在聊伪共享之前我们先来聊聊计算机的缓存机制。

1. 缓存体制

CPU与内存之间的速度相差很大,为了缓解两者之间的速度差异,引入了缓存机制。一般来说,缓存是集成在我们的CPU上的,打开CPU-Z我们就可以看到我们CPU集成的缓存信息:

image-20210320224842098

图 1 CPU-Z

从上面可以看到我的CPU是支持三级缓存的,而且还可以看出三级缓存的大小。

现代CPU上基本都会配置有缓存(下面称Cache)。既然都有缓存,那么它们是如何工作的呢?

当CPU访问某个变量的时候,会先去CPU Cache中查看是否有该变量,如果有直接从其中获取,否则去主内存中获取该变量,然后把该变量所在的内存区域的一个Cache行大小的内存复制到Cache中。

image-20210320225343906

图 2 CPU/缓存与主内存

需要注意的是Cache中是按照为单位的,行的大小通常为2的n次幂,如图1中我的CPU的三级缓存都是:64-byte line size的,即每行大小为64字节。

从上面我们可以知道:CPU去主内存中获取该一个变量的时候,会把该变量所在的内存区域的一个Cache行大小的内存复制到Cache中作为一行。

比如下面代码:

1
long a1,a2,a3,a4,a5,a6,a7,a8;		// Java中,一个long类型的变量占用8byte

当CPU去读a1变量时,发现三级缓存中都没有,就去主内存去读,读的时候顺便把a2-a8内存区域一起读到缓存中,因为刚好64byte。

为什么会把附近的内存都读到Cache中呢?这其实是利用了程序运行的局部性原则,具体本文不做深究,我们只需要知道,这样子在单线程下会大概率提高数据的读写效率,因为这些数据都在缓存中,访问起来很快。

下面我们就来测试一下运用局部性原理带来的性能提升:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class Demo10LocalPrinciple {
static final int LINE_NUM = 10240; // 一个int4个字节
static final int COLUM_NUM = 10240;

public static void main(String[] args) {
testCache();
testUnCache();
}

private static void testCache() {
long[][] array = new long[LINE_NUM][COLUM_NUM]; // 按行访问,内存连续
long start = System.currentTimeMillis(); // 每次访问可以服用缓存行的内容
for (int i = 0; i < LINE_NUM; i++) {
for (int j = 0; j < COLUM_NUM; j++) {
array[i][j] = i * 2 + j;
}
}
long end = System.currentTimeMillis();
long cacheTime = end - start;
System.out.println("cache time:" + cacheTime);
}

private static void testUnCache() { // 按列访问,内存不连续
long[][] array = new long[LINE_NUM][COLUM_NUM];
long start = System.currentTimeMillis();
for (int i = 0; i < LINE_NUM; i++) {
for (int j = 0; j < COLUM_NUM; j++) {
array[j][i] = i * 2 + j; // 每次访问都要重新去内存读
}
}
long end = System.currentTimeMillis();
long cacheTime = end - start;
System.out.println("no cache time:" + cacheTime);
}
}

输出:

1
2
cache time:140
no cache time:2080

从结果看出,testCache 比 testUnCache 性能的提升还是比较明显的。

原因是数组内的元素的内存地址是连续的,当访问数组第一个元素时,会把第一个元素后的若干元素块放入缓存行,这样顺序访问数组元素时会在缓存中直接命中,因而就不会去主内存读取了,后续访问也是这样。也就是说,当顺序访问数组里面元素时,如果当前元素在缓存没有命中, 那么会从主内存一下子读取后续若干个元素到缓存,也就是一次内存访问可以让后面多次访问直接在缓存中命中。

而在 testUnCache 是跳跃式访问数组元素的,不是顺序的,这破坏了 程序访问的局部性原则,并且缓存是有容量控制的,当缓存满了时会根据一定淘汰算法替换缓存行,这会导致从内存置换过来的缓存行的元素还没等到被读取就被替换掉了。

2. 伪共享问题

2.1 何为伪共享

从上面我们可以知道单线程环境下,CPU 将访问主内存变量是读取多个变量到内存中对程序的性能有一定的提升。那么在多线程环境下,结论还是一样吗?

前面我们知道,存放到 Cache 行的是内存块而不是单个变量,所以可能会把多个变量存放到 Cache 行中。当多个线程同时修改一个缓存行里面的多个变量时,由于缓冲行同时只能由一个线程操作,所有相比于将每个变量放到一个缓存行,性能会有所下降,这就是伪共享。

cache

图 3 缓存

如上图,变量 x 和 y 同时被放到 CPU 的一级缓存和二级缓存中。

当线程 A 使用 CPU1 对变量 x 进行更新时,首先会修改 CPU1 的一级缓存变量 x 所在的缓存行,这时在缓存一致性协议下,CPU2 中变量 x 所在的缓存行失效。那么线程 B 在写入变量 x 时只能去二级缓存里面找,这就破坏了一级缓存。这也意味着,多个线程不能同时去修改自己使用的 CPU 位于同一个缓存行的变量。更坏的情况时,如果 CPU 只有一级缓存,就只能去主内存中访问,这将极大降低效率。

2.2 伪共享的解决

从上面我们可以知道:伪共享的产生是因为多个变量被放入了一个缓存行中,并且多个线程同时去写入缓存行中不同的变量。既然如此,我们为何不将一个变量放在一个缓存行里面呢?事实上人们也正是这样来解决伪共享问题的,如下面代码。

1
2
3
4
public final static class CacheLong {
public volatile long value = 0L;
public long p1, p2, p3, p4, p5, p6;
}

加入缓存行为 64 字节,当我们的 CacheLong 类里面填充 6 个 long 类型的变量时,每个 long 类型占用 8 个字节,加上 value 变量的8字节总共 56 字节。另外这里的CacheLong是有个类对象,而每个类对象的字节码的对象头占用8字节,因此一个CacheLong对象实际上会占用64字节的内存,正好可以放到缓存行中。

这种方式在《并发编程的艺术》 volatile的优化中的「追加字节能优化性能」中也曾提到。

虽然我们知道填充满一个缓存行可以在并发环境下提高性能,但是我们在设计类或者变量的时候,需要每次都要计算要填充的数值,这样岂不是很麻烦?

JDK8提供了sun.misc.Contended注解,用来解伪共享问题。将上面代码修改为如下。

1
2
3
4
@sun.misc.Contended
public final static class CacheLong {
public volatile long value = 0L;
}

该注解也可以用来修饰变量,如Thread类中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// The following three initially uninitialized fields are exclusively 
// managed by class java.util.concurrent.ThreadLocalRandom. These
// fields are used to build the high-performance PRNGs in the
// concurrent code, and we can not risk accidental false sharing.
// Hence, the fields are isolated with @Contended.

// 下面三个字段是在并发编程中用来构建高性能伪随机数发生器,
// 所以我们应该避免这些变量在并发编程发生伪共享的意外情况,
// 因此这些字段被@Contended注解修饰


/** The current seed for a ThreadLocalRandom */
@sun.misc.Contended("tlr")
long threadLocalRandomSeed;

/** Probe hash value; nonzero if threadLocalRandomSeed initialized */
@sun.misc.Contended("tlr")
int threadLocalRandomProbe;

/** Secondary seed isolated from public ThreadLocalRandom sequence */
@sun.misc.Contended("tlr")
int threadLocalRandomSecondarySeed;

需要注意的是,@Contended只能用来修饰Java核心类,不然rt包下的类。如果在用户类路径下使用这个注解,需要在虚拟机中添加参数:-XX:-RestrictContended。填充的默认宽度为128,可以使用自定义配置:-XX:ContendedPaddingWidth。

3. 总结

  • 现代计算机CPU中都存在着缓存机制,如我的计算机CPU就有三级缓存,而且没个缓存的缓存行大小为64byte。
  • CPU在访问操作变量的时候,先去缓存行中找,如果三级缓存都没找到,就会去内存中加载该变量到缓存中。
  • 在加载变量的时候,不仅仅是将当前变量加载,而是将当前变量所在的连续代码块一起添加到缓存行中,这是利用了局部性定律。
  • 这种策略在单线程情况下,往往能够提高程序的整体性能,比如顺序访问一个数组的时候,就可以利用之前已经加载到的数据,而不需要回到内存中。
  • 但是,在多线程环境下,相同的若干变量被加载到不同的缓存行中,一个线程对其中变量执行修改,会反映到缓存行中。
  • 此时在缓存一致性协议下,其他保存了该变量的缓存行失效,如果CPU需要用到这些失效的缓存行中的变量,不得不再去内存中加载,这就是伪共享问题(false-sharing)。
  • 因此伪共享问题是在并发的环境下,多个线程并发修改一个缓存行里面的多个变量时会竞争缓冲行,从而导致程序运行性能降低。
  • 那么我们是否可以在一个缓存行中值存放一个变量?JDK8正是通过字节填充的方式来实现缓存行中只存放一个被操作的变量,从而解决伪共享问题的。

个人觉得:false-sharing翻译为失败共享应该更为贴切。

4. 参考

评论