CPU多级缓存

Takiya 2020-11-05
854

1. 基本概念

大致关系:CPU Cache --> 前端总线FSB --> Memory内存

CPU 为了更快的执行代码。于是当从内存中读取数据时,并不是只读自己想要的部分。而是读取足够的字节来填入高速缓存行。根据不同的 CPU ,高速缓存行大小不同。如 X86 是 32BYTES ,而 ALPHA 是 64BYTES 。并且始终在第 32 个字节或第 64 个字节处对齐。这样,当 CPU 访问相邻的数据时,就不必每次都从内存中读取,提高了速度。 因为访问内存要比访问高速缓存用的时间多得多。

1.1 总线概念

前端总线(FSB)就是负责将CPU连接到内存的一座桥,前端总线频率则直接影响CPU与内存数据交换速度,如果FSB频率越高,说明这座桥越宽,可以同时通过的车辆越多,这样CPU处理的速度就更快。目前PC机上CPU前端总线频率有533MHz、800MHz、1066MHz、1333MHz、1600MHz等几种,前端总线频率越高,CPU与内存之间的数据传输量越大。
前端总线——Front Side Bus(FSB),是将CPU连接到北桥芯片的总线。选购主板和CPU时,要注意两者搭配问题,一般来说,前端总线是由CPU决定的,如果主板不支持CPU所需要的前端总线,系统就无法工作

1.2 频率与降频的概念

只支持1333内存频率的cpu和主板配1600内存条就会降频。核心跟ddr2和ddr3没关系,核心数是cpu本身的性质,cpu是四核的就是四核的,是双核的就是双核的。
如果只cpu支持1333,而主板支持1600,那也会降频;cpu支1600而主板只支持1333那不仅内存会降频,而且发挥不出cpu全部性能。
另外如果是较新的主板cpu,已经采用新的qpi总线,而不是以前的fsb总线。
以前的fsb总线一般是总线为多少就支持多高的内存频率。而qpi总线的cpu集成了内存控制器,5.0
gt/s的cpu可能只支持1333内存频率,但是总线带宽相当于1333内存的内存带宽的两倍,这时候,组成1333双通道,内存速度就会倍,相当于2666的内存频率。

1.3 cache line

Cache Line可以简单的理解为CPU Cache中的最小缓存单位。目前主流的CPU Cache的Cache Line大小都是64Bytes。假设我们有一个512字节的一级缓存,那么按照64B的缓存单位大小来算,这个一级缓存所能存放的缓存个数就是512/64 = 8个。

1.4 cpu内存架构

内存架构

1.5 CPU多级缓存架构-现代CPU多级缓存

高速缓存L1、L2、L3;

多级缓存

级别越小的缓存,越接近CPU, 意味着速度越快且容量越少。

L1是最接近CPU的,它容量最小,速度最快,每个核上都有一个L1 Cache(准确地说每个核上有两个L1 Cache, 一个存数据 L1d Cache, 一个存指令 L1i Cache);

L2 Cache 更大一些,例如256K,速度要慢一些,一般情况下每个核上都有一个独立的L2 Cache;二级缓存就是一级缓存的缓冲器:一级缓存制造成本很高因此它的容量有限,二级缓存的作用就是存储那些CPU处理时需要用到、一级缓存又无法存储的数据。

L3 Cache是三级缓存中最大的一级,例如12MB,同时也是最慢的一级,在同一个CPU插槽之间的核共享一个L3 Cache。三级缓存和内存可以看作是二级缓存的缓冲器,它们的容量递增,但单位制造成本却递减。

当CPU运作时,它首先去L1寻找它所需要的数据,然后去L2,然后去L3。如果三级缓存都没找到它需要的数据,则从内存里获取数据。寻找的路径越长,耗时越长。所以如果要非常频繁的获取某些数据,保证这些数据在L1缓存里。这样速度将非常快。下表表示了CPU到各缓存和内存之间的大概速度:

从CPU到 大约需要的CPU周期 大约需要的时间(单位ns)
寄存器 1 cycle
L1 Cache 3-4 cycles 0.5-1 ns
L2 Cache 10-20 cycles 3-7 ns
L3 Cache 40-45 cycles 15 ns
跨槽传输 20 ns
内存 120-240 cycles 60-120 ns

1.6 缓存一致性

试想下面这样一个情况。

1. CPU1 读取了一个字节,以及它和它相邻的字节被读入 CPU1 的高速缓存。

2. CPU2 做了上面同样的工作。这样 CPU1 , CPU2 的高速缓存拥有同样的数据。

3. CPU1 修改了那个字节,被修改后,那个字节被放回 CPU1 的高速缓存行。但是该信息并没有被写入 RAM 。

4. CPU2 访问该字节,但由于 CPU1 并未将数据写入 RAM ,导致了数据不同步。

为了解决这个问题,芯片设计者制定了一个规则。当一个 CPU 修改高速缓存行中的字节时,计算机中的其它 CPU 会被通知,它们的高速缓存将视为无效。于是,在上面的情况下, CPU2 发现自己的高速缓存中数据已无效, CPU1 将立即把自己的数据写回 RAM ,然后 CPU2 重新读取该数据。 可以看出,高速缓存行在多处理器上会导致一些不利。

缓存一致性:处理器上提供的缓存协议,保证了缓存一致性。

缓存行(Cache line):缓存存储数据的单元。64Byte

缓存协议MESI,MESI 是指4中状态的首字母。每个Cache line有4个状态,可用2个bit表示,它们分别是:

状态 描述 监听任务
M修改(Modified) 该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。 缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行。
E 独享、互斥 (Exclusive) 该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。 缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。
S 共享 (Shared) 该Cache line有效,数据和内存中的数据一致,数据存在于很多核的Cache中。 缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。
I 无效 (Invalid) 该Cache line无效。

注意:

对于M和E状态而言总是精确的,他们在和该缓存行的真正状态是一致的,而S状态可能是非一致的。如果一个缓存将处于S状态的缓存行作废了,而另一个缓存实际上可能已经独享了该缓存行,但是该缓存却不会将该缓存行升迁为E状态,这是因为其它缓存不会广播他们作废掉该缓存行的通知,同样由于缓存并没有保存该缓存行的copy的数量,因此(即使有这种通知)也没有办法确定自己是否已经独享了该缓存行。

从上面的意义看来E状态是一种投机性的优化:如果一个CPU想修改一个处于S状态的缓存行,总线事务需要将所有该缓存行的copy变成invalid状态,而修改E状态的缓存不需要使用总线事务。

说明:

  • E:execusive 状态,数据在本地缓存中,且与内存中数据一致;其他的CPU缓存中没有:

E状态

  • S:shared状态:每个核心的缓存都一样,且与内存一致:

S状态

  • M和I状态:其中一个核心修改了值,那么其他的核心的缓存失效

M和I状态

  • 四钟状态的更新路线图

路线图

高效的状态: E, S

低效的状态: I, M

这四种状态,保证CPU内部的缓存数据是一致的,但是,并不能保证是强一致性。

理解该图的前置说明:

  1. 触发事件
触发事件 描述
本地读取(Local Read) 本地cache读取本地cache数据
本地写入(Local write) 本地cache写入本地cache数据
远端读取(Remote read) 其他cache读取本地cache数据
远端写入(Remote write) 其他cache写入本地cache数据
  1. cache分类:

    前提:所有cache共同缓存了主存中的某条数据。

    本地cache:指当前cpu的cache。
    触发cache:触发读写事件的cache。
    其他cache:指既除了以上两种之外的cache。
    注意:本地的事件触发 本地cache和触发cache为相同。

1.7 伪共享

伪共享产生的原因主要就是因为缓存行失效,这里使用伪共享的图进行讲解:

伪共享

上图中显示的是一个槽的情况,里面是多个core, 如果core1上面的线程更新了变量X,根据MESI协议,那么变量X对应的所有缓存行都会失效,这个时候如果core2中的线程进行读取变量Y,发现缓存行失效,就会按照缓存查找策略,往上查找,如果core1对应的线程更新变量X后又访问了变量X,那么左侧的L1、L2和槽内的L3 缓存行都会得到生效。这个时候core2线程可以在L3 Cache 中得到生效的数据,否则的话(即core1对应的线程更新X后没有访问X)core2的线程就只能从主内存中获取数据,对性能就会造成很大的影响,这就是伪共享。

表面上 X 和 Y 都是被独立线程操作的,而且两操作之间也没有任何关系。只不过它们共享了一个缓存行,但所有竞争冲突都是来源于共享。

Java中解决伪共享

声明类的时候使用注释sun.misc.Contended

相关博客

2.内存屏障

2.1 什么是内存屏障

内存屏障其实就是一个CPU指令,在硬件层面上来说可以分为两种:Load Barrier 和 Store Barrier即读屏障和写屏障。主要有两个作用:

  1. 阻止屏障两侧的指令重排序;

  2. 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。

屏障类型:

屏障类型 指令示例 说明
LoadLoad Load1;
LoadLoad;
Load2;
保证load1的读操作先于load2执行
StoreStore Store1;
StoreStore;
Store2;
保证store1的写操作先于store2执行,并刷新到主内存
LoadStore Load1;
LoadStore;
Store2;
保证load1的读操作先于load2的写操作执行
StoreLoad Store1;
StoreLoad;
Load2;
保证store1的写操作已刷新到主内存之后,load2及其后的读操作才能执行

2.2 内存屏障分类

内存屏障有三种类型和一种伪类型:

  1. ifence:即读屏障(Load Barrier),在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据,以保证读取的是最新的数据。
  2. sfence:即写屏障(Store Barrier),在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存,以保证写入的数据立刻对其他线程可见。
  3. mfence,即全能屏障,具备ifence和sfence的能力。
  4. Lock前缀:Lock不是一种内存屏障,但是它能完成类似全能型内存屏障的功能。

2.3 Volatile是如何保证可见性的

加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障,它有三个功能:

  1. 确保指令重排时不会把其后面的指令重排到内存屏障之前的位置,也不会把前面的指令排到内存屏障后面。
  2. 将当前处理器缓存行的数据立即写回系统内存。
  3. 写回内存的操作引起其他CPU里缓存行失效,当处理器要对这个值进行修改时,会强制重新从系统内存里把数据读取到缓存中。

问题

既然CPU有了MESI协议可以保证cache的一致性,那么为什么还需要volatile这个关键词来保证可见性(内存屏障)?或者是只有加了volatile的变量在多核cpu执行的时候才会触发缓存一致性协议?

两个解释结论:

  1. 多核情况下,所有的cpu操作都会涉及缓存一致性的校验,只不过该协议是弱一致性,不能保证一个线程修改变量后,其他线程立马可见,也就是说虽然其他CPU状态已经置为无效,但是当前CPU可能将数据修改之后又去做其他事情,没有来得及将修改后的变量刷新回主存,而如果此时其他CPU需要使用该变量,则又会从主存中读取到旧的值。而volatile则可以保证可见性,即立即刷新回主存,修改操作和写回操作必须是一个原子操作;
  2. 正常情况下,系统操作并不会进行缓存一致性的校验,只有变量被volatile修饰了,该变量所在的缓存行才被赋予缓存一致性的校验功能。

3. 经典单例模式变种

饱汉模式

基础的饱汉

饱汉,即已经吃饱,不着急再吃,饿的时候再吃。所以他就先不初始化单例,等第一次使用的时候再初始化,即“懒加载”

public class Singleton1 { private static Singleton1 singleton = null; private Singleton1() { } public static Singleton1 getInstance() { if (singleton == null) { singleton = new Singleton1(); } return singleton; } }

饱汉模式的核心就是懒加载。好处是更启动速度快、节省资源,一直到实例被第一次访问,才需要初始化单例;小坏处是写起来麻烦,大坏处是线程不安全,if语句存在竞态条件

饱汉—变种 1

最粗暴的犯法是用synchronized关键字修饰getInstance()方法,这样能达到绝对的线程安全。

public class Singleton1_1 { private static Singleton1_1 singleton = null; private Singleton1_1() { } public synchronized static Singleton1_1 getInstance() { if (singleton == null) { singleton = new Singleton1_1(); } return singleton; } }

变种1的好处是写起来简单,且绝对线程安全;坏处是并发性能极差,事实上完全退化到了串行。单例只需要初始化一次,但就算初始化以后,synchronized的锁也无法避开,从而getInstance()完全变成了串行操作。性能不敏感的场景建议使用

饱汉—变种 2

变种2是“臭名昭著”的DCL 1.0

针对变种1中单例初始化后锁仍然无法避开的问题,变种2在变种1的外层又套了一层check,加上synchronized内层的check,即所谓“双重检查锁”(Double Check Lock,简称DCL)。

public class Singleton1_2 { private static Singleton1_2 singleton = null; public int f1 = 1; // 触发部分初始化问题 public int f2 = 2; private Singleton1_2() { } public static Singleton1_2 getInstance() { // may get half object if (singleton == null) { synchronized (Singleton1_2.class) { if (singleton == null) { singleton = new Singleton1_2(); } } } return singleton; } }

问题出现在这行简单的赋值语句:

instance = new Singleton();

它并不是一个原子操作。事实上,它可以”抽象“为下面几条JVM指令:

memory = allocate(); //1:分配对象的内存空间 initInstance(memory); //2:初始化对象(对f1、f2初始化) instance = memory; //3:设置instance指向刚分配的内存地址

上面操作2依赖于操作1,但是操作3并不依赖于操作2,所以JVM可以以“优化”为目的对它们进行重排序,经过重排序后如下:

memory = allocate(); //1:分配对象的内存空间 instance = memory; //3:设置instance指向刚分配的内存地址(此时对象还未初始化) ctorInstance(memory); //2:初始化对象

可以看到指令重排之后,操作 3 排在了操作 2 之前,即引用instance指向内存memory时,这段崭新的内存还没有初始化——即,引用instance指向了一个”被部分初始化的对象”。此时,如果另一个线程调用getInstance方法,由于instance已经指向了一块内存空间,从而if条件判为false,方法返回instance引用,用户得到了没有完成初始化的“半个”单例。

饱汉—变种 3

变种3专门针对变种2,可谓DCL 2.0

针对变种3的“半个对象”问题,变种3在instance上增加了volatile关键字

public class Singleton1_3 { private static volatile Singleton1_3 singleton = null; public int f1 = 1; public int f2 = 2; private Singleton1_3() { } public static Singleton1_3 getInstance() { if (singleton == null) { synchronized (Singleton1_3.class) { // must be a complete instance if (singleton == null) { singleton = new Singleton1_3(); } } } return singleton; } }

注意:

DCL的实现方式实际非常丑陋不建议使用,这里只是为了体会volatile的作用。多线程环境下真正优秀的单例模式实现方式可以参考以下两种

饿汉模式

与饱汉相对,饿汉很饿,只想着尽早吃到。所以他就在最早的时机,即类加载时初始化单例,以后访问时直接返回即可。

public class Singleton2 { private static final Singleton2 singleton = new Singleton2(); private Singleton2() { } public static Singleton2 getInstance() { return singleton; } }

饿汉的好处是天生的线程安全(得益于类加载机制),写起来超级简单,使用时没有延迟;坏处是有可能造成资源浪费(如果类加载后就一直不使用单例的话)。

值得注意的时,单线程环境下,饿汉与饱汉在性能上没什么差别;但多线程环境下,由于饱汉需要加锁,饿汉的性能反而更优。

Holder模式

我们既希望利用饿汉模式中静态变量的方便和线程安全;又希望通过懒加载规避资源浪费。Holder模式满足了这两点要求:核心仍然是静态变量,足够方便和线程安全;通过静态的Holder类持有真正实例,间接实现了懒加载。

public class Singleton3 { private static class SingletonHolder { private static final Singleton3 singleton = new Singleton3(); private SingletonHolder() { } } private Singleton3() { } public static Singleton3 getInstance() { return SingletonHolder.singleton; } }

相对于饿汉模式,Holder模式仅增加了一个静态内部类的成本,与饱汉的变种3效果相当(略优),都是比较受欢迎的实现方式。

Holder模式缺陷

无法防范反射攻击和反序列化攻击

public static void main(String[] args) throws Exception { Singleton singleton = Singleton.getInstance(); Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor(); constructor.setAccessible(true); Singleton newSingleton = constructor.newInstance(); System.out.println(singleton == newSingleton); }

上述代码运行结果为false

public class Singleton implements Serializable { private static class SingletonHolder { private static Singleton instance = new Singleton(); } private Singleton() { } public static Singleton getInstance() { return SingletonHolder.instance; } public static void main(String[] args) { Singleton instance = Singleton.getInstance(); byte[] serialize = SerializationUtils.serialize(instance); Singleton newInstance = SerializationUtils.deserialize(serialize); System.out.println(instance == newInstance); } }

上述代码使用commons中的序列化工具SerializationUtils就可以破坏单例模式原则

「喜欢文章,快来给作者赞赏墨值吧」
【版权声明】本文为墨天轮用户原创内容,转载时必须标注文章的来源(墨天轮),文章链接,文章作者等基本信息,否则作者和墨天轮有权追究责任。如果您发现墨天轮中有涉嫌抄袭或者侵权的内容,欢迎发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论