分享

Volatile底层原理剖析

 印度阿三17 2020-04-18
基础知识回顾

还是那句话,无论语言再怎么牛,其都是对底层计算机指令的封装。

计算机CPU执行指令的时候是非常快的,如果每执行一个指令都从内存中取数据的话,那会非常慢,严重影响CPU的执行速度,所以每个CPU都有自身对应的高速缓冲区(多级寄存器),每个线程被执行的时候,会先把运行时需要的数据复制到告诉缓冲区一份,此高速缓存区只与在该CPU运行的线程有关,然后在当前线程需要CPU执行N多指令的时候,就不用再去内存中拿数据,直接从本地的缓冲区,进而提高CPU的执行任务速度,等待执行完毕后再把结果写入到主内存中,但是什么时候执行结果会被刷新至主内存中是不太确定的(但是肯定在执行下一指令之前,哈哈);在遇到线程放弃执行权限或者sleep一段时间后等再次被处理器运行的时候,会重新把需要的数据载入高速缓冲区中。

CPU缓存CPU缓存CPU缓存CPU缓存主内存

上面这个结构,对于单CPU来说没有任何问题,但是近代计算机一般都是多个CPU,这样一来,每个CPU的高速缓冲区如果同时缓存了共享变量的话,那么就有可能出现数据状态不一致的情况,那么这个情况怎么解决呢?两个解决方案:

  1. 总线锁:采用一种类似于独占内存的方式,同一时间只能有一个CPU运行,其余的则被阻塞。
  2. 缓存一致性协议:当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读重排序
  1. 在每个volatile写操作的前面插入一个StoreStore屏障。
  2. 在每个volatile写操作的后面插入一个StoreLoad屏障。
  3. 在每个volatile读操作的后面插入一个LoadLoad屏障。
  4. 在每个volatile读操作的后面插入一个LoadStore屏障。
  • 内存可见性
    JMM内存模型与上面说的类似,对于共享变量每个线程都会对其生成变量副本,在后续的读写操作中都是操作其副本,等待对变量操作完毕后,再把变量副本写入到主内存中(不是实时写入主内存),如果遇到多个线程同时读写共享变量的时候,由于他们都是操作的副本,所以各个线程之间是互不知晓的,那么怎么让其中一个线程修改变量的时候,另外一个线程立马就知道呢?通过对volatile修饰的共享变量相关代码进行编译生成的汇编指令发现,volatile写操作对应的指令是一个lock前缀指令,而lock前缀指令会在多核CPU同时运行的情况下引发两件事:
    1. CPU高速缓冲区中对应该变量的缓存行数据立马回写到主内存。在老版本的多核处理器中,lock前缀指令会在执行lock指令的时候声言LOCK#信号,该信号确保在执行此指令期间,此处理器可以独享任何内存,因为它会锁住总线,导致其他CPU不能访问总线进而不能访问系统内存;由于锁总线实在是性能消耗太大,所以在近代处理器中会锁定此共享变量的内存缓冲区(由缓存行组成),而其他CPU则无法访问对应缓冲区中锁定的那部分数据,进而实现锁定的那部分数据同一时间只有一个CPU可以操作;使用缓存一致性协议(MESI)来保证原子性,缓存一致性机制不允许同时修改被多个CPU缓存了的内存区域。
    2. 触发其他CPU缓冲区中对应的缓冲行失效。使用嗅探技术,探测其他CPU的缓存以及主内存,如果探测到共享变量有改变或者预计有写入操作,处理器将会把对应的缓存行置为无效。
    3. 据查资料,任何的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停止,为什么呢?猜测原因有二:

  1. 线程sleep后,重新获得CPU执行权限,待执行时会重新加载线程所需变量从主内存到高速缓存区,进而获取到变量最新的值。
  2. System.out.println是sync操作导致的,因为sync需要获取锁,获取锁以后才具备CPU处理资格,待执行时高速缓冲区重新加载线程运行所需变量,从而获取到变量最新的值。

有不对的地方,欢迎大家指正!

来源:https://www./content-4-678551.html

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多