volatile 简介
volatile关键字是Java虚拟机提供的的最轻量级的同步机制。它作为一个修饰符出现,可以用来修饰变量。它有什么作用呢?可以保证变量对所有线程的(实时)可见性,禁止指令重排,但是不能保证原子性。
现代计算机的内存模型
计算机执行程序时,指令是由CPU处理器执行的,而打交道的数据是在主内存当中的。
由于计算机的存储设备与处理器的运算速度有几个数量级的差距,总不能每次CPU执行完指令,然后等主内存慢悠悠存取数据吧,影响协作效率。
所以现代计算机系统加入一层读写速度接近处理器运算速度的高速缓存(Cache),来作为内存与处理器之间的缓冲[更好地协作]。
在多路处理器系统中,每个处理器都有自己的高速缓存,而它们共享同一主内存.现代计算机模型如下:
程序执行时,把需要用到的数据,从主内存拷贝一份到各自的高速缓存(副本)。
CPU处理器计算时,先从它的高速缓存中读取,再把计算完的数据写入高速缓存。
当程序运算结束,把高速缓存的数据刷新至主内存。
MESI协议是啥?
MESI协议就是为了解决缓存一致性问题的缓存一致性协议。
当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态。
因此当其他CPU需要读取这个变量时,会发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取,即获取到最新数据。
CPU中每个缓存行标记的4种状态(M、E、S、I)
缓存状态 描述
M(Modified),被修改 当前CPU cache拥有最新数据(最新的cache line),其他CPU拥有失效数据(cache line的状态是invalid),缓存中的数据和主内存不一致
E(Exclusive),独享的 该缓存行只被该CPU缓存,值和主内存的值一致。该状态在任何时刻当有其它CPU读取主内存的值时(加载至自己的cache)变成共享状态(shared)
S(Shared),共享的 该缓存行可能被多个CPU缓存,各个缓存中的数据与主存数据一致。当前CPU修改自己的缓存行时,缓存行状态变为M,同时其它CPU中的该缓存行变成无效状态(Invalid)
I(Invalid),无效的 该缓存行数据是无效的,读取时需重新从主存载入
MESI协议是如何实现的?如何保证当前处理器的内部缓存、主内存和其他处理器的缓存数据在总线上保持一致的?
多处理器总线嗅探。
什么是嗅探技术?
在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己的缓存值是不是过期了。如果处理器发现自己缓存行对应的内存地址被修改,就会将自己的缓存行设置无效状态。另外,当处理器对这个数据进行修改操作的时候,会重新从主内存中把数据读到处理器缓存中。
它是最常见的一种解决多核 CPU 数据广播的方案。本质上就是读写请求通过总线(Bus) 广播给所有的 CPU 核心,然后让各个核心去"嗅探"这些请求,再根据本地的情况进行响应。
MESI中的四种操作
Local Read(LR):本地 CPU 读操作
Local Write(LW):本地 CPU 写操作
Remote Read(RR):其他 CPU 读操作
Remote Write(RW):其他 CPU 写操作
MESI中的缓存行
CPU操作缓存的单位是"缓存行"(cache line),也就是说如果CPU要读一个变量x,那么其实就是读变量x所在的整个缓存行。可以简单的把"缓存行"理解为变量副本的存储空间。
缓存行状态切换
用几个例子加以说明,假设有三个CPU A、B、C,对应三个缓存分别是cache a、b、 c。在主内存中定义了x的引用值为0。
1.单核读取
那么执行流程是:
CPU A发出了一条指令,从主内存中读取x。
从主内存通过bus读取到缓存中(远端读取,Remote read),此时该Cache line修改为E状态(独享)。[主内存的值在一个cpu中存在,此时状态为E]
2.双核读取
那么执行流程是:
CPU A发出了一条指令,从主内存中读取x;
CPU A从主内存通过bus读取到 cache a中并将该cache line 设置为E状态;
CPU B发出了一条指令,从主内存中读取x;
CPU B试图从主内存中读取x时,CPU A检测到了地址冲突(此时对主存数据的引用是独占的)。这时CPU A对相关数据做出响应。
x在chche a中被置为S状态(共享,多个缓存可以同时拥有针对同一内存地址的引用),CPU B成功读到x,x在cache b中被置为S。[主内存的值在多个cpu中存在,此时状态为S]
3.修改数据
那么执行流程是:
CPU A 计算完成后,发指令需要修改x。
CPU A 将x设置为M状态(修改),并通知缓存了x的CPU B, CPU B将本地cache b中的x设置为I状态(无效)
CPU A 对x进行赋值。
4.同步数据
那么执行流程是:
CPU B 发出了要读取x的指令;
CPU B 通知CPU A,CPU A将修改后的数据同步到主内存,cache a 修改为E(独享);[主存值只在一个cpu中,状态为E]
CPU B重新读取主内存的最新值,载入自己的缓存,cache b和cache a中的x置为S状态(共享);[主存值在多个cpu中,状态为S]
ps: 读取数据的时候是直接从自己的缓存中读的,CPU a的缓存中此时已有最新值,而CPU b则需要重新读取主存再载入到自己的缓存最后直接读出。
JMM 模型
JMM模型有点类似现代计算机的内存模型,而volatile 跟JMM息息相关,我们先回忆一下JMM模型。
1)Java虚拟机规范试图定义一种Java内存模型,来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台上都能达到一致的内存访问效果。
2)为了更好的执行性能,java内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存打交道,也没有限制编译器进行代码顺序调整的优化。
所以Java内存模型会存在缓存一致性问题和指令重排序问题。为了解决缓存一致性问题,通常来说有以下2种方案:
通过在总线加LOCK锁的方式(早期);
通过缓存一致性协议(Cache Coherence Protocol);
缓存一致性协议(Cache Coherence Protocol),最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。
MESI的核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,
因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
3)Java内存模型规定所有的变量都是存在主内存当中,每个线程都有自己的工作内存。这里的变量包括实例变量和静态变量(类变量),但是不包括局部变量,因为局部变量是线程私有的。
成员变量(全局变量)就是类里面的变量,是可以共享的变量,不区分static。
静态变量是类中方法外的变量,有static修饰。
实例变量也是类中方法外的变量,无static修饰。
局部变量就是方法中的变量。
4)线程的工作内存保存了被该线程使用的变量(主内存变量的副本),线程对变量的所有操作都必须在工作内存中进行,而不能直接操作操作主内存。
并且每个线程不能访问其他线程的工作内存(工作内存是独立的)。
volatile能否保证原子性?
不能,比如进行i++操作的场景下。
i++操作可以分成 1、线程读取i;(从主存读取值到线程的工作空间,再从工作空间读出值) 2、temp = i + 1; (工作空间更新) 3、i = temp;(写回主存) 三个原子操作。
在多线程环境中,两个线程切换执行,最终理论值应为2。
假设一个线程执行完三个原子性操作,写回1。切换至另一个线程,该线程如果已经执行完原子操作1、2,继续执行原子操作3,
此时没有读取i(主存已更新的值),按原来的旧值运算然后写回主存,写回的值仍然是1。
还有一种情况,切换至另一个线程,该线程才开始执行原子操作1,那么这个时候肯定可以读取到主存的最新值,最终写回的值和理论值相等,为2。
事实上,i++在多线程环境中,有可能得到理论值,但通常得到的值比理论值小。因此,volatile不能保证原子性。
volatile如何保证可见性?
volatile变量,保证新值能立即同步回主内存,以及每次使用前立即从主内存刷新(从主内存载入到自己的工作内存,然后取出)。所以,volatile保证了多线程操作变量的可见性。
volatile如何禁止指令重排的?
指令重排是指在程序执行过程中,为了提高性能, 编译器和CPU可能会对指令进行重新排序(优化)。
volatile为了禁止指令重排,有一个先行发生原则(happens-before)。
程序次序规则:在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。
管程锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作(简单的说,就是再加锁之前需要先解锁)。
volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作。
线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作。
线程终止规则:线程中所有的操作都先行发生于线程的终止检测。
线程中断规则: 一个线程若捕获到中断信号,其必先执行 interrupt()方法。
对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始。
传递性:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C。
实际上volatile保证可见性和禁止指令重排都跟内存屏障有关。
我们来看一段volatile使用的示例代码:
public class Singleton {
private volatile static Singleton instance;
private Singleton (){}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
编译后,对比有volatile关键字和没有volatile关键字时所生成的汇编代码,发现有volatile关键字修饰时,会多出一个lock addl $0x0,(%esp),即多出一个lock前缀指令,lock指令相当于一个「内存屏障」。
lock指令相当于一个内存屏障,它保证以下这几点:
1.重排序时不能把内存屏障后面的指令重排序到内存屏障之前的位置
2.将本处理器的缓存写回主内存
3.如果本处理器进行写入操作,会导致其他处理器中对应的缓存行失效。
第2点和第3点就是保证volatile保证可见性的体现,第1点就是禁止指令重排列的体现。
内存屏障又是什么呢?
内存屏障分为四类(Load 代表读取指令,Store代表写入指令):
内存屏障类型 抽象场景 描述
LoadLoad屏障 Load1; LoadLoad; Load2 禁止读和读的重排序
StoreStore屏障 Store1; StoreStore; Store2 禁止写与写的重排序
LoadStore屏障 Load1; LoadStore; Store2 禁止读和写的重排序
StoreLoad屏障 Store1; StoreLoad; Load2 禁止写和读的重排序
为了实现volatile的内存语义,Java内存模型采取以下的保守策略
在每个volatile写操作的前面插入一个StoreStore屏障。
在每个volatile写操作的后面插入一个StoreLoad屏障。
在每个volatile读操作的后面插入一个LoadLoad屏障。
在每个volatile读操作的后面插入一个LoadStore屏障。
注意的是:volatile写是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障。
我对内存屏障的理解
1.StoreStore内存屏障:该屏障其实就是为了确保在我前面的volatile写操作立刻刷新数据到主内存,且先于我后面的这个volatile写操作!
对于多个cpu的线程而言,一个cpu的线程执行完我前面的这个volatile写操作且刷回主内存,另一个cpu的线程才执行我后面的这个volatile写操作并刷回主内存(开始执行时主内存的值已经是最新的了,虽然对此cpu是无感的)
2.StoreLoad内存屏障:该屏障其实就是为了确保在我前面的这个volatile写操作立刻刷新数据到主内存,然后才能被读取!
对于当前cpu的单线程而言,在我之后的volatile读操作必然可以读到最新值。但内存屏障是为多线程而生的。
对于多个cpu的线程而言,一个cpu的线程执行完这个volatile写操作后,另一个cpu的线程执行到了这个volatile读操作时,
由于StoreLoad内存屏障屏障的加持,此时volatile读操作读到的必然是最新值。
3.LoadLoad屏障:对于多个cpu的线程而言,一个cpu的线程执行完我前面的这个volatile读操作,另一个cpu的线程才执行我后面的这个volatile读操作。[好像没啥特别的作用]
4.LoadStore屏障:对于多个cpu的线程而言,一个cpu的线程执行完我后面的这个volatile读操作(读取完主内存最新值),另一个cpu的线程才执行我后面的这个volatile写操作。
内存屏障这玩意太抽象了,直接看代码:
public class Test {
volatile boolean flag = false;
int b = 0;
/**
* 小结:
* 1)重排序只会在多线程场景下遇到,在单线程场景是不会遇到的。 cpu会对代码做优化重排序,不会对有依赖关系(执行顺序改变,执行结果也改变)的做重排序。
* 2)只要两个指令之间不存在数据依赖,就可以对这两个指令乱序(重排序)。
* 3)volatile通过内存屏障禁止重排序,并保证了变量在多个线程的实时可见性。
* 4)内存屏障,就是为了保证在多线程环境下,每次写入可以立刻刷回主内存,每次读取可以立刻读到主内存最新值。
*/
public void read() {
// 1.StoreStore内存屏障:禁止写与写的重排序
// 该屏障其实就是为了确保在我前面的volatile写操作立刻刷新数据到主内存,且先于我后面的这个volatile写操作!
// 意义:对于多个cpu的线程而言,一个cpu的线程执行完我前面的这个volatile写操作且刷回主内存,
// 另一个cpu的线程才执行我后面的这个volatile写操作并刷回主内存(开始执行时主内存的值已经是最新的了,虽然对此cpu是无感的)
flag = true;//1
// 2.StoreLoad内存屏障:禁止写和读的重排序
// 该屏障其实就是为了确保在我前面的这个volatile写操作立刻刷新数据到主内存,然后才能被读取!
// 意义:对于当前cpu的单线程而言,在我之后的volatile读操作必然可以读到最新值。但内存屏障是为多线程而生的。
// 对于多个cpu的线程而言,一个cpu的线程执行完这个volatile写操作后,另一个cpu的线程执行到了这个volatile读操作时,
// 由于StoreLoad内存屏障屏障的加持,此时volatile读操作读到的必然是最新值。
// ---------------------读--------------
System.out.println(flag);//2
// 3.LoadLoad屏障:禁止读和读的重排序
// 意义:对于多个cpu的线程而言,一个cpu的线程执行完我前面的这个volatile读操作,另一个cpu的线程才执行我后面的这个volatile读操作。[好像没啥特别的作用]
// 4.LoadStore屏障:禁止读和写的重排序
// 意义:对于多个cpu的线程而言,一个cpu的线程执行完我后面的这个volatile读操作(读取完主内存最新值),另一个cpu的线程才执行我后面的这个volatile写操作。
// ---------------------读--------------
}
public void add() {
if (flag) {
int sum = b + b;
System.out.println(sum);
}
}
}
注意:
1)重排序只会在多线程场景下遇到,在单线程场景是不会遇到的。 cpu会对代码做优化重排序,不会对有依赖关系(执行顺序改变,执行结果也改变)的做重排序。
2)只要两个指令之间不存在数据依赖,就可以对这两个指令乱序(重排序)。
3)volatile通过内存屏障禁止重排序,并保证了变量在多个线程的实时可见性。
4)内存屏障,就是为了保证在多线程环境下,每次写入可以立刻刷回主内存,每次读取可以立刻读到主内存最新值。