目录
一、高速缓存与地址映射
二、MESI缓存一致性协议和伪共享
三、指令重排序和内存屏障
四、Java volatile和final关键字
CPU访问内存(DRAM)较慢,基于数据的空间局部性(该数据存储位置附近的数据可能很快被访问)和时间局部性(当前正在访问的数据可能很快被再访问)而引入了高速缓存(SRAM)。高速缓存位于分页单元和内存控制器之间,即虚拟地址通过分页单元转换成物理地址后,首先在高速缓存中查找是否存在对应的数据,如果存在则返回缓存中的数据,否则请求内存控制器读取内存,把目标数据附近的一个缓存行大小(通常是64byte)的数据读到高速缓存中,具体的读取规则取决于高速缓存同内存之间的地址映射方式。缓存行是高速缓存同内存交换数据的最低单位,是替换高速缓存中的数据的最低单位,也是高速缓存同内存地址映射的最低单位。高速缓存的命中率直接影响程序的性能,Linux下可以通过perf命令查看缓存的命中率。
高速缓存同内存之间的地址映射方式有三种:全相联映射、直接相联映射和组相联映射。全相联映射是指按缓存行大小将高速缓存分成N块,内存分成M块,内存的第m块可以映射到高速缓存中的任意一块n,通过映射表记录m和n之间对应关系,缺点是映射表实现电路复杂,需要比较的记录多,优点是高速缓存的利用率和命中率都高,适用于小容量的高速缓存。直接相联映射是指按缓存行大小将高速缓存分成N块,内存分成M块,将M块进一步分成S组,每组N块,M=S*N,内存的第s组下的第n块映射至高速缓存的第n块,通过映射表记录n和s的对应关系,缺点是因为需要频繁替换缓存行导致高速缓存利用率和命中率较低,优点是映射表实现简单。组相联映射按缓存行大小将高速缓存分成N块,S组,每组n块,N=S*n,将内存分成M块,T组,每组S块,即M=S*T,内存的第s组下第a块映射至高速缓存的第a组下的n块中的任意一块,通过映射表记录s和n之间的对应关系,即内存和高速缓存的组间采用直连相联映射,组内采用全相联映射。组相联映射按照每组n块分为n路全相联映射,n通常是2,4,8,16。组相联映射是前面两者间的折中,兼顾两者的优缺点,是普遍采用的地址映射方案。综上分析,从内存读取目标数据附近一个缓存行大小的数据时,目标数据可能位于缓存行中的任意位置,即不是从目标物理地址往后读取一个缓存行数据。
现代CPU的高速缓存包含L1,L2,L3三级高速缓存,访问速度逐渐下降,缓存容量逐渐上升,上级缓存的内容来源于下级,其中L1,L2是CPU独享的,L3是多核CPU共享的,但是对操作系统而言高速缓存只有一个,由底层硬件维护三级缓存的缓存内容读写和彼此间的缓存一致性。
参考: 主存与Cache的地址映射
CPU缓存
CPU体系结构之cache小结
CPU将修改后的数据写回高速缓存和内存有两种策略:通写(write through),每次CPU修改了高速缓存中的内容就立即同步更新到内存,写内存较慢,且频繁更新内存会导致总线资源竞争;回写(write back),每次CPU修改了cache中的数据,不会立即更新到内存,而是等到cache line在某一个必须或合适的时机才会更新到内存中,具体的时机根据缓存一致性协议确定。物理页是否采用高速缓存和写回策略由页表项中的PCD和PTW标志指定,Linux下默认是启用高速缓存和采用回写策略。
多核处理器系统每个核都可能保存了内存中同一份数据的副本,为了确保各副本之间数据一致性而引入了MESI缓存一致性协议,该协议由高速缓存控制器实现,操作系统不需要关心。MESI协议定义了缓存行的四种状态,M表示已修改,E表示独享,S表示共享,I表示无效,高速缓存控制器会监听所有CPU核对同一内容的缓存行的读取和修改操作,然后根据协议规则改变缓存行的状态,进而触发将缓存行刷新至内存或者从新从内存读取缓存行。以两个线程并发修改变量x为例,CPU A先将变量x所属的缓存行XA从内存读入高速缓存,该缓存行XA状态为独享E,接着CPU B读取变量x,此时L3高速缓存有该缓存行,从L3读取至B自己的L1,记为XB,XB的状态是共享S,这时会通知XA的高速缓存控制器将其置为共享S。假如CPU A先计算完成,将结果写回到XA,XA的状态变成M,然后通知XB的高速缓存控制器将其置为I。接着CPU B计算完成,将结果写回到XB,这时会通知XA的高速缓存控制器将XA写回到内存,并把XA的状态置为I,然后重新从内存读取XB并把状态变成M。CPU A再次读取x时,发现XA是I,会重新从内存读取缓存行,XB监听到XA的读取动作会提前把XB刷新到内存,保证CPU A读取到的是最新的,然后XB的状态变成S。
综上分析,缓存一致性协议会导致更新丢失,即第二次更新的结果会直接覆盖掉第一次更新的结果,但是会保证读到的数据是最新修改的。另外,在多线程并发修改同一个变量的时候会导致该变量频繁从高速缓存刷新至内存,接着又从内存读取到高速缓存,如果多线程并发修改的多个变量位于同一个缓存行,这个问题更加严重,并且理论上对多个变量的并行修改会变成串行修改,称之为伪共享问题。以两个线程并发修改X,Y变量,X,Y变量都位于同一个缓存行M为例,CPU A和CPU B的缓存行的初始状态为共享S,接着CPU A修改变量X,写回到缓存行MA,MA的状态变成修改M,MB的状态变成无效I,接着CPU B修改变量Y,写回到缓存MB,这时会通知并等待MA写回到内存并将其状态改成无效I,然后从内存从新读取MB并修改,将状态变成M,即事实上修改Y变量需要等待对X变量的修改刷新到内存后才能执行。
解决规避伪共享的问题的核心就是区分经常变动和不变的全局变量,不变的全局变量尽量放在同一个缓存行,经常变动的全局变量放在不同的缓存行中,后者的实现逻辑是添加不需要修改的长整型变量,确保这些长整型变量和某个容易变动的全局变量在同一个缓存行中。JDK8中新增了一个注解@sun.misc.Contended,来使各个变量在Cache line中分隔开,但是jvm需要添加参数-XX:-RestrictContended才能开启此功能。
参考:【并发编程】CPU cache结构和缓存一致性(MESI协议)
Java专家系列:CPU Cache与高性能编程
Java8使用@sun.misc.Contended避免伪共享
多核处理器下CPU修改或者读取数据后会导致缓存行状态的改变,需要跟其他CPU的高速缓存控制器通信来协调同一缓存行的状态改变,通信期间CPU是阻塞的,为了提高CPU的利用率,引入了MOB(Memory Ordering Buffers)来缓存同高速缓存交互的指令,MOB位于CPU和L1缓存之间,对其他CPU不可见,如下图所示:
MOB由一个64长度的load buffer和36长度的store buffer组成,CPU读取数据的指令会先放入load buffer,修改数据的指令先放入store buffer,两个buffer都是FIFO队列结构,存入buffer后CPU立即执行其他的指令,由高速缓存控制器逐一执行buffer中指令,执行完成由高速缓存控制器通知CPU。除此之外,为了提高invalid消息的应答效率,引入了invalid buffer队列,即当高速缓存控制器接收到其他控制器发送的invalid消息时会立即发送消息答复,并将对应的invalid操作放入invalid buffer中,然后异步执行该buffer中的invalid操作。注意store buffer对CPU是可见的,即CPU可以读取store buffer中未写入到高速缓存中的数据,但是invalid buffer对CPU是不可见的,即除非invalid buffer中的invalid操作对具体的缓存行生效了,CPU会继续读取已经事实上失效的数据。MOB通过异步执行读写缓存指令提高了CPU利用率,但是引入了两个新的问题,第一,修改数据的指令在buffer中还未执行或者invalid buffer中的invalid操作未执行时,其他CPU同一缓存行的状态还未改成无效I,此时读取的数据就不是最新的;第二,不同高速缓存控制器执行指令的顺序不一定是CPU写入指令的顺序,比如CPU A 写入load A指令,然后CPU B写入load B指令,实际上可能是load B指令先于load A指令执行,这种执行顺序上的变化称之为指令重排序,在并发的情形下可能会导致异常。
指令重排序除上述情形外,在编译器优化代码,CPU并行执行指令时都可能发生,这两种情形下的指令重排序都是为了提供代码的运行效率。所有的指令重排序都遵循as-if-serial语义原则,表示重排序后的指令执行效果同单线程下未重排序的指令的执行效果一样,这要求编译器和CPU不能对存在数据依赖性,重排序会改变执行结果的的指令做重排序。Java从JDK5中引入了happens-before概念,如果一个操作happens-before(之前发生)另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前,但是保证结果一致的前提下允许重排序。JMM规范中制定了一系列的重排序的规则,用于规范编译器优化代码时的重排序行为,其中与程序员密切相关的规则如下:
为了解决并发环境下指令重排序可能导致的问题和引入MOB导致的数据未及时更新的问题,CPU提供了内存屏障(memory barrier)指令,不同架构的CPU对应的内存屏障指令不同,通常包含以下四种类型的指令:
屏障类型 | 说明(load指令指读取数据的一类指令,store指令指修改数据的一类指令) |
---|---|
Load Barriers(x86下为ifence指令) | 强制所有在load屏障指令之后的load指令,都在load buffer 和 invaild buffer被高速缓存控制器执行完之后才执行,即确保load屏障指令前的load指令都先于load屏障之后的指令执行,且load屏障之后的指令可以看到其他CPU对同一缓存行的修改。 |
Store Barriers(x86下为sfence指令) | 强制所有在store屏障指令之前的store指令,都在该store屏障指令执行之前被执行,并把store buffer的数据都刷到高速缓存,即确保数据修改对其它CPU可见 |
Full Barriers(x86下为mfence指令) | 复合了Load Barries和Store Barriers的功能 |
参考:内存屏障
指令重排序
Java volatile变量在执行写操作之后由JVM插入一个store屏障,在执行读操作之前插入一个load屏障,从而确保程序读取到的volatile变量始终是最新的,对该变量的修改立即对其他线程可见。一个类的final字段会在初始化后插入一个store屏障,来确保final字段在构造函数初始化完成并可被使用时可见。
volatile关键字可以参考如下测试用例:
private static volatile boolean isOver = false;
public static void main(String[] args) throws Exception {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
while (!isOver) ;
System.out.println("thread exit");
}
});
thread.start();
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
isOver = true;
System.out.println("main update");
thread.join();
System.out.println("main exit");
}
上述用例在isOver加了volatile关键字以后,子线程立即读取到更新后的isOver并很快退出,没有加volatile关键字,则子线程一直运行,始终没有读取到修改后的值。理论上没有加volatile关键字,子线程不能立即读取到更新,但是应该延后一段时间也能读取到更新,为啥这里没有读取到更新了?答案是编译器优化后CPU从内存读取了一遍isOver后没有再读取,如下用例就可以读取到isOver更新后的取值。因此对于多线程共享的变量都要加上volatile关键字。
private static boolean isOver = false;
public static void main(String[] args) throws Exception {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("isOver-->"+isOver);
try {
Thread.sleep(501);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("isOver-->"+isOver);
System.out.println("thread exit");
}
});
thread.start();
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
isOver = true;
System.out.println("main update");
thread.join();
System.out.println("main exit");
}
参考:让你彻底理解volatile