JUC之Java并发基础篇——搞懂volatile

本文首发自个人博客:blog.prcode.org ,欢迎你们点击。java

本文连接: blog.prcode.org/2018/04/JUC…编程

版权声明: 本博客全部文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!缓存

做为 Java 的关键字,volatile 虽然没有 synchronized 出现的频率高,可是在 Java 源码中仍是会常常出现的,尤为是 JUC 当中,好比 AbstractQueuedSynchronizer 。那么,volatile 到底意味着什么,做用是什么?简而言之,有两点,其一是保证了内存可见性,其二是禁止指令重排序。多线程

内存可见性

缓存问题

Java内存模型规定了全部的变量都存储在主内存中,同时每一个线程还有本身的工做内存。线程的工做内存中保存了该线程使用到的从主内存拷贝的副本变量,线程对变量的全部操做都必须在工做内存中进行,而不能直接读写主内存中的变量。不一样线程之间没法直接访问对方工做内存中的变量,线程间变量值的传递均须要在主内存来完成。并发

这样就致使了一个问题:线程1修改了共享变量的值,没来得急写入主存,或者写入主存了线程2并未从主存刷新数据,这样线程2拿到的数据就是过时数据,即内存可见性问题app

线程、主内存和工做内存的交互关系以下图所示:ide

线程缓存

举例

虽然说对于可见性问题说的头头是道,可是全是理论。那么怎么证实这个现象的存在呢?看下面的例子。性能

public class DemoRunnable implements Runnable {

    private boolean flag = false;
    public DemoRunnable() {
    }
    public void setFlag(boolean flag) {
        this.flag = flag;
    }
    @Override
    public void run() {
        int i = 0;
        System.out.println("====== start ===========");
        while (!flag) {
            i++;
        }
        System.out.println("====== end ===== , i == " + i);
    }

    public static void main(String[] args) throws InterruptedException {
        DemoRunnable demo = new DemoRunnable();
        new Thread(demo).start();
        System.out.println("sleep to let demo thread run first");
        TimeUnit.MILLISECONDS.sleep(10);
        demo.setFlag(true);
    }
}
复制代码

对于上面的代码,若是主线程修改了变量的值,demo 线程能够马上发现的话,程序会正常结束,产生以下的输出:优化

====== start ===========this

sleep done let demo thread run first

====== end ===== , i == xxxxxxx

实际上并不是如此,程序进入了死循环,没法退出。这就说明了一个线程修改了共享变量,另外一个线程可能不会当即看到。极端状况下,数据可能一直都不会被看到。

禁止指令重排序

指令重排序,即在执行程序时,为了提升性能,编译器和处理器会对指令作一些优化。而 volatile 则能够禁止某些指令重排序。

对于重排序,举个例子,好比作饭。有一种流程是,洗菜 -> 炒菜 -> 淘米 -> 煮饭。可是为了提升效率,咱们能够这么作:淘米 -> 煮饭 -> 趁煮饭的时间洗菜炒菜。这样一来,就能够省了很多时间。在这里面,有依赖关系的不能重排序,好比煮饭依赖于已经淘米了。这些步骤就是一些指令,咱们本身就是处理器。

更多关于指令重排序与 happens-before 请参考以前的博客: JUC之Java并发基础篇——指令重排与happens-before

因为 volatile 能够禁止指令重排序,因此,对于文中的例子,若是不想出现结果 0, 0 ,只须要将变量 int a, b 使用 volatile 修饰便可。

原子性

volatile 是否能保证原子性,通常有两种说法。一个是说能保证原子性,只要修饰的变量在赋值时和自己无关。一种说法是不能保证原子性。本文认为 volatile 并不能保证被修饰变量的赋值操做原子性。可看如下代码:

public class VolatileDemo {
    private volatile int count;
    public static void main(String[] args) throws InterruptedException {
        int num = 1000;
        int count = 0;
        VolatileDemo demo = new VolatileDemo();
        do {
            count++;
            demo.count = 0;
            Thread t1 = new Thread(() -> {
                for (int i = 0; i < num; i++) {
                    demo.count++;
                }
            });
            Thread t2 = new Thread(() -> {
                for (int i = 0; i < num; i++) {
                    demo.count++;
                }
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
        } while (demo.count == 2 * num);
        System.out.println("第" + count + "次,跳出循环,demo.count = " + demo.count);
    }
}
复制代码

若是能够保证原子性的话,能够预见,上面的程序会是一个死循环,没法跳出。可是实际结果呢,出现了如下状况:

第22次,跳出循环,demo.count = 1622

count++ 实际上等同于 count = count + 1 ,这不是一个步骤,是分三步的:取原值、计算、赋值。volatile 保证了内存可见性,可是是保证了在取变量值的时候,取的是最新的值。在计算及赋值时,对应的值是否仍是最新的,这点是不保证的。

volatile 的解决问题之道

内存屏障

内存屏障(英语:Memory barrier),也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操做中的一个同步点,使得此点以前的全部读写操做都执行后才能够开始执行此点以后的操做。

大多数现代计算机为了提升性能而采起乱序执行,这使得内存屏障成为必须。

参考: 维基百科-内存屏障

下载列出了内存屏障的四种类型。

屏障类型 指令示例 说明
LoadLoad Barriers Load1;LoadLoad;Load2 该屏障确保Load1数据的装载先于Load2及其后全部装载指令的的操做
StoreStore Barriers Store1;StoreStore;Store2 该屏障确保Store1马上刷新数据到内存(使其对其余处理器可见)的操做先于Store2及其后全部存储指令的操做
LoadStore Barriers Load1;LoadStore;Store2 确保Load1的数据装载先于Store2及其后全部的存储指令刷新数据到内存的操做
StoreLoad Barriers Store1;StoreLoad;Load2 该屏障确保Store1马上刷新数据到内存的操做先于Load2及其后全部装载装载指令的操做。它会使该屏障以前的全部内存访问指令(存储指令和访问指令)完成以后,才执行该屏障以后的内存访问指令

StoreLoad Barriers同时具有其余三个屏障的效果,所以也称之为全能屏障,是目前大多数处理器所支持的,可是相对其余屏障,该屏障的开销相对昂贵。

volatile 内存语义

当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值当即刷新到主内存中,并通知其它线程,使其它线程的变量副本无效。

当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,从主内存中读取共享变量。

volatile 实现

在重排序一文中,咱们有提到,重排序分为编译器重排序和处理器重排序。

对于编译器

为了实现 volatile 的内存语义,JMM 会限制 volatile 的重排序,以下表。

可否重排序 第二个操做
第一个操做 普通读/写 volatile 读 volatile 写
普通读/写 no
volatile 读 no no no
volatile 写 no no
  • 当第一个操做是 volatile 读时,不论第二个操做是什么,都不能重排序
  • 当第二个操做是 volatile 写时,不论第一个操做是什么,都不能重排序
  • 当第一个操做是 volatile 写,第二个操做是 volatile 读时,不容许重排序

对于处理器

为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障。这样,处理器在执行指令时就不会进行优化处理。

  • 在每一个 volatile 写操做前面插入一个 StoreStore 屏障。禁止了 volatile 写与前一个有可能的写重排序,同时保证了内存可见性。

  • 在每一个 volatile 写操做后面插入一个 StoreLoad 屏障。禁止了 volatile 写与后一个有可能的读重排序,同时保证了内存可见性。

  • 在每一个 volatile 读操做后面插入一个 LoadLoad 屏障。禁止了 volatile 读与后续有可能的读重排序。

  • 在每一个 volatile 读操做后面插入一个 LoadStore 屏障。禁止了 volatile 读与后续有可能的写重排序。

注:参考 《Java并发编程的艺术》(方腾飞)

volatile 应用

状态标记

因为 volatile 保证了内存可见性,因此可用于修饰共享变量。可是,因为其不具有原子性,为了保证多线程状况下不出问题,最好来修饰赋值能够一步完成的变量。好比,状态标记。

public class DemoRunnable implements Runnable {

    private volatile boolean flag = false;
    public DemoRunnable() {
    }
    public void setFlag(boolean flag) {
        this.flag = flag;
    }
    @Override
    public void run() {
        int i = 0;
        System.out.println("====== start ===========");
        while (!flag) {
            i++;
        }
        System.out.println("====== end ===== , i == " + i);
    }

    public static void main(String[] args) throws InterruptedException {
        DemoRunnable demo = new DemoRunnable();
        new Thread(demo).start();
        System.out.println("sleep to let demo thread run first");
        TimeUnit.MILLISECONDS.sleep(10);
        demo.setFlag(true);
    }
}
复制代码

单例模式双重锁

单例模式的懒汉模式,若是不当心的话是会出错的。如下代码是一份不会出问题的代码。

public class SingletonDemo {
    private static volatile SingletonDemo INSTANCE;//标记3
    private SingletonDemo() {}//标记1
    public static SingletonDemo getINSTANCE() {
        if (INSTANCE == null) {
            synchronized (SingletonDemo.class) {
                if (INSTANCE == null) {//标记2
                    INSTANCE = new SingletonDemo();
                }
            }
        }
        return INSTANCE;
    }
}
复制代码

注1:构造方法必须私有化,避免外部再 new 出新对象。

注2:著名的 double-check ,若是不进行二次判断的话,极可能多个线程同时得出第一个 INSTANCE == NULL 的结论,而后依次进入同步代码块,这样就会致使 INSTANCE 会被从新 new。

注3:INSTANCE 必须使用 volatile 进行修饰,由于 new 一个对象不是一步完成的,指令重排可能会使某线程拿到的是半个对象。

new 一个对象,可能出现下面两种步骤:

顺序1 顺序2
1. 申请内存 1. 申请内存
2. 初始化属性 2. 指向对象
3. 指向对象 3. 初始化属性

也就是说,指令重排,可能会出现如下这种状况

线程1(顺序2初始化) 线程2
1. 执行 INSTANCE = new SingletonDemo()
2. 申请内存
3. 指向对象
4. 第一个 INSTANCE == null 判断
5. INSTANCE != null
6. 拿到一个假对象,因此是半个
7. 完成对象属性初始化

能够看出,在指令重排时,是有可能出现并发问题的。因此,INSTANCE 要用 volatile 修饰,在禁止重排序后,使用顺序1就不会出现这个问题。

总结

  • volatile 的做用主要有两点,一个是保证了内存可见性,一个是禁止指令重排序
  • volatile 并不能保证原子性,使用 volatile 修改的变量,在赋值时必定不要和自己原来的值相关
  • volatile 的内存语义是经过插入内存屏障来实现的
  • volatile 可用于修饰状态标记变量
  • 单例模式的懒汉模式,变量要用 volatile 来修饰,这样来禁止指令重排序
相关文章
相关标签/搜索