VOLATILE保证可见性的原理
可见性问题
可见性问题指的是一个线程在访问一个共享变量的时候,其他线程对该共享变量的修改对于第一个线程来说是不可见的,下面通过一个例子可以发现可见性问题。
public class Visable {
private static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
new Thread(()-> {
while(flag) {
}
}).start();
Thread.sleep(2000);
new Thread(() -> {
flag = false;
System.out.println("修改了共享变量flag的值");
}).start();
}
}
例子中,有一个静态共享变量flag,初始值为true,然后声明了一个线程一直在读这个共享变量,然后另一个线程对这个共享变量进行修改,但是对于读共享变量的线程来说,是感知不到另外的线程对共享变量的修改,所以一直处于死循环状态,这就是并发编程中的可见性问题。
造成可见性问题的原因
在Java中造成可见性问题的原因是
Java内存模型(JMM)
,在Java内存模型中,规定了共享变量是存放在主内存
中,然后每个线程都有自己的工作内存
,而线程对共享变量的操作,必须先从主内存中读到工作内存中去,至于什么时候写回到主内存是不可预知的,这就导致每个线程之间对共享变量的操作是封闭的,其他线程不可见的。
如何解决可见性问题?
可见性问题的解决可以加
synchronized关键字
,但是有点小题大做的感觉,其实可以使用轻量级的同步机制volatile
来保证高并发的线程之间的可见性问题,具体看如下代码。
public class Visable {
private volatile static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
new Thread(()-> {
while(flag) {
}
}).start();
Thread.sleep(2000);
new Thread(() -> {
flag = false;
System.out.println("修改了共享变量flag的值");
}).start();
}
}
只是在共享变量flag前面加了volatile关键字修饰,当线程对共享变量修改了之后,读共享变量的线程会感知到共享变量的修改,然后退出死循环,这证明了volatile可以保证线程的可见性。
VOLATILE是如何保证可见性?
有volatile修饰的共享变量进行写操作的时候多出一条带
lock前缀的指令
,如下所示
我们相信volatile保证线程可见性肯定与这条lock前缀的指令有关,经过查证知道,lock前缀的指令在多核处理器下会引发两件事情1. 将当前处理器缓存行的数据写回到系统内存。
2. 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。
为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存后再进行操作,但是操作完了不知道什么时候写回内存。而对声明了volatile关键字的变量进行写操作,JVM会向处理器发送一条lock前缀的指令,将这个变量所在的缓存行立即写回系统内存。并且为了保证各个处理器的缓存是一致的,实现了缓存一致性协议
,各个处理通过嗅探
在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态
,那么下次对这个数据进行操作,就会重新从系统内存中获取最新的值。对应JMM来说就是:
1. Lock前缀的指令让线程工作内存中的值写回主内存中;
2. 通过缓存一致性协议,其他线程如果工作内存中存了该共享变量的值,就会失效;
3. 其他线程会重新从主内存中获取最新的值;
VOLATILE保证有序性的原理
为了性能优化,JVM会在不改变
数据依赖性
的情况下,允许编译器和处理器对指令序列进行重排序
,而有序性问题指的就是程序代码执行的顺序与程序员编写程序的顺序不一致,导致程序结果不正确的问题。而加了volatile修饰的共享变量,则通过内存屏障
解决了多线程下有序性问题。
内存屏障
内次屏障分为以下4类
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad Barries | Load1;LoadLoad;Load2 | 确保Load1数据的装载先于Load2以及后续装载指令的装载。 |
StoreStore Barries | Store1;StoreStore;Store2 | 确保Store1数据刷新到内存先于Store2以及后续存储指令的存储。 |
LoadStore Barries | Load1;LoadStore;Store2 | 确保Load1数据的装载先于Store2数据刷新到内存以及后续存储指令的存储。 |
StoreLoad Barries | Store1;StoreLoad;Load2 | 确保Store1数据刷新到内存先于Load2数据的装载以及后续装载指令的装载。 |
VOLATILE内存语义的实现
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序,下面是基于保守策略的JMM内存平展插入策略。
* 在每个volatile写操作的前面插入一个StoreStore屏障。
* 在每个volatile写操作的后面插入一个StoreLoad屏障。
* 在每个volatile读操作的后面插入一个LoadLoad屏障。
* 在每个volatile读操作的后面插入一个LoadStore屏障。
volatile在写操作前后插入了内存屏障后生成的指令序列示意图如下:
volatile在读操作后面插入了内存屏障后生成的指令序列示意图如下:
参考
《Java并发编程的艺术》