你真的了解 volatile 关键字吗?(3)

当程序执行到 volatile 变量的读操作或者写操作时,在其前面的操作肯定已经全部进行,且对后面的操作可见,在其后面的操作肯定还没有进行
    在进行指令优化时,不能将 volatile 变量之前的语句放在对 volatile 变量的读写操作之后,也不能把 volatile 变量后面的语句放到其前面执行

举个栗子:

x=0;            // 1
y=1;            // 2
volatile z = 2;  // 3
x=4;            // 4
y=5;            // 5

变量z为 volatile 变量,那么进行指令重排序时,不会将语句 3 放到语句 1、语句 2 之前,也不会将语句 3 放到语句 4、语句 5 后面。但是语句 1 和语句 2、语句 4 和语句 5 之间的顺序是不作任何保证的,并且 volatile 关键字能保证,执行到语句 3 时,语句 1 和语句 2 必定是执行完毕了的,且语句 1 和语句 2 的执行结果是对语句 3、语句 4、语句 5是可见的。

回到之前的例子:

// 线程1
String config = initConfig();  // 1
volatile boolean inited = true; // 2
 
// 线程2
while(!inited){
      sleep();
}
 
doSomeThingWithConfig(config);

之前说这个例子提到有可能语句2会在语句1之前执行,那么就可能导致执行 doSomThingWithConfig() 方法时就会导致出错。

这里如果用 volatile 关键字对 inited 变量进行修饰,则可以保证在执行语句 2 时,必定能保证 config 已经初始化完毕。

synchronized 关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而 volatile 关键字在某些情况下性能要优于 synchronized,但是要注意 volatile 关键字是无法替代 synchronized 关键字的,因为 volatile 关键字无法保证操作的原子性。通常来说,使用 volatile 必须具备以下三个条件:

对变量的写入操作不依赖变量的当前值,或者能确保只有单个线程更新变量的值
    该变量不会与其他状态变量一起纳入不变性条件中
    在访问变量时不需要加锁

上面的三个条件只需要保证是原子性操作,才能保证使用 volatile 关键字的程序在高并发时能够正确执行。建议不要将 volatile 用在 getAndOperate 场合,仅仅 set 或者 get 的场景是适合 volatile 的。

常用的两个场景是:

状态标记量

volatile boolean flag = false;

while (!flag) {
    doSomething();
}

public void setFlag () {
    flag = true;
}

volatile boolean inited = false;
// 线程 1
context = loadContext();
inited = true;

// 线程 2
while (!inited) {
    sleep();
}
doSomethingwithconfig(context);

DCL双重校验锁-单例模式

public class Singleton {
    private volatile static Singleton instance = null;

private Singleton() {
    }

/**
    * 当第一次调用getInstance()方法时,instance为空,同步操作,保证多线程实例唯一
    * 当第一次后调用getInstance()方法时,instance不为空,不进入同步代码块,减少了不必要的同步
    */
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

推荐阅读:设计模式-单例模式

使用 volatile 的原因在上面解释重排序时已经讲过了。主要在于 instance = new Singleton(),这并非是一个原子操作,在 JVM 中这句话做了三件事情:

给 instance分配内存
    调用 Singleton 的构造函数来初始化成员变量
    将 instance 对象指向分配的内存库存空间(执行完这步 instance 就为非 null 了)

但是 JVM 即时编译器中存在指令重排序的优化,也就是说上面的第二步和第三步顺序是不能保证的,最终的执行顺序可能是 1-2-3,也可能是 1-3-2。如果是后者,线程 1 在执行完 3 之后,2 之前,被线程 2 抢占,这时 instance 已经是非 null(但是并没有进行初始化),所以线程 2 返回 instance 使用就会报空指针异常。

前面讲述了关于 volatile 关键字的一些使用,下面我们来探讨一下 volatile 到底如何保证可见性和禁止指令重排序的。

在《深入理解Java虚拟机》这本书中说道:

观察加入volatile关键字和没有加入 volatile 关键字时所生成的汇编代码发现,加入 volatile 关键字时,会多出一个 lock 前缀指令。

接下来举个栗子:

volatile 的 Integer 自增(i++),其实要分成 3 步:

读取 volatile 变量值到 local
    增加变量的值
    把 local 的值写回,让其它的线程可见

这 3 步的 JVM 指令为:

内容版权声明:除非注明,否则皆为本站原创文章。

转载注明出处:https://www.heiqu.com/e9eb1db51192fc09e066c4aeca17a875.html