volatile关键字
# 1 volatile关键字的理解?
被volatile修饰的共享变量具有2个特性。
保证了不同线程之间操作该共享变量的内存可见性。
禁止指令重排序
提示
volatile 关键字是 CPU缓存一致性 在java上的实现,也就是说 volatile 关键字的底层原理是使用 MESI 来保证可见性。
# 2 什么是内存可见性?
# 2.1 Java内存模型
关于内存可见性的话,要先提一下 Java内存模型(JMM) ,Java虚拟机里面定义的一种 抽象模型 。
根据JMM的设计:
系统存在一个
主内存(Main Memory),Java中所有变量都储存在主存中,对于所有线程都是共享的。每条线程都有自己的
工作内存(Working Memory),工作内存中保存的是主存中某些变量的拷贝,线程对所有变量的操作都是在工作内存中进行,线程之间无法相互直接访问,变量传递均需要通过主存完成。
JVM和JMM之间的关系
jmm中的主内存、工作内存与jvm中的Java堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的。
如果一定要勉强对应起来,jmm的主内存可以对应jvm堆中对象实例部分,jmm工作内存可以对应jvm虚拟机栈中的部分区域。
从更低层次上说,主内存就直接对应于物理硬件的内存, 而为了获取更好的运行速度,虚拟机可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问读写的是工作内存。
# 2.2 内存可见性
volatile保证了变量的可见性主要是因为:
1)线程更新volatile变量时,先去更新工作内存中这个变量的副本,然后再将改变后副本的值从工作内存刷新到主内存。
2)线程读取volatile变量的时候,先去主内存中读取最新值到工作内存,然后再从工作内存中读取。
# 2.3 数据同步八大原子操作
关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成。
(1)lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态。
(2)unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
(3)read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用。
(4)load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
(5)use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎。
(6)assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量。
(7)store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
(8)write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送到主内存的变量中。
如果要把一个变量从主内存中复制到工作内存中,就需要按顺序地执行read和load操作,如果把变量从工作内存中同步到主内存中,就需要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
# 3 什么是指令重排序?
# 3.1 指令重排序
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重新排序。
# 3.2 happens-before(先行发生原则)
Java内存模型中会默认保证一定的有序性,就是happens-before规则,指令重排序需要遵循这个规则。
如果2个操作的执行顺序无法从happens-before规则中推导出来,就不能保证他们的有序性,jvm就可能对他们进行重排序。
happens-before主要有以下几条规则:
1.程序次序规则: 一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。(保证单线程的执行结果是正确的,对于无关紧要的重排序是允许的。)
2.锁定规则: 一个unLock操作先行发生于后面对同一个锁的lock操作。
3.volatile变量规则: 对一个变量的写操作先行发生于后面对这个变量的读操作。
4.传递规则: 如果操作A先行发生于操作B,而操作B又先发生于操作C,则可以得出操作A先行发生于操作C。
前四条规则是比较重要的,后四条是比较显而易见的。
1.线程启动规则: Thread对象的start()方法先行发生于此线程的每一个动作
2.线程中断规则: 对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
3.线程终结规则: 线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行。
4.对象终结规则: 一个对象的初始化完成先行发生于他的finalize()方法的开始。
# 3.3 volatile禁止指令重排及保证可见性
# 3.3.1 硬件层的内存屏障
Intel硬件提供了一系列的内存屏障,包括以下几种:
lfence,是一种
Load Barrier读屏障sfence, 是一种
Store Barrier写屏障mfence, 是一种全能型的屏障,具备ifence和sfence的能力
Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。
Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。它后面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令。
提示
不同硬件实现内存屏障的方式不同,Java内存模型屏蔽了这种底层硬件平台的差异,由JVM来为不同的平台生成相应的机器码。JVM中提供了四类内存屏障指令。
| 屏障类型 | 指令示例 | 说明 |
|---|---|---|
| LoadLoad | Load1;LoadLoad;Load2 | 保证load1的读取操作在load2及后续读取操作之前执行 |
| StoreStore | Store1;StoreStore;Store2 | 在store2及其后的写操作执行前,保证store1的写操作已刷新到主内存 |
| LoadStore | Load1;LoadStore;Store2 | 在stroe2及其后的写操作执行前,保证load1的读操作已读取结束 |
| StoreLoad | Store1;StoreLoad;Load2 | 保证store1的写操作已刷新到主内存之后,load2及其后的读操作才能执行 |
内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个:
一是保证特定操作的执行顺序
二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。
提示
由于编译器和处理器都能执行指令重排优化。
如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。
Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。
# 3.3.2 volatile重排序规则表
| 第一个操作 | 第二个操作:普通读写 | 第二个操作:volatile读 | 第二个操作:volatile写 |
|---|---|---|---|
| 普通读写 | 可以重排 | 可以重排 | 不可以重排 |
| volatile读 | 不可以重排 | 不可以重排 | 不可以重排 |
| volatile写 | 可以重排 | 不可以重排 | 不可以重排 |
# 4 总结
在并发领域中,存在三大特性:原子性、有序性、可见性。volatile关键字用来修饰对象的属性,在并发环境下可以保证这个属性的可见性,对于加了volatile关键字的属性,在对这个属性进行修改时,会直接将CPU高级缓存中的数据写回主内存,对这个变量的读取也会从主内存中读取,从而保证了可见性,底层是通过操作系统的内存屏障来实现的,由于使用了内存屏障,所以会禁止指令重排,所以同时也保证了有序性。