半吊子彻底搞懂volatile关键字!
刚开始学习Java并发时,百度一查volatile关键字的作用,都能知道有两个:
- 保证变量的可见性
- 禁止指令重排序
可是,什么是可见性呢?怎么保证的呢?怎么禁止指令重排序呢?这样做有什么好处呀?
小七心里有大大的疑惑,于是马上开始了大搜罗,终于把它搞懂了!
先上个脑图:
保证可见性
内存可见性是什么?
指的是共享变量对于线程之间的可见性,当一个线程修改了某个共享变量,另一个线程可以读取到修改后的值。
上个栗子,先让我们看段代码,判断该程序能不能正常终止。
public class Demo {
private static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
System.out.println(Thread.currentThread().getName()+"执行");
while (!flag){
}
System.out.println(Thread.currentThread().getName()+"执行结束");
},"A").start();
TimeUnit.SECONDS.sleep(2);
new Thread(()->{
System.out.println(Thread.currentThread().getName()+"执行");
flag = true;
System.out.println(Thread.currentThread().getName()+"修改了flag");
},"B").start();
}
}
结果是“A执行结束”这句话将迟迟不会输入,程序不能终止。
因为flag在没有加上volatile关键字情况下,A线程开启后,睡眠了2秒,才开启了B线程,即在B线程对flag进行修改前,A线程从主内存将flag刷回到了工作内存。后续B线程对flag的操作,A线程都是不可见的,所以A线程里的flag永远是false,跳不出循环。
解决该现象有两种方法:
-
将
TimeUnit.SECONDS.sleep(2);注释掉。(但是这种方法有时候也会不灵验)必须保证B线程在A线程对
flag进行判断前对flag进行了修改并刷回主内存,A线程第一次读取flag,到主内存取到了修改后的值放在自己的本地内存上。程序正常退出。 -
在
flag前加上volatile关键字不管是否有睡眠操作,B线程对
flag的修改,A线程能够立马感知,并拿到最新值。(这就是内存的可见性)
很多情况下,我们的线程都是以无法预知的速度向前推进的,采用第一种方法,我们根本无法保证A线程读到的变量是B修改好的,程序正常退出。而加上volatile能保证线程间的可见性。
如何实现内存可见性?
上面介绍了volatile的神力,还不够。学习知识不仅要知其然更要知其所以然 —— 如何实现内存可见性的? 这就涉及到Java内存模型和volatile的特殊语义了!看完这两个概念,我们就能明白~
Java内存模型
因为现代计算机为了高效,往往会在高速缓冲区存储共享变量,而主内存就在内存中,访问相对较慢。而本地内存是抽象概念,通常在缓存区,写缓存和寄存器中。
Java线程之间的通信由JMM控制,即它定义了线程和主内存之间的抽象关系。
根据JMM的规定,线程对共享变量的所有操作都必须在自己的本地内存中进行,不能直接从主内存中读取。
通过上面的模型图可以看到,线程B并不是先去主内存中读取共享变量的值,而是在本地内存B找到它,发现它已经被更新了,然后再去主内存读取这个共享变量的新值,并拷贝到本地内存中,然后再读取本地内存的新值。
而对于主内存和本地内存的交互协议,Java内存模型定义了8种原子性操作来完成:
- lock:作用于主内存,把变量标识为线程独占状态。
- unlock:作用于主内存,解除独占状态。
- read:作用主内存,把一个变量的值从主内存传输到线程的工作内存。
- load:作用于工作内存,把read操作传过来的变量值放入工作内存的变量副本中。
- use:作用工作内存,把工作内存当中的一个变量值传给执行引擎。
- assign:作用工作内存,把一个从执行引擎接收到的值赋值给工作内存的变量。
- store:作用于工作内存的变量,把工作内存的一个变量的值传送到主内存中。
- write:作用于主内存的变量,把store操作传来的变量的值放入主内存的变量中。
volatile写-读的内存语义
我们依旧拿上面的栗子,画出JMM模型图:
线程是如何发现被volatile修饰的变量已经更新的呢?这归功于volatile的特殊语义。
-
写的内存语义:
当写一个volatile变量flag时,JMM会把该线程对应的本地内存中的变量刷新到主内存中。
也就是说:assign->store->write动作必须连续出现。 -
读的内存语义:
当读一个volatile变量flag时,JMM会把该线程对应的本地内存置为无效,再从主内存中读取该变量。
也就是说:read->load->use动状必须连续出现。
JMM的内存屏障
在底层,JMM是通过内存屏障禁止了指令的重排序,来保证volatile的特殊语义的。这个我们在下面一节详细讲噢~
禁止指令重排序
为什么指令要重排序?
简单地说,是为了优化程序性能,对原有指令执行顺序进行重排。
指令重排有三种:
- 编译器优化重排
- 在 不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令并行重排
- 现代处理器采用了指令级并行技术来将多条指令重叠执行,如果不存在数据依赖性
- 内存系统重排
指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。因此在多线程下,指令重排可能会导致一些问题。
JMM对禁止指令重排序的实现
题外话
在旧的Java内存模型,虽然禁止了volatile变量之间的重排序,但是volatile变量与普通变量的读写重排序,依旧是会出现我们开头栗子的糟糕情况,只不过这次对象是普通变量。
因此,JSR-133的专家组决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量和普通变量的重排序。
编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。JMM采用了一种相对保守的内存屏障插入策略:
- 在每个volatile写操作前插入一个
StoreStore屏障 - 在每个volatile写操作之后插入一个
StoreLoad屏障 - 在每个volatile读操作后插入一个
LoadLoad屏障 - 在每个volatile读操作后再插入一个
LoadStore屏障
StoreStore屏障:保证在volatile写之前,前面的普通写操作已经对任意处理器可见了。
StoreLoad屏障:避免volatile写与后面的有可能的volatile写/读重排序,因为编译器无法判断后面是否有volatile读/写,所以保守地在之后插入了该屏障
LoadLoad屏障:避免volatile读与后面的普通读重排序
LoadStore屏障:避免了volatile读与后面的普通写重排序
禁止重排序的好处?
就是为了保证可见性。比如DCL单例模式中volatile的运用:
public class Singleton{
private volatile static Singleton instance;
public static Singleton getInstance(){
if(instance == null){
synchronized(Singleton.class){
if(instance == null){
instance = new Singleton();
}
}
}
return instance;
}
}
instance = new Singleton();
//可以分解为三个步骤
1. memory = allocate(); //分配内存
2. ctorInstanc(memory); //初始化对象
3. s = memory; //设置s指向刚刚分配的内存
//可能会被重排序为 1 -> 3 -> 2
如果不加volatile关键字的话,比如线程A执行完 1-> 3后,这个时候线程B执行到了第一个判断,判断instance不为空,直接返回一个还未初始化的instance。
加了volatile关键字,JMM使用StoreLoad内存屏障,避免了volatile写和后面的volatile读重排序。
注意:volatile阻止的不是instance = new Singleton()这句话内部的1->2->3指令重排,而是保证了在这个语句写操作完成之前,不会调用读操作if(instance == null)
与synchronized的区别
-
性能上:
volatile是比锁更轻量级的线程间通信机制,所以性能肯定比synchronized要好。 -
原子性:
volatile仅仅只保证对单个volatile变量的读写具有原子性,而synchronized对整个临界区代码的执行具有原子性。 -
侧重点:
volatile主要用于解决变量在多个线程之间的可见性,而synchronized更关注线程之间访问资源的同步性。
嗅探又是什么?
嗅探是用来检查数据是否失效的,它跟volatile有密不可分的关系。
刚刚我们聊到如何实现内存可见性时,抛出了一个问题,线程是如何发现被volatile修饰的变量已经更新的呢? 在逻辑层面上,是归功于volatile的特殊语义。在物理层面上,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理的缓存行设置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器的缓存中。
使用volatile的建议
由于需要不断的从主内存嗅探和CAS不断循环将缓存的值刷回主内存,无效交互会导致总线带宽达到峰值。所以不要大量使用volatile。
参考资料
<<深入理解Java虚拟机>>
<<Java并发编程的艺术>>