1. CPU物理缓存结构
由于CPU的运算速度比主存的存取速度快很多,为了提高处理速度,现代CPU不直接和主存进行通信,而是在CPU和主存之间设计了多层的Cache(高速缓存),越靠近CPU的高速缓存越快,容量也越小。
按照数据读取顺序和与CPU内核结合的紧密程度,CPU高速缓存有L1和L2高速缓存(即一级高速缓存和二级缓存高速),部分高端CPU还具有L3高速缓存(即三级高速缓存)。每一级高速缓存中所存储的数据都是下一级高速缓存的一部分,越靠近CPU的高速缓存读取越快,容量也越小。拥有L3高速缓存的CPU,CPU存取数据的命中率可达95%,也就是说只有不到5%的数据需要从主存中去存取。 L1高速缓存和L2高速缓存都只能被一个单独的CPU内核使用,L3高速缓存可以被同一个CPU芯片上的所有CPU内核共享,而主存可以由系统中的所有CPU共享。
CPU内核读取数据时,先从L1高速缓存中读取,如果没有命中,再到L2、L3高速缓存中读取,假如这些高速缓存都没有命中,它就会到主存中读取所需要的数据。高速缓存大大缩小了高速CPU内核与低速主存之间的速度差距。
每个CPU的处理过程为:先将计算需要用到的数据缓存在CPU的高速缓存中,在CPU进行计算时,直接从高速缓存中读取数据并且在计算完成之后写回高速缓存中。在整个运算过程完成后,再把高速缓存中的数据同步到主存。由于每个线程可能会运行在不同的CPU内核中,因此每个线程拥有自己的高速缓存。同一份数据可能会被缓存到多个CPU内核中,在不同CPU内核中运行的线程看到同一个变量的缓存值就会不一样,就可能发生内存的可见性问题。
硬件层的MESI协议是一种用于解决内存的可见性问题的手段。
2. 硬件层面的MESI协议(可见性原理)
为了解决内存的可见性问题,CPU主要提供了两种解决办法:总线锁和缓存锁。
1、总线锁
操作系统提供了总线锁机制:
CPU总线是所有CPU与芯片组连接的主干道,负责CPU与外界所有部件的通信,包括高速缓存,其控制总线向各个部件发送控制信号,通过地址总线发送地址信号指定其要访问的部件,通过数据总线实现双向传输。
每当CPU内核访问L3中的数据时,都会通过线程总线来进行读取。总线锁的意思是在线程总线中加入一把锁,例如,当不同的CPU内核访问同一个缓存行时,只允许一个CPU内核进行读取。
如图所示,a、b存储于L3高速缓存中,当CPU内核1对a进行访问时,会在总线上发送一个LOCK#信号,CPU内核2想对b进行查询,但是总线被锁住,得等CPU内核1访问完,CPU内核2才能访问b。 在多CPU的系统中,当其中一个CPU要对共享主存进行操作时,在总线上发出一个LOCK#信号,这个信号使得其他CPU无法通过总线来访问共享主存中的数据,总线锁把CPU和主存之间的通信锁住了,这使得锁定期间,其他CPU不能操作其他主存地址的数据,总线锁的开销比较大,这种机制显然是不合适的。
总线锁的缺陷是:某一个CPU访问主存时,总线锁把CPU和主存的通信给锁住了,其他CPU不能操作其他主存地址的数据,使得效率低下,开销较大。
总线锁的粒度太大了,最好的方法就是控制锁的保护粒度,只需要保证被多个CPU缓存的同一份数据一致即可。所以引入了缓存锁(如缓存一致性机制),后来的CPU都提供了缓存一致性机制。
2、缓存锁
相比总线锁,缓存锁降低了锁的粒度。为了达到数据访问的一致,需要各个CPU在访问高速缓存时遵循一些协议,在存取数据时根据协议来操作,常见的协议有MSI、MESI、MOSI等。最常见的就是MESI协议。
就整体而言,缓存一致性机制就是当某CPU对高速缓存中的数据进行操作之后,通知其他CPU放弃存储在它们内部的缓存数据,或者从主存中重新读取,如图: 为了提高处理速度,CPU不直接和主存进行通信,而是先将系统主存的数据读到内部高速缓存(L1、L2或其他)后再进行操作,但操作完不知道何时会写入内存。如果对声明了volatile的变量进行写操作,JVM就会向CPU发送一条带lock前缀的指令,将这个变量所在缓存行的数据写回系统主存。
但是,即使写回系统主存,如果其他CPU高速缓存中的值还是旧的,再执行计算操作也会有问题。所以,在多CPU的系统中,为了保证各个CPU的高速缓存中数据的一致性,会实现缓存一致性协议,每个CPU通过嗅探在总线上传播的数据来检查自己的高速缓存中的值是否过期,当CPU发现自己缓存行对应的主存地址被修改时,就会将当前CPU的缓存行设置成无效状态,当CPU对这个数据执行修改操作时,会重新从系统主存中把数据读到CPU的高速缓存中。
主要的缓存一致性协议有MSI协议、MESI协议等。
3. 硬件层面的内存屏障 (有序性和可见性原理)
为了重复释放硬件的高性能,编译器、CPU会优化待执行的指令序列,包括调整某些指令的顺序执行。优化的结果,指令执行顺序会与代码顺序略有不同,可能会导致代码执行出现有序性问题。
内存屏障是一系列的CPU指令,它的作用主要是保证特定操作的执行顺序,保障并发执行的有序性。在编译器和CPU都进行指令的重排优化时,可以通过在指令间插入一个内存屏障指令,告诉编译器和CPU,禁止在内存屏障指令前(或后)执行指令重排序。
多核情况下,所有的CPU操作都会涉及缓存一致性协议(MESI协议)校验,该协议用于保障内存可见性。但是,缓存一致性协议仅仅保障内存弱可见(高速缓存失效),没有保障共享变量的强可见,而且缓存一致性协议更不能禁止CPU重排序,也就是不能确保跨CPU指令的有序执行。
如何保障跨CPU指令重排序之后的程序结果正确呢?需要用到内存屏障。
内存屏障是让一个CPU高速缓存的内存状态对其他CPU内核可见的一项技术,也是一项保障跨CPU内核有序执行指令的技术。
1、读屏障
(1) 读屏障让高速缓存中相应的数据失效。在指令前插入读屏障,可以让高速缓存中的数据失效,强制重新从主存加载数据。
(2) 读屏障会告诉CPU和编译器,先于这个屏障的指令必须先执行。(读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前)
读屏障既使得当前CPU内核对共享变量的更改对所有CPU内核可见,又阻止了一些可能导致读取无效数据的指令重排。
2、写屏障
(1) 在指令后插入写屏障指令能让高速缓存中的最新数据更新到主存,让其他线程可见。
(2) 写屏障会告诉CPU和编译器,后于这个屏障的指令必须后执行。(写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后)
3、 内存屏障的作用
(1) 阻止屏障两侧的指令重排序
编译器和CPU可能为了使性能得到优化而对指令重排序,但是插入一个内存屏障相当于告诉CPU和编译器先于这个屏障的指令必须先执行,后于这个屏障的指令必须后执行。
(2) 强制让高速缓存的数据失效
硬件层的内存屏障强制把高速缓存中的最新数据写回主存,让高速缓存中相应的脏数据失效。一旦完成写入,任何访问这个变量的线程将会得到最新的值。
4. volatile的底层原理
为了解决CPU访问主存时主存读写性能的短板,在CPU中增加了高速缓存,但这带来了可见性问题。
Java的volatile关键字可以保证共享变量的主存可见性,也就是将共享变量的改动值立即刷新回主存。在正常情况下,系统操作并不会校验共享变量的缓存一致性,只有当共享变量用volatile关键字修饰了,该变量所在的缓存行才被要求进行缓存一致性的校验。
public class VolatileVar {
private volatile int var;
public void setVar(int var) {
System.out.println("setVar="+var);
this.var = var;
}
public static void main(String[] args) {
VolatileVar var = new VolatileVar();
var.setVar(100);
}
}
分析volatile关键字对应的汇编指令: 由于共享变量var加了volatile关键字,因此在汇编指令中,操作var之前多出一个lock前缀指令lockaddl,该lock前缀指令有三个功能。
(1) 将当前CPU缓存行的数据立即写回系统内存
在对volatile修饰的共享变量进行写操作时,其汇编指令前用lock前缀修饰。lock前缀指令使得在执行指令期间,CPU可以独占共享内存(即主存)。通过缓存锁实现对共享内存的独占性访问,缓存锁(缓存一致性协议)会阻止两个CPU同时修改共享内存的数据。
(2) lock前缀指令会引起在其他CPU中缓存了该内存地址的数据无效
写回操作时要经过总线传播数据,而每个CPU通过嗅探在总线上传播的数据来检查自己缓存的值是否过期,当CPU发现自己缓存行对应的内存地址被修改时,就会将当前CPU的缓存行设置为无效状态,当CPU要对这个值进行修改的时候,会强制重新从系统内存中把数据读到CPU缓存。
(3) lock前缀指令禁止指令重排
lock前缀指令的最后一个作用是作为内存屏障使用,可以禁止指令重排序,从而避免多线程环境下程序出现乱序执行的现象。
volatile关键字的底层原理使用的就是内存屏障
|