谈谈volatile关键字

概述

​ 在多线程访问共享变量时,java通过volatile关键字保证变量的可见性,相比于synchronized关键字,volatile并不会引起线程上下文的切换,因此具有较小的开销。同时,volatile可以禁止指令重排序。这篇文章将对以上两点作用的,希望可以加深对一些概念的理解。

volatile保证变量可见性

​ 共享变量的可见性,简单的说就是在一个线程中修改了变量,在另一个线程可以读到修改后的变量。在对这块实现原理进行介绍之前,我们必须对java内存模型有一定的了解。

java内存模型

​ java内存模型规定了所有的共享变量(除局部变量及方法参数)都存储在主内存中,而每个线程也有自己的工作内存,线程的工作内存保存了该线程使用到的变量的主内存的副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接读取主内存中的变量,具体如下如所示:

​ 主内存和工作内存间的交互通过如下8中操作来完成,可以看出主内存变量值到工作内存可以通过read和load完成,工作内存到主内存变量值可以通过store和write完成。

(1) lock(锁定):作用于主内存,把一个变量标识为一个线程独占的状态;

(2) unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

(3) read(读取):作用于主内存,把一个变量从主内存传输到工作内存;

(4) load(载入):作用于工作内存,把read操作从主内存中得到的变量值放入工作内存的变量副本中;

(5) use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。

(6) assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

(7) store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。

(8) write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

​ 对于非volatile变量读取变量值会直接执行load,use操作从工作内存中读取变量,这种可能会出现读取的变量值是失效的,与主内存中的不一致。volatile变量通过强制要求read,load,use必须连续出现保证每次读取前都从主内存中刷新最新的值。通过要求assign,store,write必须连续出现保证了修改的数据立刻同步到主内存中,这种就保证了变量的可见性。

volatile禁止指令重排序

​ 指令重排序是指编译器和处理器为了优化程序执行顺序对程序的执行语句进行优化。举一个简单的创建对象的语句:

1
dclProblemSingleton = new DCLProblemSingleton();

​ 这条语句会被jvm解析为以下三个操作执行:(1) 为对象分配内存;(2) 为对象赋值;(3)将内存地址赋值给dclProblemSingleton变量。由于(1)和(3)并不存在依赖关系,所以指令重排序后语句执行的顺序可能是(1)(3)(2)。这个时候如果作为共享变量多线程访问就会存在问题。经典的DCL双重检查锁实现的饱汉模式的单例就会存在这个问题,可能存在又有的线程访问到未被初始化的对象,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class DCLProblemSingleton {
private int ele = 0;

private static DCLProblemSingleton dclProblemSingleton;

private DCLProblemSingleton() {}

public static DCLProblemSingleton getDclProblemSingleton() {
//第一次检测
if (dclProblemSingleton == null) {
synchronized (DCLProblemSingleton.class) {
//第二次检测
if (dclProblemSingleton == null) {
dclProblemSingleton = new DCLProblemSingleton();//可能出现问题的地方
}
}
}
return dclProblemSingleton;
}
}

​ 正是由于java中的指令重排序,DCL方式实现的指令重排序会存在问题,解决这个问题的方式就是通过将dclProblemSingleton声明为volatile来禁止指令重排序。

内存屏障

​ JDK1.5中已引入了内存屏障,volatile关键字是通过内存屏障(栅栏)来实现的禁止质量重排序。内存屏障又称为内存栅栏,是一组cpu指令。不同架构的cpu的内存屏障实现都不相同,我们以Intel x86为例子,其实现了如下内存栅栏:

(1) sfence: 确保sfence前所有的store先与sfence所有的store命令执行;

(2) Ifence: 确保Ifence前所有的load先与Ifence所有的load命令执行;

(3) mfence: 功能相当于sfence和mfence总和。

(4) Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。

有了上述的内存屏障命令我们看下volatile是如何实现禁止指令重排序的?

​ 在x86平台上volatile是通过Lock前缀实现的,Lock前缀先对总线/缓存加锁,然后执行后面的指令,最后释放锁后会把高速缓存中的脏数据全部刷新回主内存。在Lock前缀锁住总线/缓存期间,其他cpu的读写请求都会被阻塞直到释放,Lock后的写操作会会使得其他cpu的的缓存失效。例如以上的赋值语句dclProblemSingleton = new DCLProblemSingleton(),如果dclProblemSingleton被volatile关键字修饰,那么执行(3)上加上lock,那么(3)前面的步骤(1)(2)不执行完成就无法执行(3),也就禁止了指令重排序。在lock释放后,又会将缓存刷新到主存中。

内存屏障与上述的可见性实现是否有关系?

​ 在上面的lock操作中,在lock释放后会将缓存刷新到主存中,也就是我们上面所将的assign,store,write必须连续出现。我理解volatile的可见性在jvm层面是通过read,load,use必须连续出现,assign,store,write必须连续出现保证的。而这个连续出现则是内存屏障使用的表象,底层原理还是依靠内存屏障来解决。

参考

周志明. “深入理解Java虚拟机:JVM高级特性与最佳实践(第2版)”

https://juejin.im/post/5a52cfdc518825733b0eb69a

https://blog.csdn.net/HJsir/article/details/80713783

袁琼琼 wechat
欢迎您扫一扫上面的微信公众号,订阅我的博客!
多谢支持,共同成长!
0%