文章目录
- 多级缓存-填补内存读写速度与CPU计算速度的鸿沟
- 局部性原理与Cache Line
- 伪共享
-
- 对齐填充
- @Contended
- 备注
- 尾巴
对于一个程序来说,几乎所有的计算任务都不可能仅通过CPU的计算就可以完成,它至少要和内存打交道:读取运算数据、写入运算结果。
现代CPU的算力已经十分强大,相比之下存储设备的IO读写速度却发展的十分缓慢。通常情况下,内存每完成一次读写操作,CPU已经可以进行上百次的运算,为了填补两者速度上的鸿沟,现代CPU不得不加入一层或多层读写速度接近于CPU处理速度的高速缓存Cache。CPU会将计算需要的数据读取到缓存中,让计算可以快速进行,当计算结束后,再将计算结果统一写回到主存中,这样CPU就不需要缓慢的等待内存读写了。
多级缓存-填补内存读写速度与CPU计算速度的鸿沟
笔者画了一个简图,大概表示现代CPU的缓存架构,如下:
各级缓存的读写速度如下:
缓存 | 时钟周期(大约) | 时间(大约) |
---|---|---|
主存 | - | 80ns |
L3 | 40 | 15ns |
L2 | 10 | 3ns |
L1 | 3~4 | 1ns |
寄存器 | 1 | - |
越靠近CPU的缓存读写速度越快,相应的 容量越小、且成本越高。
当CPU需要读取数据时,首先从最近的缓存开始找,找不到就逐层往上寻找,如果命中缓存,就无需再从主存中去读取,直接拿来计算。反之从主存中加载数据并依次写入多级缓存中,下次就可以直接从缓存中读数据了。
局部性原理与Cache Line
CPU访问存储器时,无论是存取指令还是存取数据,所访问的存储单元都趋于聚集在一个较小的连续区域中。
- 当我们需要从内存中读取一个int变量i的值时,CPU真的只会仅仅将这个4字节的i加载到缓存吗?
答案是:NO!!!
当我们去读取一个4字节的int变量时,计算机认为程序接下来很大概率会访问相邻的数据,于是会把相邻的数据给一块儿加载到缓存中,下次再读取时,就不用访问主存了,直接从缓存中读取就可以了,减少了CPU访问主存的次数,提高缓存的命中率。
说白了,CPU读取数据时,总是会一个块一个块的读,哪怕你需要的仅仅是1个字节的数据,这个【块】就被称为【Cache Line】。
不同的CPU,Cache Line大小是不一样的,Intel的CPU大部分都是64字节。
多级缓存就是由若干个Cache Line组成的,CPU每次从主存中拉取数据时,会把相邻的数据也存入同一个Cache Line。
如下测试代码,各自读取一千万次数据,Cache Line失效的耗时11ms,能很好的利用Cache Line特性的只需要3ms。
public class CacheLine {
static int length = 8 * 10000000;
static long[] arr = new long[length];
public static void main(String[] args) {
long temp;// 无特殊含义,读取出来的数据赋值
// 1.每次读取都跳8个,下次读取的数据一定不在上次读取的Cache Line中,缓存全部未命中
long start = System.currentTimeMillis();
for (int i = 0; i < length; i += 8) {
temp = arr[i];
}
long end = System.currentTimeMillis();
System.out.println(end - start);// 11ms
// 2.顺序读取,Cache Line生效,只读前8分之1的数据
start = System.currentTimeMillis();
for (int i = 0; i < length / 8; i++) {
temp = arr[i];
}
end = System.currentTimeMillis();
System.out.println(end - start);// 3ms
}
}
伪共享
当多个线程去同时读写共享变量时,由于缓存一致性协议,只要Cache Line中任一数据失效,整个Cache Line就会被置为失效。这就会导致本来相互不影响的数据,由于被分配在同一个Cache Line中,双方在写数据时,导致对方的Cache Line不断失效,无法利用Cache Line缓存特性的现象就被称为【伪共享】。
如下代码,启动两个线程,分别修改共享变量a和b,由于a个b一共占用16字节,可以被分配进同一个Cache Line中,本来互相不影响的两个线程修改数据,但是由于a个b被分配到同一个Cache Line中,导致对方的Cache Line不断失效,不断的重新发起load指令重主存中重新加载数据,降低程序的性能。
public class FalseShare {
static volatile long a;
static volatile long b;
public static void main(String[] args) throws InterruptedException {
CountDownLatch cdl = new CountDownLatch(2);
long t1 = System.currentTimeMillis();
new Thread(()->{
for (long i = 0; i < 1_0000_0000L; i++) {
// 线程只改a
FalseShare.a = i;
}
cdl.countDown();
}).start();
new Thread(()->{
for (long i = 0; i < 1_0000_0000L; i++) {
// 线程只改b
FalseShare.b = i;
}
cdl.countDown();
}).start();
cdl.await();
long t2 = System.currentTimeMillis();
System.err.println(t2 - t1);
}
}
程序运行结果:耗时2782ms。
对齐填充
要想解决上面的伪共享问题也很简单,既然一个Cache Line存放64字节的数据,只要在a和b变量之间填充7个无意义的Long变量,占满64字节,这样a和b就无法被分配进同一个Cache Line中,线程之间修改数据互不影响,就没有上面的问题了。
解决方法如下:
程序运行结果:耗时752ms。
@Contended
Cache Line对齐其实是一种比较low的解决办法,因为你无法判断你写的程序会被放到哪种CPU上运行,不同的CPU它的Cache Line大小是不一样的,如果超过了64字节,填充7个long变量就没有效果了,还有没有更好的解决办法呢???
JDK8引入了一个新的注解@Contended
,被它修饰的变量,会被存放到一个单独的Cache Line中,不会和其他变量共享Cache Line。
修改后如下:
程序运行结果:耗时744ms,Cache Line是生效的。
备注
JDK8的老版本中@Contended默认是禁用的,需要手动开启:
-XX:-RestrictContended
。笔者的JDK版本为【1.8.0_191】
,-XX:-RestrictContended
参数已经没有用了,默认都会开启对@Contended注解的支持。
尾巴
理解硬件设计对编写高性能程序是有必要的!!!