深入理解volatile关键字

hresh 2.2K 2

深入理解volatile关键字

前言

volatile 这个关键字可能很多朋友都听说过,它有两个重要的特性:保证可见性禁止指令重排序。但是对于 volatile 的使用以及背后的原理我们一无所知,所以本文将带你好好了解一番。

由于 volatile 关键字是与 Java的内存模型有关的,因此在讲述 volatile 关键之前,我们先来了解一下 Java 内存模型,然后介绍 volatile 关键字的使用,最后详解 volatile 关键字的原理。废话不多说,我们直接进入正文。

volatile的使用

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

  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();

    }
}

这段代码是很典型的一段代码,很多人在中断线程时可能都会采用这种标记办法。但是事实上,这段代码会完全运行正确么?即一定会将线程中断么?不一定,也许在大多数时候,这个代码能够把线程中断,但是也有可能会导致无法中断线程(虽然这个可能性很小,但是只要一旦发生这种情况就会造成死循环了)。

下面解释一下这段代码为何有可能导致无法中断线程。在前面已经解释过,每个线程在运行过程中都有自己的工作内存,那么线程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线程输入结果为==>" + count);
        } 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操作)、以及加法操作(加一个数),减法操作(减一个数)进行了封装,保证这些操作是原子性操作。

深入理解volatile关键字

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的原理

可见性实现

在前文中已经提及过,线程本身并不直接与主内存进行数据的交互,而是通过线程的工作内存来完成相应的操作。这也是导致线程间数据不可见的本质原因。 如下图所示:

深入理解volatile关键字

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发送消息。

如下图所示:

深入理解volatile关键字

禁止指令重排序

在 JMM 一文中有提及编译器和处理器关于重排序的内容,单线程环境下由于遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序,也就不会出现错误。但是多线程环境下,重排序可能会导致无法获取准确的数据。

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

深入理解volatile关键字

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

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

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

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

深入理解volatile关键字

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

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

深入理解volatile关键字

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

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

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

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

下面是保守策略下,volatile写插入内存屏障后生成的指令序列示意图:

深入理解volatile关键字

下面是在保守策略下,volatile读插入内存屏障后生成的指令序列示意图:

深入理解volatile关键字

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

上述 volatile 写和 volatile 读的内存屏障插入策略非常保守。在实际执行时,只要不改变 volatile 写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。下面通过具体的示例代码进行说明。

class VolatileBarrierExample {
  int a;
  volatile int v1 = 1;
  volatile int v2 = 2;
  void readAndWrite() {
    int i = v1; // 第一个volatile读
    int j = v2; // 第二个volatile读
    a = i + j; // 普通写
    v1 = i + 1; // 第一个volatile写
    v2 = j * 2; // 第二个 volatile写
  } …
    // 其他方法
}

针对 readAndWrite 方法,编译器对指令可以做如下优化:

深入理解volatile关键字

注意,最后的 StoreLoad 屏障不能省略。因为第二个 volatile 写之后,方法立即 return。此时编译器可能无法准确断定后面是否会有 volatile 读或写,为了安全起见,编译器通常会在这里插入一个 StoreLoad 屏障。

扩展

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关键字

根据 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关键字

也许我这种测试方式不正确,但只是想证明 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 关键字和 synchronized 关键字是 Java 并发学习中非常重要的知识点,不管之前你对它们了解到什么程度,既然开始学习 Java 并发进阶,必须要深入学习其背后的实现原理。

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

总体来说,volatile 是并发编程中的一种优化,在某些场景下可以代替 synchronized。但是,volatile 的不能完全取代 synchronized 的位置,只有在一些特殊的场景下,才能适用volatile。总的来说,加锁机制既可以保证可见性又可以确保原子性,而 volatile 变量可以确保可见性和禁止指令重排,所以当变量的写操作属于原子操作时,才可以单独使用 volatile,我们常见的状态标记量案例。关于禁止指令重排,比较典型的就是单例实现中的双重检查锁,有兴趣的朋友可以去阅读一下我之前写的单例模式文章内容。

参考文献

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

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

java volatile数组,我的测试结果与预期不符

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

《Java并发编程实战》

《深入理解Java虚拟机》

《Java并发编程的艺术》

发表评论 取消回复
表情 图片 链接 代码

  1. 罗简单
    罗简单 Lv 1

    您好,我们有一个网站需要爬取数据,参数也加密了,需要您的帮助,付费,请问如何联系到您?
    我的vx是18966813391.

    • hresh
      hresh 站长

      @罗简单你好,我暂时不做爬虫的工作,所以没法给你提供帮助。

分享