Java并发编程学习系列七:深刻了解volatile关键字

前言volatile的使用volatile保证可见性volatile没法保证原子性volatile禁止指令重排volatile的原理可见性实现禁止指令重排序扩展volatile修饰对象和数组总结参考文献html

前言

volatile 这个关键字可能不少朋友都据说过,它有两个重要的特性:可见性和禁止指令重排序。可是对于 volatile 的使用以及背后的原理咱们一无所知,因此本文将带你好好了解一番。java

因为 volatile 关键字是与 Java的内存模型有关的,所以在讲述 volatile 关键以前,咱们先来了解一下与内存模型相关的概念和知识,原本想总结写一篇 JMM 的文章,可是在网上看到一篇总结的很好的文章,因此此处推荐你们阅读一下Java并发编程学习系列六:JMM,而后介绍 volatile 关键字的使用,最后详解 volatile 关键字的原理。废话很少说,咱们直接进入正文。程序员

volatile的使用

一旦一个共享变量(类的成员变量、类的静态成员变量)被 volatile 修饰以后,那么就具有了两层语义:web

  1. 保证了不一样线程对这个变量进行操做时的可见性,即一个线程修改了某个变量的值,这新值对其余线程来讲是当即可见的。
  2. 禁止进行指令重排序。

volatile保证可见性

先看一段代码,假如线程A先执行,线程B后执行: 编程

public class VolatitleTest {

    private static boolean stopRequested = false;

    public static void main(String[] args) throws InterruptedException {
        int n = 0;
        Thread thread1 = new Thread(() -> {
            int i = 0;
            while (!stopRequested) {
                i++;
            }
        },"A");

        Thread thread2 = new Thread(() -> {
            stopRequested = true;
        },"B");

        thread1.start();
        TimeUnit.SECONDS.sleep(1);    //为了演示死循环,特地sleep一秒
        thread2.start();

    }
}
复制代码

这段代码是很典型的一段代码,不少人在中断线程时可能都会采用这种标记办法。可是事实上,这段代码会彻底运行正确么?即必定会将线程中断么?不必定,也许在大多数时候,这个代码可以把线程中断,可是也有可能会致使没法中断线程(虽然这个可能性很小,可是只要一旦发生这种状况就会形成死循环了)。segmentfault

下面解释一下这段代码为什么有可能致使没法中断线程。在前面已经解释过,每一个线程在运行过程当中都有本身的工做内存,那么线程A在运行的时候,会将 stopRequested 变量的值拷贝一份放在本身的工做内存当中。数组

那么当线程B更改了 stopRequested 变量的值以后,可是还没来得及写入主存当中,线程B转去作其余事情了,那么线程A因为不知道线程B对 stopRequested 变量的更改,所以还会一直循环下去。缓存

上述代码将 stopRequested 定义为 volatile,就变成了典型的状态标记量案例。安全

当一个变量被定义成 volatile 以后,它将具有如下特性:保证此变量对全部线程的可见性,这里的“ 可见性”是指当一条线程修改了这个变量的值,新值对于其余线程来讲是能够当即得知的。具体而言就是说,volatile 关键字能够保证直接从主存中读取一个变量,若是这个变量被修改后,老是会被写回到主存中去。Java 内存模型是经过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存做为传递媒介的方式来实现可见性的。多线程

普通变量与 volatile 变量的区别是:volatile 的特殊规则保证了新值能当即同步到主内存,以及每一个线程在每次使用 volatile 变量前都当即从主内存刷新。所以咱们能够说 volatile 保证了多线程操做时变量的可见性,而普通变量则不能保证这一点。

在本例中,线程B更改了 stopRequested 变量的值以后,新值会被当即回写到主存中,线程A再次读取 stopRequested 变量时要去主存读取。

关于 volatile 变量的可见性,常常会被开发人员误解,他们会误觉得下面的描述是正确的:“ volatile 变量对全部线程是当即可见的,对 volatile 变量全部的写操做都能马上反映到其余线程之中。换句话说,volatile 变量在各个线程中是一致的,因此基于 volatile 变量的运算在并发下是线程安全的”。这句话的论据部分并无错,可是由其论据并不能得出“ 基于 volatile 变量的运算在并发下是线程安全的”这样的结论。Java 里面的运算操做符并不是原子操做,这致使 volatile 变量的运算在并发下同样是不安全的。

volatile没法保证原子性

在 JMM 一文中提到 volatile 不能保证原子性,接下来咱们经过案例进行分析。

public class VolatileAddNum {
    static volatile int count = 0;

    public static void main(String[] args) {
        VolatileAddNum obj = new VolatileAddNum();
        Thread t1 = new Thread(() -> {
            obj.add();
        },"A");

        Thread t2 =new Thread(() -> {
            obj.add();
        },"B");

        t1.start();
        t2.start();

        try {
            t1.join();
            t2.join();

            System.out.println("main线程输入结果为==>" + num);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

    }

    public void add() {
        for (int i = 0; i < 100000; i++) {
            count++;
        }
    }
}
复制代码

上面这段代码作的事情很简单,开了 2 个线程对同一个共享整型变量分别执行十万次加1操做,咱们指望最后打印出来 count 的值为200000,但事与愿违,运行上面的代码,count 的值是极有可能不等于 20万的,并且每次运行结果都不同,老是小于 20万。为何会出现这个状况呢?

自增操做是不具有原子性的,它包括读取变量的原始值、进行加1操做、写入工做内存。那么就是说自增操做的三个子操做可能会分割开执行,就有可能致使下面这种状况出现:

假如某个时刻变量 count 的值为10,

线程A对变量进行自增操做,线程A先读取了变量 count 的原始值,而后线程A被阻塞了(可能存在的状况);

而后线程B对变量进行自增操做,线程B也去读取变量 count 的原始值,因为线程A只是对变量 count 进行读取操做,而没有对变量进行修改操做,因此主存中 count 的值未发生改变,此时线程B会直接去主存读取 count 的值,发现 count 的值为10,而后进行加1操做,并把11写入工做内存,最后写入主存。

而后线程A接着进行加1操做,因为已经读取了 count 的值,注意此时在线程A的工做内存中 count 的值仍然为10,因此线程A对 count 进行加1操做后 count 的值为11,而后将11写入工做内存,最后写入主存。

那么两个线程分别进行了一次自增操做后,inc只增长了1。

解释到这里,可能有朋友会有疑问,不对啊,前面不是保证一个变量在修改 volatile 变量时,新值对于其余线程来讲是能够当即得知的?对,这个没错。这个就是上面的 happens-before 规则中的 volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。可是要注意,线程A对变量进行读取操做以后,被阻塞了的话,并无对 count 值进行修改。而后虽然 volatile 能保证线程B对变量 count 的值读取是从内存中读取的,可是线程A没有进行修改,因此线程B根本就不会看到修改的值。

根源就在这里,自增操做不是原子性操做,并且 volatile 也没法保证对变量的任何操做都是原子性的。

把上面的代码改为如下任何一种均可以达到效果:

Synchronized 关键字,伪码以下:

    public synchronized void add() {
        for (int i = 0; i < 100000; i++) {
            num ++;
        }
    }
复制代码

Lock 锁,代码以下:

public static volatile int num = 0;
Lock lock = new ReentrantLock();

public synchronized void add() {
    lock.lock();
    try {
        for (int i = 0; i < 100000; i++) {
            num ++;
        }
    } finally {
        lock.unlock();
    }
复制代码

除了上述两种方案,咱们还能够采用 AtomicInteger 来完成加法操做。

public class VolatileAddNum {
    public static int num = 0;
    public AtomicInteger inc = new AtomicInteger();

    public static void main(String[] args) {
        VolatileAddNum obj = new VolatileAddNum();
        Thread t1 = new Thread(() -> {
            obj.add();
        },"A");

        Thread t2 =new Thread(() -> {
            obj.add();
        },"B");

        t1.start();
        t2.start();

        try {
            t1.join();
            t2.join();

            System.out.println("main线程输入结果为==>" + obj.inc);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

    }

    public void add() {
        for (int i = 0; i < 100000; i++) {
//            num ++;
            inc.getAndIncrement();
        }
    }
}
复制代码

在 JDK1.5的 java.util.concurrent.atomic 包下提供了一些原子操做类,即对基本数据类型的 自增(加1操做),自减(减1操做)、以及加法操做(加一个数),减法操做(减一个数)进行了封装,保证这些操做是原子性操做。

AtomicInteger 类主要利用 CAS (compare and swap) + volatile 和 native 方法来保证原子操做,从而避免 synchronized 的高开销,执行效率大为提高。 CAS 其实是利用处理器提供的CMPXCHG 指令实现的,而处理器执行 CMPXCHG 指令是一个原子性操做。

volatile禁止指令重排

在前面提到 volatile 关键字能禁止指令重排序,因此 volatile 能在必定程度上保证有序性。

volatile 关键字禁止指令重排序有两层意思:

  • 当程序执行到 volatile 变量的读操做或者写操做时,在其前面的操做的更改确定所有已经进行,且结果已经对后面的操做可见;在其后面的操做确定尚未进行;
  • 在进行指令优化时,不能将在对 volatile 变量访问的语句放在其后面执行,也不能把 volatile 变量后面的语句放到其前面执行。

咱们从一个最经典的例子来分析重排序问题。你们应该都很熟悉单例模式的实现,而在并发环境下的单例实现方式,咱们一般能够采用双重检查加锁(DCL)的方式来实现。其源码以下:

public class Singleton {
    public static volatile Singleton singleton;

    /**
     * 构造函数私有,禁止外部实例化
     */

    private Singleton() {};

    public static Singleton getInstance() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}
复制代码

如今咱们分析一下为何要在变量 singleton 之间加上 volatile 关键字。要理解这个问题,先要了解对象的构造过程,实例化一个对象其实能够分为三个步骤:

(1)分配内存空间。

(2)初始化对象。

(3)将内存空间的地址赋值给对应的引用。

可是因为操做系统能够对指令进行重排序,因此上面的过程也可能会变成以下过程:

(1)分配内存空间。

(2)将内存空间的地址赋值给对应的引用。

(3)初始化对象

若是是这个流程,多线程环境下就可能将一个未初始化的对象引用暴露出来,从而致使不可预料的结果。所以,为了防止这个过程的重排序,咱们须要将变量设置为 volatile 类型的变量。

volatile的原理

可见性实现

在前文中已经说起过,线程自己并不直接与主内存进行数据的交互,而是经过线程的工做内存来完成相应的操做。这也是致使线程间数据不可见的本质缘由。 以下图所示:

img
img

volatile 保证此变量对全部线程的可见性,这里的“ 可见性”是指当一条线程修改了这个变量的值,新值对于其余线程来讲是能够当即得知的。

底层缘由:

volatile 使用 Lock 前缀的指令禁止线程本地内存缓存,保证不一样线程之间的内存可见性

在了解 JMM 的相关知识后,咱们知道 JVM 为了提升处理速度,处理器不直接和主内存进行通讯,而是先将主内存的数据读到内部缓存(L1,L2或其余)后再进行操做,但操做完不知道什么时候会将缓存中的数据写回到主内存。若是对声明了 volatile 的变量进行写操做,JVM 就会向处理器发送一条 Lock 前缀的指令,将这个变量所在缓存行的数据会当即写回到主内存。可是,就算写回到内存,若是其余处理器缓存的值仍是旧的,再执行计算操做就会有问题。因此在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每一个处理器经过嗅探在总线上传播的数据来检查本身缓存的值是否是过时了,当处理器发现本身缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操做的时候,会从新从主内存中把数据读处处理器缓存里。

Lock 前缀的指令在多核处理器下会引起了两件事情:

  • 将当前处理器缓存行的数据写回到主内存。
  • 一个处理器的缓存回写到主内存会致使其余处理器的缓存无效。

理解 volatile 特性的一个好方法是把对 volatile 变量的单个读/写,当作是使用同一个锁对这些单个读/写操做作了同步。从内存语义的角度来讲,volatile 的写-读与锁的释放-获取有相同的内存效果:volatile 写和锁的释放有相同的内存语义;volatile 读与锁的获取有相同的内存语义——这使得 volatile 变量的写-读能够实现线程之间的通讯。

volatile的内存语义:

  • volatile 写的内存语义:当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存
  • volatile 读的内存语义:当读一个volatile变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

volatile写 - 读的内存语义:

  • 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所作修改的)消息。
  • 线程B读一个volatile变量,实质上是线程B接收了以前某个线程发出的(在写这个volatile变量以前对共享变量所作修改的)消息。
  • 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A经过主内存向线程B发送消息。

以下图所示:

img
img

禁止指令重排序

在 JMM 一文中有说起编译器和处理器关于重排序的内容,单线程环境下因为遵照数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操做的执行顺序,也就不会出现错误。可是多线程环境下,重排序可能会致使没法获取准确的数据。

首先咱们来看下指令重排序对内存可见性的影响:

img
img

当1和2之间没有数据依赖关系时,1和2之间就可能被重排序(3和4相似)。这样的结果就是:读线程B执行4时,不必定能看到写线程A在执行1时对共享变量的修改。

volatile禁止指令重排序语义的实现关键在于内存屏障。

重排序可能会致使多线程程序出现内存可见性问题。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,经过内存屏障指令来禁止特定类型的处理器重排序。经过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。

img
img

StoreLoad Barriers是一个“全能型”的屏障,它同时具备其余3个屏障的效果。现代的多处理器大多支持该屏障(其余类型的屏障不必定被全部处理器支持)。执行该屏障开销会很昂贵,由于当前处理器一般要把写缓冲区中的数据所有刷新到内存中(Buffer Fully Flush)。

JMM针对编译器制定volatile重排序规则表:

img
img

  • 当第一个操做是 volatile 读时,无论第二个操做是什么,都不能重排序。这个规则确保 volatile 读以后的操做不会被编译器重排序到volatile读以前。
  • 当第一个操做是 volatile 写,第二个操做是 volatile 读时,不能重排序
  • 当第二个操做是 volatile 写时,无论第一个操做是什么,都不能重排序。这个规则确保 volatile 写以前的操做不会被编译器重排序到 volatile 写以后。

为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

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

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

从编译器重排序规则和处理器内存屏障插入策略来看,只要 volatile 变量与普通变量之间的重排序可能会破坏volatile 的内存语义(内存可见性),这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。

扩展

volatile修饰对象和数组

volatile 修饰对象和数组时,只是保证其引用地址的可见性。

以下述代码所示,nums 加了 volatile以后下面的代码会立刻打印“结束”,若是不给数组加 volatile 就永远不会打印。

public class VolatileWork {

    static volatile int[] nums = new int[5];

    public static void main(String[] args) {
        new Thread(()->{
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            nums[0] = 2;
        },"A").start();

        new Thread(()->{
            while (true){
//                int i = num;
                if (nums[0] == 2) {
                    System.out.println("结束");
                    break;
                }
//                System.out.println("waiting");
            }
        },"B").start();
    }
}
复制代码

首先须要了解的一点是:数组存放在主内存中,当线程访问该对象时,会将数组引用复制一份到线程的工做内存,甚至有可能将 nums[0] 复制到工做内存中,参考《深刻理解Java虚拟机》 以下叙述:

根据 volatile 可见性的实现原理分析,咱们知道当执行 nums[0] = 2;语句时,数组引用会回写到主内存中,而且致使线程B工做内存中关于数组引用的缓存行失效,从而致使从新从主内存中读取。可是有一点须要注意的是:nums 引用和 nums[0] 不位于同一缓存行中,因此没法保证 nums[0] 在线程之间的可见性。

为了测试多线程状况下,没法实时读取 nums[0] 的最新值,咱们利用下面代码进行演示:

public class VolatileWork {

    static volatile int[] nums = new int[5];

    public static void main(String[] args) throws InterruptedException {

        Thread t1 = new Thread(() -> {
            try {
                TimeUnit.MILLISECONDS.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            nums[0] = 2;
            System.out.println("写入成功");
        }, "A");

        t1.start();


        for (int i = 0; i < 500; i++) {
            new Thread(() -> {
                if (nums[0] != 2){
                    System.out.println(nums[0]);
                }
            }).start();
        }

    }
}
复制代码

屡次执行上述代码,观察结果变化,最后发现有这么一种状况:

也许我这种测试方式不正确,但只是想证实 volatile 修饰数组时,并不会保证数组元素在线程之间的可见性。一样能够这点的是 ConcurrentHashMap,在 ConcurrentHashMap(1.8)中,内部使用一个 volatile 的数组 table保存数据,细心的同窗能够发现,Doug Lea 每次在获取数组的元素时,采用 Unsafe 类的 getObjectVolatile 方法,在设置数组元素时,采用 compareAndSwapObject 方法,而不是直接经过下标去操做。这是什么缘由呢?

网上看到文章里是这样总结的:由于 Java 数组在元素层面的元数据设计上的缺失,没法表达元素是 final、volatile 等语义,因此开了后门,使用 getObjectVolatile 用来补上没法表达元素是 volatile 的坑,@Stable用来补上 final 的坑,数组元素就跟没有标 volatile 的成员字段同样,没法保证线程之间可见性。

关于 volatile 修饰对象一样存在这么一个状况,因此除了要当心对待。

此外在网上看到这样一个案例,有兴趣的朋友能够去了解一下,R大亲自回答,讲解的很是详细。

import java.util.concurrent.TimeUnit;

public class ThreadTest {
   private static boolean stopRequested;

   public static void main(String[] args) throws InterruptedException {
      Thread backgroundThread = new Thread(new Runnable() {
         public void run() {
            int i = 0;
            while (!stopRequested){
               i++;
               //这段System.out语句会致使线程结束,缘由?
               System.out.println(i);
            }
         }
      });
      backgroundThread.start();
      TimeUnit.SECONDS.sleep(1);
      stopRequested = true;
   }

}
复制代码

System.out语句会引发线程结束,若是去掉System.out语句,线程是永远不会结束的

总结

开始研究 volatile 源于在学习 CopyOnWriteArrayList 类中的 add 方法,在该方法中将数组复制了一份,而后增长完新值以后,而后再覆盖原数组。这个数组被 volatile 修饰,当时看的那篇文章中博主说了这么一句话“ 若是将 array 数组设定为 volitile 的, 对 volatile 变量写 happens-before 读,读线程不是可以感知到 volatile 变量的变化。 ”我当时只是简单知道 volatile 的两个特性,仅限于口头上了解,对于 happens-before 原则也不清晰,而后我就在网上查看相关资料,一步一步去了解,最后了解到 JMM,而后到 JMM 下的线程间通讯,以及 volatile 的使用及背后原理。这一路看下来内容仍是比较多的,某一点不理解,就要去网上查资料或者看相关书籍,由此我也明白了一个道理:单纯的去看书,很容易疲劳,带着问题去读,每句每字都会用心去看,更利于加深我的理解。

上面的内容不少都是从网上和书上整理出来的,目前我也只是对 volatile 有个基本的了解,但愿能对你们有所帮助,若是文中内容有错误,望不吝赐教。在后续的学习中会常常遇到它,好比线程安全类以及 Spring 源码。整体来讲,volatile 是并发编程中的一种优化,在某些场景下能够代替 Synchronized。可是,volatile 的不能彻底取代 Synchronized 的位置,只有在一些特殊的场景下,才能适用volatile。总的来讲,加锁机制既能够保证可见性又能够确保原子性,而 volatile 变量能够确保可见性和禁止指令重排。因此当变量的写操做属于原子操做时,才能够单独使用 volatile,咱们常见的状态标记量案例。关于禁止指令重排,比较典型的就是单例实现中的双重检查锁,有兴趣的朋友能够去阅读一下这位朋友写的单例模式文章内容。

参考文献

Java并发编程:volatile关键字解析

Java 并发编程:volatile的使用及其原理

java volatile数组,个人测试结果与预期不符

如何保证数组元素的可见性

《Java并发编程实战》

《深刻理解Java虚拟机》

《Java并发编程的艺术》

相关文章
相关标签/搜索