一、CPU 缓存
CPU 缓存是位于 CPU 与内存之间的临时存储器,它的容量比内存小的多,但是交换速度比内存快得多。高速缓存主要是为了解决 CPU 运算速度与内存读写速度不匹配的矛盾,因为 CPU 运算速度比内存快得多,这样会使 CPU 花费很长时间等待数据或者将数据写入内存,当 CPU 调用大量数据时,可以先从缓存中调用,从而加快读取速度。
1、CPU 多级缓存
在 CPU 缓存出现不久,随着系统越来越复杂,高速缓存和主内存之间的速度被拉大,直到加入了另一级缓存。新加入的这级缓存比第一缓存更大,并且更慢,而且经济上不合适,所以有了二级缓存,甚至是三级缓存。每一级缓存中所储存的全部数据都是下一级缓存的一部分,这三种缓存的技术难度和制造成本是相对递减的,所以其容量也是相对递增的。当 CPU 要读取一个数据时,首先从一级缓存中查找,如果没有找到再从二级缓存中查找,如果还是没有就从三级缓存或内存中查找。一般来说,每级缓存的命中率大概都在 80% 左右,也就是说全部数据量的 80% 都可以在一级缓存中找到,只剩下 20% 的总数据量才需要从二级缓存、三级缓存或内存中读取,由此可见一级缓存是整个 CPU 缓存架构中最为重要的部分。
2、CPU 缓存一致性
多核 CPU 的情况下有多个一级缓存,如何保证缓存内部数据的一致,不让系统数据混乱。这里就引出了一个一致性的协议 MESI。
CPU 中每个缓存行使用四种状态进行标记:
- M(被修改,Modified):该缓存行只缓存在该 CPU 缓存中,并且是被修改过的,与主内存数据不一致,缓存行的数据需要在未来某个时间点写回主内存。当被写回主内存之后,该缓存行就会变成独享状态;
- E(独享的,Exclusive):该缓存行只被缓存在该 CPU 缓存中,并且与主存数据一致。当有其他 CPU 读取该内存时变成共享(shared)状态。同样的,当 CPU 修改该缓存内容时,该状态可以变成被修改状态;
- S(共享的,Shared):该状态意味着该缓存行可能被多个 CPU 缓存,并且各个缓存与主内存数据一致,当有一个 CPU 修改该缓存行时,其他 CPU 中该缓存行可以被作废(变成无效状态(Invalid));
- I(无效的,Invalid):该缓存是无效的(可能有其他 CPU 修改了该缓存行)。
在一个典型系统中,可能会有几个缓存共享主存总线,每个相应的 CPU 会发出读写请求,而缓存的目的就是减少 CPU 读写共享主存的次数。一个缓存除在 I 状态外都可以满足 CPU 的读请求,一个 invalid 的缓存行必须从主存中读取来满足该 CPU 的请求。
一个写请求必须是在 M 或 E 状态才能被执行,如果缓存行处于 S 状态,必须先将其它缓存中该缓存行变成 Invalid 状态(即不允许不同 CPU 同时修改同一缓存行,即使该缓存行中不同位置也不允许)。
一个处于 M 状态的缓存行必须时刻监控所有试图读取该缓存行相对主内存的操作,这种操作必须在缓存将该缓存行写回主内存并将状态变成 S 之前被延迟执行。
一个处于 S 状态的缓存行也必须时刻监听其他缓存行使该缓存行无效或者独享缓存行的请求,并将该缓存行变成无效。
一个处于 E 状态的缓存行也时刻监听其他缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成 S 状态。
对于 M 和 E 状态而言总是精确的,他们在和该缓存行的真正状态是一致的。而 S 状态可能是非一致的,如果一个缓存将处于 S 状态的缓存行作废,而另一个缓存实际可能已经独享了该缓存,但是该缓存却不会将该缓存行升迁为 E 状态,这是因为其他缓存不会广播他们作废掉该缓存行的通知。同样由于缓存并没有保存该缓存行的 copy 的数量,因此(即使有这种通知)也没有办法确定自己是否已经独享了该缓存行。
二、CPU 乱序执行
处理器为提高运算速度而做出的违背代码原有顺序的优化。例如:a=10;b=20;result=a+b; 正常顺序先执行 a,再执行 b,最后执行 a+b ,但假如 a 不在缓存中,b 在缓存中,因为 a 不在缓存中,需要从主内存读取,这样 b=20 的操作就需要等待 a 执行完,CPU 为了提高效率,先执行 b=20,再执行 a=10,最后执行 a+b,提高执行效率。
三、内存屏障
CPU 乱序执行在单线程环境下是一种很好的优化手段,但是在多线程环境下,就会出现数据不一致的问题,因此就可以通过内存屏障这个机制来处理这个问题。
在 JSR 规范中定义了4种内存屏障:
- LoadLoad屏障:(指令Load1; LoadLoad; Load2),在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- LoadStore屏障:(指令Load1; LoadStore; Store2),在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
- StoreStore屏障:(指令Store1; StoreStore; Store2),在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
- StoreLoad屏障:(指令Store1; StoreLoad; Load2),在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
在 x86 的 CPU 中,实现了相应的内存屏障写屏障(Store barrier)、读屏障(load barrier)和全屏障(full barrier),主要的作用是:
1、写内存屏障(Store Memory Barrier)
store barrier 称为写屏障,相当于storestore barrier, 强制所有在 store store 内存屏障之前的所有执行,都要在该内存屏障之前执行,并发送缓存失效的信号。所有在 store store barrier指令之后的 store 指令,都必须在 store store barrier屏障之前的指令执行完后再被执行。也就是进制了写屏障前后的指令进行重排序,是的所有 store barrier之前发生的内存更新都是可见的(这里的可见指的是修改值可见以及操作结果可见)。
在指令后插入 store barrier,能让写入缓存中最新数据更新写入主内存中,让其他线程可见。强制写入主内存,这种显示调用,不会让 CPU 去进行指令重排序;
2、读内存屏障(Load Memory Barrier)
load barrier 称为读屏障,相当于 load load barrier,强制所有在 load barrier读屏障之后的 load 指令,都在 load barrier 屏障之后执行。也就是进制对 load barrier读屏障前后的 load 指令进行重排序,配合 store barrier,使得所有store barrier之前发生的内存更新,对 load barrier之后的load操作是可见的。
在指令后插入 load barrier,可以让高速缓存中的数据失效,强制重新从主内存中加载数据,也是不会让 CPU 去进行指令重排。
3、全内存屏障(Full Memory Barrier)
full barrier 成为全屏障,相当于 store load,是一个全能型的屏障,因为它同时具备前面两种屏障的效果。强制了所有在 store load barrier 之前的 store/load 指令,都在该屏障之前被执行,所有在该屏障之后的的 store/load 指令,都在该屏障之后被执行。禁止对 store load 屏障前后的指令进行重排序。
4、总结
内存屏障只是解决顺序一致性问题,不解决缓存一致性问题,缓存一致性是由cpu的缓存锁以及MESI协议来完成的。而缓存一致性协议只关心缓存一致性,不关心顺序一致性。所以这是两个问题。
内存屏障的性能影响
内存屏障阻碍了 CPU 采用优化技术来降低内存操作延迟,必须考虑因此带来的性能损失。为了达到最佳性能,最好是把要解决的问题模块化,这样处理器可以按单元执行任务,然后在任务单元的边界放上所有需要的内存屏障。采用这个方法可以让处理器不受限的执行一个任务单元。合理的内存屏障组合还有一个好处是:缓冲区在第一次被刷后开销会减少,因为再填充改缓冲区不需要额外工作了。
四、Java 内存模型
JMM怎么解决原子性、可见性、有序性的问题?
在Java中提供了一系列和并发处理相关的关键字,比如:volatile、Synchronized、final、juc(java.util.concurrent)等,这些就是 Java 内存模型封装了底层的实现后提供给开发人员使用的关键字,在开发多线程代码的时候,我们可以直接使用 synchronized 等关键词来控制并发,使得我们不需要关心底层的编译器优化、缓存一致性的问题了,所以在 Java 内存模型中,除了定义了一套规范,还提供了开放的指令在底层进行封装后,提供给开发人员使用。、
- 原子性保障:在 java 中提供了两个高级的字节码指令 monitorenter 和 monitorexit,在 Java 中对应的 synchronized 来保证代码块内的操作是原子的;
- 可见性:Java 中的 volatile 关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。因此,可以使用 volatile 来保证多线程操作时变量的可见性。除了 volatile,Java 中的 synchronized 和 final 两个关键字也可以实现可见性;
- 有序性:在 Java 中,可以使用 synchronized 和 volatile 来保证多线程之间操作的有序性。实现方式有所区别:volatile 关键字会禁止指令重排。synchronized 关键字保证同一时刻只允许一条线程操作。
Java 内存模型中 volatile 变量,保证可见性和有序性的方法:
- 对每个 volatile 写操作的前面会插入 store store barrier
- 对每个 volatile 写操作的后面会插入 store load barrier
- 对每个 volatile 读操作的前面会插入 load loadbarrier
- 对每个 volatile 读操作的后面会插入 load store barrier
lock:解锁时,jvm 会强制刷新 CPU 缓存,导致当前线程更改,对其他线程可见;
final:一个类的 final 字段会在初始化后插入一个 store 屏障,来禁止重排序,保证可见性。来确保 final 字段在构造函数初始化完成并可被使用时可见。
标题:CPU 缓存与内存屏障
作者:Yi-Xing
地址:http://47.94.239.232:10014/articles/2021/02/23/1614059820040.html
博客中若有不恰当的地方,请您一定要告诉我。前路崎岖,望我们可以互相帮助,并肩前行!