CacheLine和伪共享

CacheLine简介#

CPU缓存系统中是以缓存行(cache line)为单位存储的。缓存行是2的整数幂个连续字节,目前主流的CPU Cache的Cache Line大小都是64Bytes(和操作系统、CPU有关)。**当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享。**缓存行上的写竞争是运行在SMP系统中并行线程实现可伸缩性最重要的限制因素。

由于共享变量在CPU缓存中的存储是以缓存行为单位,一个缓存行可以存储多个变量(存满当前缓存行的字节数);而CPU对缓存的修改又是以缓存行为最小单位的,如果多线程同时修改同一缓存行上的两个变量,那么就会出现上诉的伪共享问题。

伪共享是无声的性能杀手,因为从代码中很难看清楚是否会出现伪共享。对于多线程编程来说,特别是多线程处理列表和数组的时候,要非常注意伪共享的问题。否则不仅无法发挥多线程的优势,还可能比单线程性能还差。

为什么CacheLine并发慢?#

  • 下面的图中,X和Y在同一缓存行中,读取的时候只能一起从“内存–L3–L2–L1–寄存器”去加载。使用的时候反着从“寄存器-L1-L2-L3-主存”去拿,走得越远越慢。
  • 所以如果你在做一些很频繁的事,你要尽量确保数据在L1缓存中。另外,线程之间共享一份数据的时候,需要一个线程把数据写回主存,而另一个线程访问主存中相应的数据。
  • 但是当core1修改X的时候,把这个CacheLine读取进来修改完毕,根据MESI缓存一致性协议,缓存行会失效。需要从下级Cache或者其他核心的Cache,甚至主存重新load。

image-20200615095526972

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(Share共享有效);

此时其中的一个核a改变了变量i的值,那么在核a中的当前缓存行的状态将变为M(有效但修改了,和内存不一致),b,c核中的当前缓存行状态将变为I(无效)。如下图:

image-20200615104347330

FalseSharing伪共享问题#

在上面的情况下a核心修改后缓存状态变为M,b和c核心的缓存变为I无效,去重新load(从其他核心的缓存中同步,或者从主存同步,看CPU实现了)。

此时在核b上运行的线程,正好想要修改变量Y,那么就会出现相互竞争,相互失效的情况,这就是伪共享啦。

伪共享的解决方案#

下面使用一个带有long类型的简单对象来测试(使用一个long数组测试也行,略不直观)

原始有缓存行竞争的问题#

如下,假设一个数组有多个Obj1对象。我们多线程需要对不同位置的Obj1的x进行并发赋值,在修改的时候会因为一次load了多个Obj1到内存中,就会产生缓存行互相失效的竞争问题。这就是原始的伪共享问题。

1
2
3
4
5
6
7
8
9
10
// 抽象一个父类,为了完成缓存行通用测试,无意义。
abstract class Obj{
long x;
}

// 普通未进行缓存行优化的对象
class Obj1 extends Obj{
long x; // 假设一个数组有多个Obj1对象,我们多线程需要对不同位置的Obj1的x进行赋值,在修改的时候会因为一次load了多个Obj1到内存中,就会产生缓存行竞争
}

JDK8以前人肉解决:#

前后各加8个long(64位)来规避缓存行竞争,在诸如Disruptor等追求极致性能的地方会见到这种代码:

1
2
3
4
5
6
7
8
9
10
// 缓存行优化对象
class Obj2 extends Obj{
long p1,p2,p3,p4,p5,p6,p7;
/**
* 因为对象前7个long+x是64个字节,正好一个缓存行。后面也是。
* 所以多线程写的时候,两个线程永远不在一个缓存行中,效率更高。
*/
long x;
long p8,p9,p10,p11,p12,p13,p14;
}

JDK8以后@Contended解决:#

使用@Contended注解自动进行缓存行填充,防止多线程竞争缓存行。JVM会给我们前后各加128bit的padding,保证规避了缓存行竞争。

1
2
3
4
5
6
7
8
/**
* 使用@Contended注解自动进行缓存行填充,防止多线程竞争缓存行
* 执行时,必须加上虚拟机参数-XX:-RestrictContended,@Contended注释才会生效。
*/
@Contended
class Obj3 extends Obj{
long x;
}

确认内存布局#

我们的两种优化是不是真的有效呢?JOL看一下这三个对象的内存布局:

1
2
3
4
5
6
Obj1 obj1 = new Obj1();
Obj2 obj2 = new Obj2();
Obj3 obj3 = new Obj3();
System.out.println(ClassLayout.parseInstance(obj1).toPrintable());
System.out.println(ClassLayout.parseInstance(obj2).toPrintable());
System.out.println(ClassLayout.parseInstance(obj3).toPrintable());

可以看到:

  • 没加缓存行优化的Obj1:我们的核心属性,前后没有缓存行对齐,直接多线程修改它是有伪共享问题的(两个Obj1的x在同一个缓存行中)
  • 使用人肉8个long进行缓存行优化的Obj2:我们的核心属性,前后都有64位的填充,多线程竞争也不会使得两个Obj2对象的x在同一个缓存行中
  • 使用JDK自带@Contended修饰的Obj3:前后各有一个长达128字节的填充,多线程竞争也不会使得两个Obj2对象的x在同一个缓存行中
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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# 没加缓存行优化的简单extends的Obj1:
com.xxxx.Obj1 object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 81 c1 00 f8 (10000001 11000001 00000000 11111000) (-134168191)
12 4 (alignment/padding gap)
16 8 long Obj.x 0
24 8 long Obj1.x 0 # 我们的核心属性,前后没有缓存行对齐,直接多线程修改它是有伪共享问题的(两个Obj1的x在同一个缓存行中)
Instance size: 32 bytes
Space losses: 4 bytes internal + 0 bytes external = 4 bytes total





# 人肉使用了一堆long进行占位
com.xxxx.Obj2 object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) bf c1 00 f8 (10111111 11000001 00000000 11111000) (-134168129)
12 4 (alignment/padding gap)
16 8 long Obj.x 0 # 这个是继承的,不用管
24 8 long Obj2.p1 0
32 8 long Obj2.p2 0
40 8 long Obj2.p3 0
48 8 long Obj2.p4 0
56 8 long Obj2.p5 0
64 8 long Obj2.p6 0
72 8 long Obj2.p7 0
80 8 long Obj2.x 0 # 我们的核心属性,前后都有64位的填充,多线程竞争也不会使得两个Obj2对象的x在同一个缓存行中
88 8 long Obj2.p8 0
96 8 long Obj2.p9 0
104 8 long Obj2.p10 0
112 8 long Obj2.p11 0
120 8 long Obj2.p12 0
128 8 long Obj2.p13 0
136 8 long Obj2.p14 0
Instance size: 144 bytes
Space losses: 4 bytes internal + 0 bytes external = 4 bytes total







# 使用JDK的@Contended修饰的class
com.xxxx.Obj3 object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 05 c2 00 f8 (00000101 11000010 00000000 11111000) (-134168059)
12 4 (alignment/padding gap)
16 8 long Obj.x 0
24 128 (alignment/padding gap) # 前面有一个长达128字节的填充
152 8 long Obj3.x 0 # 我们的核心对象
160 128 (loss due to the next object alignment) # 后面也有一个长达128的填充
Instance size: 288 bytes
Space losses: 132 bytes internal + 128 bytes external = 260 bytes total

效果测试#

下面是工具方法:

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
/**
* 测试缓存行工具方法
*/
private static void testCacheLine(Obj[] arr) throws InterruptedException {
StopWatch watch = new StopWatch();
watch.start();
// 一亿次
long times = 1_0000_0000l;

CountDownLatch latch = new CountDownLatch(2);
// 启动2个线程,对数组的两个位置进行竞争修改
new Thread(()->{
for (Long i = 0l; i < times; i++) {
arr[0].x = i;
}
latch.countDown();
}).start();

new Thread(()->{
for (Long i = 0l; i < times; i++) {
arr[1].x = i;
}
latch.countDown();
}).start();
// 等待两个线程都执行完毕,计时
latch.await();
watch.stop();
System.out.println(watch.getTime());
}

测试原始问题、人肉优化、@Contended优化。#

注意@Contended需要运行的时候加上-XX:-RestrictContended参数才生效

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
36
37
38
39
40
41
42
43
44
public static void main(String[] args) {
try {
/**
* 结果很慢,因为对象里只有一个long是8个字节,缓存行64个字节。
* 多个线程并发写的时候,必然需要进行缓存行同步。
*/
Obj [] arr1 = new Obj1[2];
arr1[0] = new Obj1();
arr1[1] = new Obj1();
// 缓存行伪共享测试
testCacheLine(arr1);
} catch (InterruptedException e) {
e.printStackTrace();
}

try {
/**
* 快,因为对象前7个long+x是64个字节,正好一个缓存行。后面也是。
* 所以多线程写的时候,两个线程永远不在一个缓存行中,效率更高。
*/
Obj [] arr2 = new Obj2[2];
arr2[0] = new Obj2();
arr2[1] = new Obj2();
// 缓存行伪共享优化测试
testCacheLine(arr2);
} catch (InterruptedException e) {
e.printStackTrace();
}


try {
/**
* 快,因为@Contended会自动填充,正好一个缓存行。后面也是。
* 所以多线程写的时候,两个线程永远不在一个缓存行中,效率更高。
* @Contended 需要加上-XX:-RestrictContended才生效
*/
Obj [] arr3 = new Obj3[2];
arr3[0] = new Obj3();
arr3[1] = new Obj3();
testCacheLine(arr3);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

多次运行可以发现未优化的速度明显慢于后两者优化后的:

后两者优化后的差异不大,通常都很接近。

1
2
3
1802
674
874

也可参见美团的一篇disruptor文章中的测试:

https://tech.meituan.com/2016/11/18/disruptor.html