基础知识回顾
还是那句话,无论语言再怎么牛,其都是对底层计算机指令的封装。
计算机CPU执行指令的时候是非常快的,如果每执行一个指令都从内存中取数据的话,那会非常慢,严重影响CPU的执行速度,所以每个CPU都有自身对应的高速缓冲区(多级寄存器),每个线程被执行的时候,会先把运行时需要的数据复制到告诉缓冲区一份,此高速缓存区只与在该CPU运行的线程有关,然后在当前线程需要CPU执行N多指令的时候,就不用再去内存中拿数据,直接从本地的缓冲区,进而提高CPU的执行任务速度,等待执行完毕后再把结果写入到主内存中,但是什么时候执行结果会被刷新至主内存中是不太确定的(但是肯定在执行下一指令之前,哈哈);在遇到线程放弃执行权限或者sleep一段时间后等再次被处理器运行的时候,会重新把需要的数据载入高速缓冲区中。
上面这个结构,对于单CPU来说没有任何问题,但是近代计算机一般都是多个CPU,这样一来,每个CPU的高速缓冲区如果同时缓存了共享变量的话,那么就有可能出现数据状态不一致的情况,那么这个情况怎么解决呢?两个解决方案:
- 总线锁:采用一种类似于独占内存的方式,同一时间只能有一个CPU运行,其余的则被阻塞。
- 缓存一致性协议:当CPU更改数据的时候,如果发现是共享变量就会通知其他CPU此变量的缓存行是无效的,这样其他CPU在使用该变量的时候,就会从内存重新读取数据。
术语 | 释义 |
---|---|
共享变量 | 可以被多个线程同时访问的变量 |
内存屏障 | 一组处理器指令,用于对内存操作的(指令)顺序限制 |
缓冲行 | 缓存中可以分配的最小存储单位 |
缓存一致性协议也称MESI协议:
- 独占(exclusive):仅当前处理器拥有该缓存行,并且没有修改过;
- 共享(share):有多个处理器拥有该缓存行,每个处理器都没有修改过缓存;
- 修改(modified):仅当前处理器拥有该缓存行,并且缓存行被修改过了;
- 失效(invalid):缓存行被其他处理器修改过;
它们之间的关系如下:
- 一个处于M状态的缓存行,必须时刻监听所有试图读取该缓存行对应的主存地址的操作,如果监听到,则必须在此操作执行前把其缓存行中的数据写回CPU;
- 一个处于S状态的缓存行,必须时刻监听使该缓存行无效或者独享该缓存行的请求,如果监听到,则必须把其缓存行状态设置为I;
- 一个处于E状态的缓存行,必须时刻监听其他试图读取该缓存行对应的主存地址的操作,如果监听到,则必须把其缓存行状态设置为S
- 当CPU需要读取数据时,如果其缓存行的状态是I的,则需要从内存中读取,并把自己状态变成S,如果不是I,则可以直接读取缓存中的值,但在此之前,必须要等待其他CPU的监听结果,如其他CPU也有该数据的缓存且状态是M,则需要等待其把缓存更新到内存之后,再读取;
- 当CPU需要写数据时,只有在其缓存行是M或者E的时候才能执行,否则需要发出特殊的RFO指令(Read Or Ownership,这是一种总线事务),通知其他CPU置缓存无效(I),这种情况下会性能开销是相对较大的。在写入完成后,修改其缓存状态为M。
Volatile的实现原理
- 禁止指令重排
处理器的内存屏障分为两种:Load Barrier 和 Store Barrier即读屏障和写屏障。- 阻止屏障两侧的指令重排序;
- 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。
JVM的内存屏障其实也是对计算机内存屏障的封装,其兼容了不容平台的差异,通过调用硬件的内存屏障指令来实现禁止指令重排。
分类 | 说明 |
---|---|
StoreStore | 禁止上面的普通写和下面的volatile写重排序 |
StoreLoad | 防止上面的volatile写与下面可能有的volatile读/写重排序 |
LoadLoad | 禁止下面所有的普通读操作和上面的volatile读重排序 |
LoadStore | 禁止下面所有的普通写操作和上面的volatile读重排序 |
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
- 内存可见性
JMM内存模型与上面说的类似,对于共享变量每个线程都会对其生成变量副本,在后续的读写操作中都是操作其副本,等待对变量操作完毕后,再把变量副本写入到主内存中(不是实时写入主内存),如果遇到多个线程同时读写共享变量的时候,由于他们都是操作的副本,所以各个线程之间是互不知晓的,那么怎么让其中一个线程修改变量的时候,另外一个线程立马就知道呢?通过对volatile修饰的共享变量相关代码进行编译生成的汇编指令发现,volatile写操作对应的指令是一个lock前缀指令,而lock前缀指令会在多核CPU同时运行的情况下引发两件事:- CPU高速缓冲区中对应该变量的缓存行数据立马回写到主内存。在老版本的多核处理器中,lock前缀指令会在执行lock指令的时候声言LOCK#信号,该信号确保在执行此指令期间,此处理器可以独享任何内存,因为它会锁住总线,导致其他CPU不能访问总线进而不能访问系统内存;由于锁总线实在是性能消耗太大,所以在近代处理器中会锁定此共享变量的内存缓冲区(由缓存行组成),而其他CPU则无法访问对应缓冲区中锁定的那部分数据,进而实现锁定的那部分数据同一时间只有一个CPU可以操作;使用缓存一致性协议(MESI)来保证原子性,缓存一致性机制不允许同时修改被多个CPU缓存了的内存区域。
- 触发其他CPU缓冲区中对应的缓冲行失效。使用嗅探技术,探测其他CPU的缓存以及主内存,如果探测到共享变量有改变或者预计有写入操作,处理器将会把对应的缓存行置为无效。
- 据查资料,任何的lock前缀指令都有内存屏障的作用。
JMM内存模型验证
private static boolean exit = false;
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
while(!exit) {
// try {
// System.out.println("continue...");
// Thread.currentThread().sleep(1000);
// } catch (InterruptedException e) {}
}
System.out.println("over...");
}).start();
Thread.currentThread().sleep(2000);
exit = true;
}
执行上面这段程序,你会发现程序会一直运行,但是将exit变量声明为volatile的时候,2s就停止了。
但是你把try代码块注释打开的话,那么你会发现虽然exit变量不是volatile的,但是程序也会在2停止,为什么呢?猜测原因有二:
- 线程sleep后,重新获得CPU执行权限,待执行时会重新加载线程所需变量从主内存到高速缓存区,进而获取到变量最新的值。
- System.out.println是sync操作导致的,因为sync需要获取锁,获取锁以后才具备CPU处理资格,待执行时高速缓冲区重新加载线程运行所需变量,从而获取到变量最新的值。
有不对的地方,欢迎大家指正!