前言
2019年的最后一篇技术文了(明天写总结),找个细碎的话题随便聊聊吧。本文的知识点在之前的文章中隐蔽地出现过。
CPU缓存简介
CPU高速缓存(cache)是现代中央处理器的必备部件。它位于CPU package内部,在金字塔形存储体系中,距离计算单元的距离是各存储部件中第二近的,存取速度也是第二快的,仅次于寄存器。
CPU在读内存时,会先尝试从缓存读取,如果缓存未命中,再从内存读取目标数据与其相邻的数据,并放入缓存。CPU在写内存时,也会首先写缓存,然后再批量将缓存数据写回内存。根据局部性原理:
时间局部性(temporal locality):如果一条数据正在被访问,那么在不久的将来它很可能再次被访问。
空间局部性(spatial locality):如果一条数据正在被访问,那么在不久的将来与它相邻的数据有可能也被访问。
所以缓存是加速CPU访存的利器。
不同的处理器微架构可以有不同的缓存方案。Intel/AMD的桌面级处理器采用的缓存都是3级的,分别称为L1、L2和L3缓存。从I家的Skylake PPT上抄一张图来描述缓存层级结构吧。
可见,L1和L2缓存是每个核心都有的,并且L1缓存还分为数据(D)缓存和指令(I)缓存,L3缓存则是由所有核心共享的。MC和DDR则分别表示内存控制器与内存模组。
以笔者自用MBP中的Intel Core i9-9880H处理器(8核16线程)为例,用MacCPUID软件查看缓存信息如下,其中的Size和Max Threads属性都是与上文的框图相符的。
Assoc(associative)属性是缓存与内存之间的组相连映射关系,这不是本文要讨论的,不再赘述。Line Size属性则表示一个缓存行的大小,目前基本都为64个字节。缓存行是CPU缓存组织的最小单位,它比较重要,请务必牢记。
伪共享与缓存行填充
用Java来举例,考虑如下代码(availableProcessors除以2是为了减去超线程的影响)。
class VolatileLong {
volatile long value;
// long padding1, padding2, padding3, padding4, padding5;
}
class VolatileLongThread extends Thread {
private VolatileLong[] shares;
private int index;
VolatileLongThread(VolatileLong[] shares, int index) {
this.shares = shares;
this.index = index;
}
@Override
public void run() {
for (int i = 0; i < 1000000; i++) {
shares[index].value++;
}
}
}
public class FalseSharingExample {
public static void main(String[] args) throws Exception {
int processorsNum = Runtime.getRuntime().availableProcessors() / 2;
System.out.println("Processor num: " + processorsNum);
for (int i = 0; i < 10; i++) {
VolatileLong[] shares = new VolatileLong[processorsNum];
for (int j = 0; j < processorsNum; j++) {
shares[j] = new VolatileLong();
}
Thread[] threads = new Thread[processorsNum];
for (int j = 0; j < processorsNum; j++) {
threads[j] = new VolatileLongThread(shares, j);
}
for (Thread t : threads) {
t.start();
}
long start = System.nanoTime();
for (Thread t : threads) {
t.join();
}
long end = System.nanoTime();
System.out.println((i + 1) + " loop costs " + (end - start) + " ns");
}
}
}
执行结果如下。
Processor num: 8
1 loop costs 132287965 ns
2 loop costs 142371998 ns
3 loop costs 115655744 ns
4 loop costs 120651836 ns
5 loop costs 122757797 ns
6 loop costs 121669394 ns
7 loop costs 110498861 ns
8 loop costs 123886526 ns
9 loop costs 123184849 ns
10 loop costs 120447527 ns
然后我们把注释掉的那行代码取消注释,重新运行,结果如下。
Processor num: 8
1 loop costs 62166652 ns
2 loop costs 62525585 ns
3 loop costs 58595315 ns
4 loop costs 41346343 ns
5 loop costs 9786560 ns
6 loop costs 9768927 ns
7 loop costs 7786844 ns
8 loop costs 46012633 ns
9 loop costs 47246131 ns
10 loop costs 52160379 ns
可见,执行效率有了明显的提升,最高可以达到之前的12倍多。到底是为什么呢?上文已经说过,CPU缓存行是64B大的。在64位JVM下,对取消注释之前的VolatileLong对象而言,它占用的空间大小为(关闭OOP压缩,即-XX:-UseCompressedOops
):
- 对象头mark word,8B;
- 对象头klass word,8B;
- volatile long value,8B。一共为24B。
也就是说,一个缓存行可以存储不止一个VolatileLong对象实例。而我们使用数组来维护VolatileLong,故它们在内存中是连续存储的。由于该类内的value成员已经使用volatile关键字来修饰,故CPU要保证它的修改对所有线程都立即可见,value的自增值会直接回写到内存,并将对应的缓存行置为失效(即MESI一致性协议中的I状态)。
发现什么问题了吗?在这种情况下,如果两个CPU核心对位于同一缓存行内的VolatileLong.value进行操作,那么它们会对这一行产生竞争,该缓存就会频繁失效,失去了作为缓存的作用,这就是伪共享(false sharing)。英文维基给出的定义如下:
In computer science, false sharing is a performance-degrading usage pattern that can arise in systems with distributed, coherent caches at the size of the smallest resource block managed by the caching mechanism. When a system participant attempts to periodically access data that will never be altered by another party, but those data share a cache block with data that are altered, the caching protocol may force the first participant to reload the whole unit despite a lack of logical necessity. The caching system is unaware of activity within this block and forces the first participant to bear the caching system overhead required by true shared access of a resource.
伪共享的产生如下图所示。
伪共享在SMP系统中是隐形的性能杀手。解决伪共享问题的最常用方法就是缓存行填充(cache line padding)。如果我们在上述类中加上padding1~padding5五个long字段,一个VolatileLong对象实例就会占用24 + 5 * 8 = 64B,刚好是一个缓存行的大小,消灭了竞争,效率自然就提高了。
当然,由于padding1~padding5并没有实际的含义,所以缓存行的利用率会降低,只有原来的3 / 8,这就是典型的用空间换时间的思路。
伪共享与缓存行填充的例子在之前已经提到过,看官可以参见《再谈JVM里的记忆集合》与《浅谈Java的伪随机数发生器和线性同余法》两篇文章。在后者还出现了缓存行填充的另一种方式,即JDK8中新增的@sun.misc.Contended
注解。为了稳妥,它支持对注解字段进行分组,并会在被注解的对象或字段前后都各加上128B的padding,详情请参见这里。
The End
写得实在是有些潦草。民那晚安。