volatile 关键字精讲
1.错误案例
通过一个案例引出volatile关键字,例如以下代码示例 : 此时没有加volatile关键字两个线程间的通讯就会有问题
public class ThreadsShare {
private static boolean runFlag = false; // 此处没有加 volatile
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
System.out.println("线程一等待执行");
while (!runFlag) {
}
System.out.println("线程一开始执行");
}).start();
Thread.sleep(1000);
new Thread(() -> {
System.out.println("线程二开始执行");
runFlag = true;
System.out.println("线程二执行完毕");
}).start();
}
}
输出结果 :
结论 : 线程一并没有感觉到线程二已经将 runFlag 改为true 的信号, 所以"线程一开始执行"这句话一直也没有输出,而且程序也没有终结
就像下面的场景:
在当前场景中就可能出现在处理器 A 和处理器 B 没有将它们各自的写缓冲区中的数据刷回内存中, 将内存中读取的A = 0、B = 0 进行给X和Y赋值,此时将缓冲区的数据刷入内存,导致了最后结果和实际想要的结果不一致。因为只有将缓冲区的数据刷入到了内存中才叫真正的执行
造成这个问题的原因:
计算机在执行程序时,每条指令都是在处理器中执行的。而执行指令过程中,势必涉及到数据的读取和写入。程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,由于处理器执行速度很快,而从内存读取数据和向内存写入数据的过程跟处理器执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。为了解决这个问题,就设计了CPU高速缓存,每个线程执行语句时,会先从主存当中读取值,然后复制一份到本地的内存当中,然后进行数据操作,将最新的值刷新到主存当中。这就会造成一种现象缓存不一致
针对以上现象提出了缓存一致性协议: MESI
核心思想是:MESI协议保证了每个缓存中使用的共享变量的副本是一致的。当处理器写数据时,如果发现操作的变量是共享变量,即在其他处理器中也存在该变量的副本,会发出信号通知其他处理器将该共享变量的缓存行置为无效状态(总线嗅探机制),因此当其他处理器需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
嗅探式的缓存一致性协议:
所有内存的传输都发生在一条共享的内存总线上,而所有的处理器都能看到这条总线,缓存本身是独立的,但是内存是共享的。所有的内存访问都要进行仲裁,即同一个指令周期中只有一个处理器可以读写数据。处理器不仅在内存传输的时候与内存总线打交道,还会不断的在嗅探总线上发生数据交换跟踪其他缓存在做什么,所以当一个处理器读写内存的时候,其他的处理器都会得到通知(主动通知),他们以此使自己的缓存保存同步。只要某个处理器写内存,其他处理器就会知道这块内存在他们的缓存段中已经是无效的了。
MESI详解:
在MESI协议中每个缓存行有四个状态 :
- Modified修改的,表示这行数据有效,数据被修改了和内存中的数据不一致,数据只存在当前缓存中
- Exclusive独有的,这行数据有效,数据和内存中的数据一致,数据只存在在本缓存
- Shared共享的,这行数据有效,数据和内存中的数据一致,数据存在很多缓存中,
- Invalid这行数据无效
这里的Invalid,shared,modified都符合嗅探式的缓存一致性协议,但是Exclusive表示独占的,当前数据有效并且和内存中的数据一致,但是只在当前缓存中Exclusive状态解决了一个处理器在读写内存的之前我们要通知其他处理器这个问题,只有当缓存行处于Exclusive和modified的时候处理器才能写,就是说只有在这两种状态之下,处理器是独占这个缓存行的。
当处理器想写某个缓存行的时候,如果没有控制权就必须先发送一条我要控制权的请求给总线,这个时候会通知其他处理器把他们拥有同一缓存段的拷贝失效,只要在获得控制权的时候处理器才能修改数据,并且此时这个处理器直到这个缓存行只有一份拷贝并且只在它的缓存里,不会有任何冲突,反之如果其他处理器一直想读取这个缓存行,独占或已修改的缓存行必须要先回到共享状态,如果是已经修改的缓存行,还要先将内容回写到内存中
所以 java 提供了一个轻量级的同步机制volatile
2.作用
volatile是Java提供的一种轻量级的同步机制。volatile是轻量级,因为它不会引起线程上下文的切换和调度。但是volatile 变量的同步性较差,它不能保证一个代码块的同步,而且其使用也更容易出错。volatile关键字 被用来保证可见性,即保证共享变量的内存可见性以解决缓存一致性问题。一旦一个共享变量被 volatile关键字修饰,那么就具备了两层语义:内存可见性和禁止进行指令重排序。在多线程环境下,volatile关键字主要用于及时感知共享变量的修改,并使得其他线程可以立即得到变量的最新值
使用volatile关键字后程序的效果 :
使用方式 :
private volatile static boolean runFlag = false;
代码 :
public class ThreadsShare {
private volatile static boolean runFlag = false;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
System.out.println("线程一等待执行");
while (!runFlag) {
}
System.out.println("线程一开始执行");
}).start();
Thread.sleep(1000);
new Thread(() -> {
System.out.println("线程二开始执行");
runFlag = true;
System.out.println("线程二执行完毕");
}).start();
}
}
输出结果 :
结论 : 线程一感觉到了线程二已经将 runFlag 改为true 的信号, 所以"线程一开始执行"这句话得到了输出,而且程序终结了。
volatile 两个效果:
- 当一个线程写一个volatile变量时,JMM会把该线程对应的本地内存中的变量值强制刷新到主内存中去
- 这个写会操作会导致其他线程中的这个共享变量的缓存失效,要使用这个变量的话必须重新去主内存中取值。
思考 : 如果两个处理器同时读取或者修改同一个共享变量咋办?
多个处理器要访问内存,首先要获得内存总线锁,任何时刻只有一个处理器能获得内存总线的控制权,所以不会出现以上情况。
重点 : volatile关键字 被用来保证可见性,即保证共享变量的内存可见性以解决缓存一致性问题
3.特点
3.1 可见性
当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性(以上的案例就已经展示了可见性的作用了)
3.2 禁止指令重排
在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性
volatile关键字禁止指令重排序有两层意思:
- 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
- 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
为了解决处理器重排序导致的内存错误,java编译器在生成指令序列的适当位置插入内存屏障指令,来禁止特定类型的处理器重排序
内存屏障指令 : 内存屏障是volatile语义的实现下面会讲解
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoadBarriers | Load1;LoadLoad;Load2 | Load1数据装载发生在Load2及其所有后续数据装载之前 |
StoreStoreBarriers | Store1;StoreStore;Store2 | Store1数据刷回主存要发生在Store2及其后续所有数据刷回主存之前 |
LoadStoreBarriers | Load1;LoadStore;Store2 | Load1数据装载要发生在Store2及其后续所有数据刷回主存之前 |
StoreLoadBarriers | Store1;StoreLoad;Load2 | Store1数据刷回内存要发生在Load2及其后续所有数据装载之前 |
4.volatile 与 happens-before
public class Example {
int r = 0;
double π = 3.14;
volatile boolean flag = false; // volatile 修饰
/**
* 数据初始化
*/
void dataInit() {
r = 1; // 1
flag = true; // 2
}
/**
* 数据计算
*/
void compute() {
if(flag){ // 3
System.out.println(π * r * r); //4
}
}
}
如果线程A 执行 dataInit() ,线程B执行 compute() 根据 happens-before 提供的规则(前一篇java内存模型有讲) java内存模型有讲步骤 2 一定在步骤 3 前面符合volatile规则, 步骤 1 在步骤 2前面,步骤 3 在步骤 4 前面,所以根据传递性规则 步骤 1 也在步骤 4 前面。
5.内存语义
5.1 读内存语义
当读取一个volatile的变量时会将本地的工作内存变成无效,去内存中获取volatile修饰的变量当前值。
5.2 写内存语义
当写一个volatile的变量时会将本地的工作内存中的值强制的刷回内存中。
5.3 内存语义的实现
JMM针对编译器制定的volatile重排序规则表
是否能重新排序 | 第二个操作 | ||
---|---|---|---|
第一个操作 | 普通的读或者写 | volatile读 | volatile写 |
普通的或者写 | NO | ||
volatile 读 | NO | NO | NO |
volatile 写 | NO | NO |
举例说明,第三行最后一个单元格的意思:
当地一个操作为普通操作的时候,如果第二个操作为volatile写,那么编译器不能重排序这两个操作
5.4 总结
- 当第二个操作是volatile写的时候,第一个操作无论是什么都不能进行重排序操作。这个规则保证了volatile写之前的操作是不能被编译器重新排到volatile写后面的
- 当第一哥操作是volatile读的时候,无论第二个操作是什么都不能进行重新排序。这个规则确保volatile读之后的操作不会被编译器编译到volatile之前
- 当第一个操作volatile写,第二个操作是volatile读的时候不能重排序
为了实现volatile的内存语义,编译器在生成字节码的时候,会在指令序列中插入内存屏障来禁止特定类型的处理器排序。
JMM内存屏障插入策略:
- 在每个 volatile 写操作的前面插入一个StoreStore 屏障。
- 在每个 volatile 写操作后面插入一个StoreLoad 屏障。
- 在每个 volatile 读操作的后面插入一个LoadLoad 屏障。
- 在每个 volatile 读操作的后面插入一个LoadStore 屏障。
volatile写插入内存屏障后生成的指令序列示意图:
StoreStore屏障可以保证在volatile 写之前,其前面的所有普通写操作已经对任意处理器可见了,这是因为StoreStore屏障将保障上面所有的普通写在volatile 写之前刷新到主内存。
StoreLoad屏障可以保证volatile写与后面可能有的volatile读或者写操作重排序。
volatile读插入内存屏障后生成的指令序列示意图:
LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。
LoadStore 屏障用来禁止处理器把上面的volatile读与下面的普通读写重排序。
6.实战
6.1 使用 volatile 必须具备条件
- 对变量的写操作不依赖于当前值
- 该变量没有包含在具有其他变量的不变式中
实际上,这些条件表明可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。事实上,上面的两个条件就是保证对该volatile变量的操作是原子操作,这样才能保证使用 volatile关键字的程序在并发时能够正确执行
6.2 volatile 主要使用的场景
在多线程环境下及时感知共享变量的修改,并使得其他线程可以立即得到变量的最新值
场景一 : 状态标记量(文中举例)
public class ThreadsShare {
private volatile static boolean runFlag = false; // 状态标记
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
System.out.println("线程一等待执行");
while (!runFlag) {
}
System.out.println("线程一开始执行");
}).start();
Thread.sleep(1000);
new Thread(() -> {
System.out.println("线程二开始执行");
runFlag = true;
System.out.println("线程二执行完毕");
}).start();
}
}
场景二 Double-Check
DCL版单例模式是double check lock 的缩写,中文名叫双端检索机制。所谓双端检索,就是在加锁前和加锁后都用进行一次判断
public class Singleton1 {
private static Singleton1 singleton1 = null;
private Singleton1 (){
System.out.println("构造方法被执行.....");
}
public static Singleton1 getInstance(){
if (singleton1 == null){ // 第一次check
synchronized (Singleton1.class){
if (singleton1 == null) // 第二次check
singleton1 = new Singleton1();
}
}
return singleton1 ;
}
}
用synchronized只锁住创建实例那部分代码,而不是整个方法。在加锁前和加锁后都进行了判断,这就叫双端检索机制。这样确实只创建了一个对象。但是,这也并非绝对安全。new 一个对象也是分三步的:
- 1.分配对象内存空间
- 2.初始化对象
- 3.将对象指向分配的内存地址,此时这个对象不为null
步骤二和步骤三不存在数据依赖,因此编译器优化时允许这两句颠倒顺序。当指令重排后,多线程去访问也会出问题。所以便有了如下的最终版单例模式。这种情况不会发生指令重排
public class Singleton2 {
private static volatile Singleton2 singleton2 = null;
private Singleton2() {
System.out.println("构造方法被执行......");
}
public static Singleton2 getInstance() {
if (singleton2 == null) { // 第一次check
synchronized (Singleton2.class) {
if (singleton2 == null) // 第二次check
singleton2 = new Singleton2();
}
}
return singleton2;
}
}