知名公司面试题:谈谈你对volatile关键字的理解
作为一名java程序员,求职面试时,关于volatile关键字时常会遇到。张工最近到某知名互联网公司面试,面试官提出这样的一个问题:
谈谈你对volatile关键字的理解
张工一时间没有回答上来,面试官:你都工作三年了,怎么对volatile关键字都没掌握啊。
张工被面试官这么一说,都不好意思了。
对于一名java开发者,不管是在求职面试还是项目实际开发中,volatile都是一个需要掌握的知识点,是需要掌握好的。我们平时在阅读源码的过程中,时常会遇到volatile关键字,譬如Atomic类,通过源码我们会发现volatile无处不在。
为什么要用到volatile关键字?
在Java多线程的开发中有三种特性:
原子性
可见性
有序性
volatile主要作用是保证内存可见性和防止指令重排序。
保持内存可见性
内存可见性(Memory Visibility):所有线程都能看到共享内存的最新状态。可见性就是指当一个线程修改了线程共享变量的值,其它线程能够立即得知这个修改。
每一个线程都有一份自己的本地内存,所有线程共用一份主内存。如果一个线程对主内存中的数据进行了修改,而此时另外一个线程不知道是否已经发生了修改,就说此时是不可见的。
这种不可见的状况会带来一个问题,两个线程有可能会操作同一份但是值不一样的数据。
这时该volatile闪亮出场了。
那么volatile是如何保持内存可见性的。
volatile的特殊规则就是:
read、load、use动作必须连续出现。
assign、store、write动作必须连续出现。
所以,使用volatile变量能够保证:
每次读取前必须先从主内存刷新最新的值。
每次写入后必须立即同步回主内存当中。
也就是说,volatile关键字修饰的变量看到的随时是自己的最新值。线程1中对变量v的最新修改,对线程2是可见的。
防止指令重排序
在基于偏序关系的Happens-Before内存模型中,指令重排技术大大提高了程序执行效率,但同时也引入了一些问题。
一个指令重排的问题——被部分初始化的对象
懒加载单例模式和竞态条件
我们来看一个懒加载的单例模式:
class Singleton {
private static Singleton instance;
private Singleton(){}
public static Singleton getInstance() {
if ( instance == null ) { //存在竞态条件
instance = new Singleton();
}
return instance;
}
}
竞态条件会导致instance引用被多次赋值,使用户得到两个不同的单例。
DCL和被部分初始化的对象
为了解决这个问题,最简单的方法是将 getInstance() 方法设为同步(synchronized),虽然解决了问题,但很容易导致阻塞,这就引出了双重检验锁DCL(Double Check Lock)机制,使得大部分请求都不会进入阻塞代码块:
class Singleton {
private static Singleton instance;
private Singleton(){}
public static Singleton getInstance() {
if ( instance == null ) {
synchronized (Singleton.class) {
if ( instance == null ) {
instance = new Singleton();
}
}
}
return instance;
}
}
这段代码看起来很完美:不仅减少了阻塞,又避免了竞态条件。
但实际上仍然存在一个问题——当instance不为null时,仍可能指向一个"被部分初始化的对象"。
问题出在这里:
instance = new Singleton();
它并不是一个原子操作。事实上,它可以”抽象“为下面几条JVM指令:
memory = allocate();//1:分配对象的内存空间
ctorInstance(memory);//2:初始化对象
instance = memory;//3:设置instance指向刚分配的内存地址
操作2依赖于操作1,但是操作3并不依赖于操作2,所以JVM可以以“优化”为目的对它们进行重排序,把它们重新排序:
memory = allocate();//1:分配对象的内存空间
instance = memory;//3:设置instance指向刚分配的内存地址(注意此时对象还未初始化)
ctorInstance(memory);//2:初始化对象
可以看到指令重排后,操作3排在了操作2前,即引用instance指向内存memory时,这段崭新的内存还没有初始化,引用instance指向了一个"被部分初始化的对象"。
此时,如果另一个线程调用getInstance方法,由于instance已经指向了一块内存空间,从而if条件判为false,方法返回instance引用,用户得到了没有完成初始化的“半个”单例。
解决这个该问题,只需要将instance声明为volatile变量:
private static volatile Singleton instance;
调整后一个完整的单例模式
public class Singleton {
private static volatile Singleton instance;
private Singleton(){}
public static Singleton getInstance() {
if ( instance == null ) {
synchronized (Singleton.class) {
if ( instance == null ) {
instance = new Singleton();
}
}
}
return instance;
}
}
volatile不能保证原子性
volatile关键字保证可见性和有序性,但是并意味着volatile就可以保证原子性,原子性,也就是一个操作,要么不执行,要么执行到底。
volatile:从最终汇编语言层面来看,volatile使得每次将i进行了修改之后,增加了一个内存屏障lock addl $0x0,(%rsp)保证修改的值必须刷新到主内存才能进行内存屏障后续的指令操作。但是内存屏障之前的指令并不是原子的。
对此我们使用代码来验证一下:
public class VolatileTest {
public static volatile int i = 0;
public static void increase() {
i++;
}
}
字节码:
volatile方式的i++,一般有四个步骤:
load、
Increment、
store、
Memory Barriers
在某一时刻线程1将i的值load取出来,放到cpu缓存中,然后再将此值放置到寄存器A中,然后A中的值自增1。
感觉这样解释有点不好理解,我们可以这样理解:寄存器A中保存的是中间值,并没有直接修改i值,其他线程并不会获取到这个自增1的值。
此时如果有线程2也执行同样的操作,获取值i==10,自增1变为11,然后刷入主内存。
此时由于线程2修改了i的值,实时的线程1中的i==10的值缓存失效了,重新从主内存中读取,变为11。接下来线程1恢复。将自增过后的A寄存器值11赋值给cpu缓存i。这样就出现了线程安全问题。
总结:
volatile保持内存可见性和防止指令重排序的原理,其实都是依靠内存屏障。volatile可以保证可见性、有序性,但不能保证原子性。
文中关于volatile讨论基于java8。
由于笔者水平有限,文中纰漏之处在所难免,权当抛砖引玉,不妥之处,请大家批评指正。
-END-
作者:洪生鹏 技术交流、媒体合作、品牌宣传请加作者微信: hsp-88ios
猜你喜欢
我看你简历上写着熟悉kafka,如果让你自己写一个消息队列,该如何进行设计?说一下你的思路
更多惊喜,请长按二维码识别关注
上一篇: C++学习(第五章)