面试题:volatile关键字的做用、原理

在只有双重检查锁,没有volatile的懒加载单例模式中,因为指令重排序的问题,我确实不会拿到两个不一样的单例了,但我会拿到“半个”单例java

而发挥神奇做用的volatile,能够当之无愧的被称为Java并发编程中“出现频率最高的关键字”,经常使用于保持内存可见性(随时见到的都是最新值)和防止指令重排序。面试

保持内存可见性

内存可见性(Memory Visibility):全部线程都能看到共享内存的最新状态。编程

失效数据

如下是一个简单的可变整数类:设计模式

public class MutableInteger { private int value; public int get(){ return value; } public void set(int value){ this.value = value; } }

MutableInteger不是线程安全的,由于getset方法都是在没有同步的状况下进行的。若是线程1调用了set方法,那么正在调用的get的线程2可能会看到更新后的value值,也可能看不到安全

解决方法很简单,将value声明为volatile变量:多线程

private volatile int value;

神奇的volatile关键字

神奇的volatile关键字解决了神奇的失效数据问题。并发

Java变量的读写

Java经过几种原子操做完成工做内存主内存的交互:app

  1. lock:做用于主内存,把变量标识为线程独占状态。
  2. unlock:做用于主内存,解除独占状态。
  3. read:做用主内存,把一个变量的值从主内存传输到线程的工做内存
  4. load:做用于工做内存,把read操做传过来的变量值放入工做内存的变量副本中
  5. use:做用工做内存,把工做内存当中的一个变量值传给执行引擎
  6. assign:做用工做内存,把一个从执行引擎接收到的值赋值给工做内存的变量。
  7. store:做用于工做内存的变量,把工做内存的一个变量的值传送到主内存中。
  8. write:做用于主内存的变量,把store操做传来的变量的值放入主内存的变量中。

volatile如何保持内存可见性

volatile的特殊规则就是:优化

  • read、load、use动做必须连续出现
  • assign、store、write动做必须连续出现

因此,使用volatile变量可以保证:this

  • 每次读取前必须先从主内存刷新最新的值。
  • 每次写入后必须当即同步回主内存当中。

也就是说,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和被部分初始化的对象

为了解决这个问题,可使用synchronized关键字将getInstance方法改成同步方法;但这样串行化的单例是不能忍的。因此我猿族前辈设计了DCL(Double Check Lock,双重检查锁)机制,使得大部分请求都不会进入阻塞代码块:

class Singleton { private static Singleton instance; private Singleton(){} public static Singleton getInstance() { if ( instance == null ) { //当instance不为null时,仍可能指向一个“被部分初始化的对象” synchronized (Singleton.class) { if ( instance == null ) { instance = new Singleton(); } } } return instance; } }

“看起来”很是完美:既减小了阻塞,又避免了竞态条件。不错,但实际上仍然存在一个问题——当instance不为null时,仍可能指向一个"被部分初始化的对象"

问题出在这行简单的赋值语句:

instance = new Singleton();

它并非一个原子操做。事实上,它能够”抽象“为下面几条JVM指令:

memory = allocate(); //1:分配对象的内存空间 initInstance(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;

也就是说,在只有DCL没有volatile的懒加载单例模式中,仍然存在着并发陷阱。我确实不会拿到两个不一样的单例了,但我会拿到“半个”单例(未完成初始化)。
然而,许多面试书籍中,涉及懒加载的单例模式最多深刻到DCL,却只字不提volatile。这“看似聪明”的机制,曾经被我广大初入Java世界的猿胞大加吹捧——我在大四实习面试跟谁学的时候,也得意洋洋的从饱汉、饿汉讲到Double Check,如今看来真是傻逼。对于考查并发的面试官而言,单例模式的实现就是一个很好的切入点,看似考查设计模式,其实指望你从设计模式答到并发和内存模型。

volatile如何防止指令重排

volatile关键字经过内存屏障”来防止指令被重排序

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。然而,对于编译器来讲,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,Java内存模型采起保守策略。

下面是基于保守策略的JMM内存屏障插入策略:

  • 在每一个volatile写操做的前面插入一个StoreStore屏障。
  • 在每一个volatile写操做的后面插入一个StoreLoad屏障。
  • 在每一个volatile读操做的后面插入一个LoadLoad屏障。
  • 在每一个volatile读操做的后面插入一个LoadStore屏障。

进阶

在一次回答上述问题时,忘记了解释一个很容易引发疑惑的问题:

若是存在这种重排序问题,那么synchronized代码块内部不是也可能出现相同的问题吗?

即这种状况:

class Singleton { ... if ( instance == null ) { //可能发生不指望的指令重排 synchronized (Singleton.class) { if ( instance == null ) { instance = new Singleton(); System.out.println(instance.toString()); //程序顺序规则发挥效力的地方 } } } ... }

难道调用instance.toString()方法时,instance也可能未完成初始化吗?

首先还请放宽心,synchronized代码块内部虽然会重排序,但不会在代码块的范围内致使线程安全问题

Happens-Before内存模型和程序顺序规则

程序顺序规则:若是程序中操做A在操做B以前,那么线程中操做A将在操做B以前执行。

前面说过,只有在Happens-Before内存模型中才会出现这样的指令重排序问题。Happens-Before内存模型维护了几种Happens-Before规则,程序顺序规则最基本的规则。程序顺序规则的目标对象是一段程序代码中的两个操做A、B,其保证此处的指令重排不会破坏操做A、B在代码中的前后顺序,但与不一样代码甚至不一样线程中的顺序无关

所以,在synchronized代码块内部,instance = new Singleton()仍然会指令重排序,但重排序以后的全部指令,仍然可以保证在instance.toString()以前执行。进一步的,单线程中,if ( instance == null )能保证在synchronized代码块以前执行;但多线程中,线程1中的if ( instance == null )却与线程2中的synchronized代码块之间没有偏序关系,所以线程2中synchronized代码块内部的指令重排对于线程1是不指望的,致使了此处的并发陷阱。

相似的Happens-Before规则还有volatile变量规则监视器锁规则等。程序猿能够借助(Piggyback)现有的Happens-Before规则来保持内存可见性和防止指令重排。

注意点

上面简单讲解了volatile关键字的做用和原理,但对volatile的使用过程当中很容易出现的一个问题是:

错把volatile变量当作原子变量。

出现这种误解的缘由,主要是volatile关键字使变量的读、写具备了“原子性”。然而这种原子性仅限于变量(包括引用)的读和写,没法涵盖变量上的任何操做,即:

  • 基本类型的自增(如count++)等操做不是原子的。
  • 对象的任何非原子成员调用(包括成员变量成员方法)不是原子的。

若是但愿上述操做也具备原子性,那么只能采起锁、原子变量更多的措施。

总结

综上,其实volatile保持内存可见性和防止指令重排序的原理,本质上是同一个问题,也都依靠内存屏障获得解决。更多内容请参见JVM相关书籍。

相关文章
相关标签/搜索