CPU Cache与缓存行
Posted 钟齐峰的博客
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了CPU Cache与缓存行相关的知识,希望对你有一定的参考价值。
编译环境:windows10+Idea+x86 CPU。
1、CPU Cache
CPU 访问内存时,首先查询 cache 是否已缓存该数据。如果有,则返回数据,无需访问内存;如果不存在,则需把数据从内存中载入 cache,最后返回给理器。在处理器看来,缓存是一个透明部件,旨在提高处理器访问内存的速率,所以从逻辑的角度而言,编程时无需关注它,但是从性能的角度而言,理解其原理和机制有助于写出性能更好的程序。Cache 之所以有效,是因为程序对内存的访问存在一种概率上的局部特征:
- Spatial Locality:对于刚被访问的数据,其相邻的数据在将来被访问的概率高。
- Temporal Locality:对于刚被访问的数据,其本身在将来被访问的概率高。
下图是计算机存储的基本结构。L1、L2、L3分别表示一级缓存、二级缓存、三级缓存。越靠近CPU的缓存,速度越快,容量也越小。L1缓存小但很快,并且紧靠着在使用它的CPU内核。分为指令缓存和数据缓存;L2大一些,也慢一些,并仍然只能被一个单独的CPU核使用;L3更大、更慢,并且被单个插槽上的所有CPU核共享;最后是主存,由全部插槽上的所有CPU核共享。
当CPU执行运算的时候,它先去L1查找所需的数据、再去L2、然后是L3,如果最后这些缓存中都没有,所需的数据就要去主内存拿。走得越远,运算耗费的时间就越长。所以要尽量确保数据在L1缓存中。
Martin和Mike的 QCon presentation 演讲中给出了一些缓存未命中的消耗数据,也就是从CPU访问不同层级数据的时间概念:
可见CPU读取主存中的数据会比从L1中读取慢了近60-80倍。
2、Cache Line
Cache是由很多个 Cache line 组成的。Cache line 是 cache 和 RAM 交换数据的最小单位,通常为 64 Byte。当 CPU 把内存的数据载入 cache 时,会把临近的共 64 Byte 的数据一同放入同一个Cache line,因为空间局部性:临近的数据在将来被访问的可能性大。
由于缓存行的特性,当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享(下面会介绍到)。有人将伪共享描述成无声的性能杀手,因为从代码中很难看清楚是否会出现伪共享问题。
需要注意,数据在缓存中不是以独立的项来存储的,它不是我们认为的一个独立的变量,也不是一个单独的指针,它是有效引用主存中的一块地址。一个Java的long类型是8字节,因此在一个缓存行中可以存8个long类型的变量。
以大小为 32 KB,cache line 的大小为 64 Byte 的L1级缓存为例,对于不同存放规则,其硬件设计也不同,下图简单表示一种设计:
缓存行的这种特性也决定了在访问同一缓存行中的数据时效率是比较高的。比如当你访问java中的一个long类型的数组,当数组中的一个值被加载到缓存中,它会额外加载另外7个,因此可以非常快速的遍历这个数组。实际上,你可以非常快速的遍历在连续的内存块中分配的任意数据结构。
3、False Sharing(伪共享)
处理器为了提高处理速度,不直接和内存进行通讯,而是先将系统内存的数据读到内部缓存(L1,L2,L3)后再进行操作,但操作完之后不知道何时会写到内存;如果对声明了volatile 变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在的缓存行的数据写回到系统内存。但就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读取到处理器缓存里。
为了说明伪共享问题,下面举一个例子进行说明:两个线程分别对两个变量(刚好在同一个缓存行)分别进行读写的情况分析。
在core1上线程需要更新变量X,同时core2上线程需要更新变量Y。这种情况下,两个变量就在同一个缓存行中。每个线程都要去竞争缓存行的所有权来更新对应的变量。如果core1获得了缓存行的所有权,那么缓存子系统将会使core2中对应的缓存失效。相反,如果core2获得了所有权然后执行更新操作,core1就要使自己对应的缓存行失效。这里需要注意:整个操作过程是以缓存行为单位进行处理的,这会来来回回的经过L3缓存,大大影响了性能,每次当前线程对缓存行进行写操作时,内核都要把另一个内核上的缓存块无效掉,并重新读取里面的数据。如果相互竞争的核心位于不同的插槽,就要额外横跨插槽连接,效率可能会更低。
3、缓存对齐
基于以上问题的分析,在一些情况下,比如会频繁进行操作的数据,可以根据缓存行的特性进行缓存行对齐(即将要操作的数据凑一个缓存行进行操作)下面使用一个示例进行说明:
public final class T { public static class X{ //8字节 private volatile long x = 0L; } private static X[] arr = new X[2]; static { arr[0] = new X(); arr[1] = new X(); } public static void main(String[] args) throws InterruptedException { Thread thread1 = new Thread(()->{ for(long i = 0;i < 1000_0000L;i++){ //volatile的缓存一致性协议MESI或者锁总线,会消耗时间 arr[0].x = i; } }); Thread thread2 = new Thread(()->{ for(long i = 0;i< 1000_0000L;i++){ arr[1].x = i; } }); long startTime = System.nanoTime(); thread1.start(); thread2.start(); thread1.join(); thread2.join(); System.out.println("总计消耗时间:"+(System.nanoTime()-startTime)/100_000); } }
运行结果如下:
总计消耗时间:4645
升级改造运行,实现缓存行对齐,重点代码如下:
private static class Padding{ //7*8字节 public volatile long p1,p2,p3,p4,p5,p6,p7; } public static class T extends Padding{ //8字节 private volatile long x = 0L; }
通过上述代码做缓存对齐,每次都会有初始的7*8个占位,加上最后一个就是独立的一块缓存行,整理后代码如下:
public final class T { private static class Padding{ //7*8字节 public volatile long p1,p2,p3,p4,p5,p6,p7; } public static class X extends Padding{ //8字节 private volatile long x = 0L; } private static X[] arr = new X[2]; static { arr[0] = new X(); arr[1] = new X(); } public static void main(String[] args) throws InterruptedException { Thread thread1 = new Thread(()->{ for(long i = 0;i < 1000_0000L;i++){ //volatile的缓存一致性协议MESI或者锁总线,会消耗时间 arr[0].x = i; } }); Thread thread2 = new Thread(()->{ for(long i = 0;i< 1000_0000L;i++){ arr[1].x = i; } }); long startTime = System.nanoTime(); thread1.start(); thread2.start(); thread1.join(); thread2.join(); System.out.println("总计消耗时间:"+(System.nanoTime()-startTime)/100_000); } }
运行结果如下:
总计消耗时间:868
从上面可以看到,使用缓存对齐,相同操作情况下对齐后的时间比没对齐的时间有飞跃的提速。
这种缓存行填充的方法在早期是比较流行的一种解决办法,比较有名的Disruptor框架就采用了这种解决办法提高性能,Disruptor是一个线程内通信框架,用于线程里共享数据。与LinkedBlockingQueue类似,提供了一个高速的生产者消费者模型,广泛用于批量IO读写,在硬盘读写相关的程序中应用十分广泛,Apache旗下的HBase、Hive、Storm等框架都有使用Disruptor。
4、Cache Line伪共享解决方案
处理伪共享的两种方式:
- 字节填充:增大元素的间隔,使得不同线程存取的元素位于不同的cache line上,典型的空间换时间。
- 在每个线程中创建对应元素的本地拷贝,结束后再写回全局数组。
Java6 中实现字节填充:
public class PaddingObject{ public volatile long value = 0L; // 实际数据 public long p1, p2, p3, p4, p5, p6; // 填充 }
PaddingObject 类中需要保存一个 long 类型的 value 值,如果多线程操作同一个 CacheLine 中的 PaddingObject 对象,便无法完全发挥出 CPU Cache 的优势。
实际数据 value + 用于填充的 p1~p6 总共只占据了 7 * 8 = 56 个字节,而 Cache Line 的大小应当是 64 字节,这是有意而为之,在 Java 中,对象头还占据了 8 个字节,所以一个 PaddingObject 对象可以恰好占据一个 Cache Line。
Java7 中实现字节填充:
在 Java7 之后,一个 JVM 的优化给字节填充造成了一些影响,上面的代码片段 public long p1, p2, p3, p4, p5, p6;
会被认为是无效代码被优化掉,有回归到了伪共享的窘境之中。
为了避免 JVM 的自动优化,需要使用继承的方式来填充。
abstract class AbstractPaddingObject{ protected long p1, p2, p3, p4, p5, p6;// 填充 } public class PaddingObject extends AbstractPaddingObject{ public volatile long value = 0L; // 实际数据 }
Java8 中实现字节填充:
//JDK 8中提供的注解 @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.FIELD, ElementType.TYPE}) public @interface Contended { /** * The (optional) contention group tag. * This tag is only meaningful for field level annotations. * * @return contention group tag. */ String value() default ""; }
在 JDK 8 里提供了一个新注解@Contended,可以用来减少false sharing的情况。JVM在计算对象布局的时候就会自动把标注的字段拿出来并且插入合适的大小padding。
因为这个功能暂时还是实验性功能,暂时还没到默认普及给用户代码用的程度。要在用户代码(非bootstrap class loader或extension class loader所加载的类)中使用@Contended注解的话,需要使用 -XX:-RestrictContended 参数。
代码优化如下:
public final class T { @sun.misc.Contended public static class X { //8字节 private volatile long x = 0L; } private static X[] arr = new X[2]; static { arr[0] = new X(); arr[1] = new X(); } public static void main(String[] args) throws InterruptedException { Thread thread1 = new Thread(()->{ for(long i = 0;i < 1000_0000L;i++){ //volatile的缓存一致性协议MESI或者锁总线,会消耗时间 arr[0].x = i; } }); Thread thread2 = new Thread(()->{ for(long i = 0;i< 1000_0000L;i++){ arr[1].x = i; } }); long startTime = System.nanoTime(); thread1.start(); thread2.start(); thread1.join(); thread2.join(); System.out.println("总计消耗时间:"+(System.nanoTime()-startTime)/100_000); } }
运行结果如下:
总计消耗时间:870
比如在JDK 8 的ConcurrentHashMap 源码中,使用 @sun.misc.Contended
对静态内部类 CounterCell 进行了修饰。
/* ---------------- Counter support -------------- */ /** * A padded cell for distributing counts. Adapted from LongAdder * and Striped64. See their internal docs for explanation. */ @sun.misc.Contended static final class CounterCell { volatile long value; CounterCell(long x) { value = x; } }
Thread 线程类的源码中,使用 @sun.misc.Contended 对成员变量进行修饰。
// 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. /** 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;
一款优秀的开源框架 Disruptor 中的一个数据结构 RingBuffer使用字节填充和继承的方式来避免伪共享。
abstract class RingBufferPad { protected long p1, p2, p3, p4, p5, p6, p7; } abstract class RingBufferFields<E> extends RingBufferPad{}
以上是关于CPU Cache与缓存行的主要内容,如果未能解决你的问题,请参考以下文章