一、问题引入
由一个案例引进,先上代码
public class NoCacheLinePadding{
private volatile long cacheLine1 = 1L;
private volatile long cacheLine2 = 1L;
public void setCacheLine1(long cacheLine1) {
this.cacheLine1 = cacheLine1;
}
public void setCacheLine2(long cacheLine2) {
this.cacheLine2 = cacheLine2;
}
public long getCacheLine1() {
return cacheLine1;
}
public long getCacheLine2() {
return cacheLine2;
}
}
void testNoCacheLine() throws Exception{
CountDownLatch countDownLatch = new CountDownLatch(2);
NoCacheLinePadding noCacheLinePadding = new NoCacheLinePadding();
Thread a = new Thread(() -> {
for (int i=0;i<100000000;i++) {
noCacheLinePadding.setCacheLine1(i);
}
countDownLatch.countDown();
});
Thread b = new Thread(() -> {
for (int i=0;i<100000000;i++) {
noCacheLinePadding.setCacheLine2(i);
}
countDownLatch.countDown();
});
long start = System.nanoTime();
a.start();
b.start();
countDownLatch.await();
long end = System.nanoTime();
System.out.println("cache1= "+noCacheLinePadding.getCacheLine1()+",cache2="+noCacheLinePadding.getCacheLine2()+",耗时: "+ (end - start)/ 1000000 +"毫秒" );
}
上面是一个 由两个线程分别去循环1亿次去修改一个对象中两个不同属性的测试用例。
测试结果:cache1= 99999999,cache2=99999999,耗时: 461毫秒
/**
* @author zhangxiaohu
* @descript p0,p1,p2,p3,p4,p5,p6 p9,p10,p11,p12,p13,p14,p15 是缓存行填充,解决cpu缓存伪共享问题
* @date 2023-4-11
*/
public class CacheLinePadding{
private volatile long p0,p1,p2,p3,p4,p5,p6;
private volatile long cacheLine1 = 1L;
private volatile long p9,p10,p11,p12,p13,p14,p15;
private volatile long cacheLine2 = 1L;
public void setCacheLine1(long cacheLine1) {
this.cacheLine1 = cacheLine1;
}
public void setCacheLine2(long cacheLine2) {
this.cacheLine2 = cacheLine2;
}
public long getCacheLine1() {
return cacheLine1;
}
public long getCacheLine2() {
return cacheLine2;
}
}
void testCacheLine() throws Exception{
CountDownLatch countDownLatch = new CountDownLatch(2);
CacheLinePadding cacheLinePadding = new CacheLinePadding();
Thread a = new Thread(() -> {
for (int i=0;i<100000000;i++) {
cacheLinePadding.setCacheLine1(i);
}
countDownLatch.countDown();
});
Thread b = new Thread(() -> {
for (int i=0;i<100000000;i++) {
cacheLinePadding.setCacheLine2(i);
}
countDownLatch.countDown();
});
long start = System.nanoTime();
a.start();
b.start();
countDownLatch.await();
long end = System.nanoTime();
System.out.println("cache1= "+cacheLinePadding.getCacheLine1()+",cache2="+cacheLinePadding.getCacheLine2()+",耗时: "+ (end - start)/ 1000000 +"毫秒" );
}
这个测试用例和第一个基本一致,不同的是我们在对象中加了一些看似多余的属性p0,p1,p2,p3,p4,p5,p6 p9,p10,p11,p12,p13,p14,p15(下文会解释为什么要这么做)
测试结果:cache1= 99999999,cache2=99999999,耗时: 109毫秒
问题:第二个测试用例只是多加入了一些多余的属性,为什么时间消耗 和 第一个测试用例的时间消耗 差距将近 5 倍?
为了搞清楚cpu缓存伪共享的概念,下面我们将从以下几个方面来解释。
- cpu缓存结构
- cpu缓存伪共享是怎么产生的
- 如何解决cpu缓存伪共享的问题
2.1、cpu缓存结构
随着计算机的发展,CPU 和内存的访问性能相差越来越⼤,于是就在 CPU 内部嵌⼊了 CPUCache(⾼速缓存),CPU Cache 离 CPU 核⼼相当近,因此它的访问速度是很快的,于是它充当了 CPU 与内存之间的缓存⻆⾊。CPU Cache 通常分为三级缓存:L1 Cache、L2 Cache、L3 Cache,级别越低的离 CPU 核⼼越近,访问速度也快,但是存储容量相对就会越⼩。其中,在多核⼼的 CPU ⾥,每个核⼼
都有各⾃的 L1/L2 Cache,⽽ L3 Cache 是所有核⼼共享使⽤的。从上图也可以看到,从上往下,存储设备的容量会越⼤,⽽访问速度会越慢。可以看到, CPU 访问 L1 Cache 速度⽐访问内存快 100 倍,这就是为什么 CPU ⾥会有L1~L3 Cache 的原因,⽬的就是把 Cache 作为 CPU 与内存之间的缓存层,以减少对内存的访问频率。
CPU 从内存中读取数据到 Cache 的时候,并不是⼀个字节⼀个字节读取,⽽是⼀块⼀块的⽅式来读取数据的,这⼀块⼀块的数据被称为 CPU Line(缓存⾏),所以 CPU Line 是 CPU从内存读取数据到 Cache 的单位⾄于 CPU Line ⼤⼩,在 Linux 系统可以⽤下⾯的⽅式查看到,大部分服务器的Cache Line ⼤⼩是 64 字节,也就意味着 L1 Cache ⼀次载⼊数据的⼤⼩是 64 字节
那么对数组的加载, CPU 就会加载数组⾥⾯连续的多个数据到 Cache ⾥,因此我们应该按照物理内存地址分布的顺序去访问元素,这样访问数组元素的时候,Cache 命中率就会很⾼,于是就能减少从内存读取数据的频率, 从⽽可提⾼程序的性能,但是在我们不使⽤数组,⽽是使⽤单独的变量的时候,则会有 Cache 伪共享的问题,Cache 伪共享问题上是⼀个性能杀⼿,我们应该要规避它。接下来,就来看看 Cache 伪共享是什么?⼜如何避免这个问题。
2.2、cpu缓存伪共享是如何产生的
还是以我们文章开头的代码举例现在假设有⼀个双核⼼的 CPU,这两个 CPU 核⼼并⾏运⾏着两个不同的线程,它们同时从内存中读取两个不同的数据,分别是类型为 long 的变量 A(代码中的cacheline1) 和 B(代码中的cacheline2),这个两个数据的地址在物理内存上是连续的,如果 Cahce Line 的⼤⼩是 64 字节,并且变量 A 在 Cahce Line 的开头位置,那么这两个数据是位于同⼀个 Cache Line 中,⼜因为 CPU Line 是 CPU 从内存读取数据到 Cache 的单位,所以这两个数据会被同时读⼊到了两个 CPU 核⼼中各⾃ Cache 中
我们来思考⼀个问题,如果这两个不同核⼼的线程分别修改不同的数据,⽐如 1 号 CPU 核⼼的线程只修改了 变量 A,或 2 号 CPU 核⼼的线程的线程只修改了变量 B,会发⽣什么呢
①. 最开始变量 A 和 B 都还不在 Cache ⾥⾯,假设 1 号核⼼绑定了线程 A,2 号核⼼绑定了线程 B,线程 A 只会读写变量 A,线程 B 只会读写变量 B
②. 1 号核⼼读取变量 A,由于 CPU 从内存读取数据到 Cache 的单位是 Cache Line,也正好变量 A 和 变量 B 的数据归属于同⼀个 Cache Line,所以 A 和 B 的数据都会被加载到Cache,并将此 Cache Line 标记为「独占」状态
③. 接着,2 号核⼼开始从内存⾥读取变量 B,同样的也是读取 Cache Line ⼤⼩的数据到Cache 中,此 Cache Line 中的数据也包含了变量 A 和 变量 B,此时 1 号和 2 号核⼼的Cache Line 状态变为「共享状态
④. 1 号核⼼需要修改变量 A,发现此 Cache Line 的状态是「共享」状态,所以先需要通过总线发送消息给 2 号核⼼,通知 2 号核⼼把 Cache 中对应的 Cache Line 标记为「已失效」状态,然后 1 号核⼼对应的 Cache Line 状态变成「已修改」状态,并且修改变量 A
⑤. 之后,2 号核⼼需要修改变量 B,此时 2 号核⼼的 Cache 中对应的 Cache Line 是已失效状态,另外由于 1 号核⼼的 Cache 也有此相同的数据,且状态为「已修改」状态,所以要先把 1 号核⼼的 Cache 对应的 Cache Line 写回到内存,然后 2 号核⼼再从内存读取 CacheLine ⼤⼩的数据到 Cache 中,最后把变量 B 修改到 2 号核⼼的 Cache 中,并将状态标记为「已修改」状态。
所以,可以发现如果 1 号和 2 号 CPU 核⼼这样持续交替的分别修改变量 A 和 B,就会重复④ 和 ⑤ 这两个步骤,Cache 并没有起到缓存的效果,虽然变量 A 和 B 之间其实并没有任何的关系,但是因为同时归属于⼀个 Cache Line ,这个 Cache Line 中的任意数据被修改后,都会相互影响,从⽽出现 ④ 和 ⑤ 这两个步骤因此,这种因为多个线程同时读写同⼀个 Cache Line 的不同变量时,⽽导致 CPU Cache 失
效的现象称为伪共享(False Sharing)
2.3、避免伪共享的方法
因此,对于多个线程共享的热点数据,即经常会修改的数据,应该避免这些数据刚好在同⼀个 Cache Line 中,否则就会出现为伪共享的问题。接下来,看看在实际项⽬中是⽤什么⽅式来避免伪共享的问题的。在 Linux 内核中存在 __cacheline_aligned_in_smp 宏定义,是⽤于解决伪共享的问题我们可以使⽤上⾯介绍的宏定义,将 b 的地址设置为 Cache Line 对⻬地址,如下
所以,避免 Cache 伪共享实际上是⽤空间换时间的思想,浪费⼀部分 Cache 空间,从⽽换来性能的提升。
再回到我们文章开头中的引入案例中,就可以明白,为什么我们在第二个测试案例中,性能会提升这么多。 其实就是因为我们在cacheline1前p0-p6,在cacheline2中也定义了p9~p15的变量。按照一个cacheline 64字节来计算,long类型的p0~p6 再加上cacheline1 刚好64字节,p9~p15加上cacheline2 也是64字节,这样就可以保证这 cacheline1 和 cacheline2 一定是分布在 不同的cacheLine 中。
其实,抛开本文的案例,我们依旧可以在一些开源的代码中找到这样的处理方法,比如在 jdk1.8 版本中的高并发累加器LongAdder中,内部cell元素就用了@sun.misc.Contended,这个注解原理就是通过继承的方式来填充属性,保证多个变量分布在不同的cacheline中。此外,在超高并发的Disruptor内存队列中 中有⼀个 RingBuffer,也是通过字节填充 + 继承的⽅式,来避免伪共享的问题,有兴趣的读者可以自行翻阅源码。
3、总结
在实际生产项目中,我们很难通过工具嗅探到cpu缓存伪共享的发生。但是该现象发生有一个共同特点,就是多个线程共享的热点数据,即经常会修改的数据,应该避免这些数据刚好在同⼀个 Cache Line 中。