深入理解 volatile 关键字

1,436 阅读15分钟

前言

手写一个双重检验锁的单例设计模式相信大家分分钟就能解决,但有一个问题为什么我需要在类变量前面加一个 volatile 关键字,百度了下一发现它主要用来解决两个问题,即变量内存可见性以及禁止指令重排序,并发编程的三要素除了原子性之外它占了两个,为何它如此强大,一个小小的 volatile 所包含的知识点可能会远远超过你的想象。

内存可见性与JMM

//程序清单 1.1
public class Test {
    //注意我在这里没有加 volatile
    private static  boolean tag = false;

    public static void main(String[] args) {

        new Thread(()->{
            System.out.println("子线程 begin");

            while (!tag){

            }

            System.out.println("子线程 end");
        }).start();
        
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        tag = true;

    }

}

首先我们来看下上面的程序清单,我定义了一个 tag 默认值为 false ,开启了一个子线程如果 tag 的值为 false 那么 “子线程 end" 这句永远都没法输出出来,在主线程中 tag 的值被赋值为 true ,运行这段代码你会发现无论如何你运行多少遍 “子线程 end” 这句永远都没法打印出来,追究其原因呢是因为在 Java 中有一个Java内存模型的概念简称JMM。

线程、主内存、工作内存之间的关系.png

  • Java内存模型的主要目标是定义程序中各种变量的访问规则,即虚拟机中将变量从主内存中拿出以及将变量存储到内存中的底层细节。

  • JMM规定所有的变量存储在主内存中,每一个线程都拥有它们自己的工作内存,线程的工作内存保存了被该线程使用到的主内存变量的副本拷贝。

  • 线程对变量的所有操作都必须在线程内存的工作内存中处理,不能直接读取主内存中的变量,不同线程之间也无法访问对方工作内存中的变量,线程之间数据的传递需要在主内存中完成。

  • 这里的变量不包含局部变量和方法参数,因为这两者本身属于线程私有的不会共享自然也就不会出现竞争问题。

因为Java内存模型内存可见性的问题,在上述程序清单中尽管在主线程中将变量 tag 的值改为 true,但是有可能没有及时同步到主内存中,就算同步到主内存中了 thread 的工作内存也可能没有及时拿到主内存最新的数据拷贝,所以 thread 中使用还是之前拿到值为 false 拷贝,意料中的 “子线程 end” 并没有被打印出来。

JMM中8大原子性操作

从主内存中拿到数据并进行读写的操作可以拆分为JMM模型中的8大原子性操作:

  • read(读取):从主内存中读取数据
  • load(载入):将主内存读取到的数据写到工作内存
  • use(使用):从工作内存读取数据计算
  • assign(赋值):将计算好的值重新赋值到工作内存中
  • store(存储):将工作内存数据写入主内存
  • write(写入):将存入的数据变量值赋值给主内存中的共享变量
  • lock(锁定):将主内存变量加锁
  • unlock(解锁):将主内存变量解锁

JMM内存模型的8大原子操作.png

内存可见性问题解决

使用 volatile 关键字可以解决程序清单1.1中的内存可见性问题,在 tag 变量前面加上 volatile 修饰,使用 volatile 修饰的变量同时具备两种特性:

  • 保证此变量对所有线程可见,这里指当一个线程修改了这个变量的值那么这个修改对其它线程是立即可见的,比如说线程A修改了变量的值然后将新值回写到主内存中,线程B在线程A将新值回写到主内存中之后在从主内存中进行读取操作,新变量值才会对线程B可见。

  • 禁止指令重排优化,你写的java代码不一定会按照你写的顺序运行,JVM会根据一系列复杂的算法优化你写的代码的顺序,如果两行代码之间没有依赖关系那么这两行代码很可能会被“指令重排序”,即这两行代码的顺序是颠倒过来的,在单线程模式模型下指令重排没有任何问题,但是在并发场景下经常会出现稀奇古怪的问题,在很多场景比如说双重校验锁的单例设计模式中我们就很有必要进行避免指令重排。

解决问题了内存可见,就一定能拿到正确的值吗?

//程序清单1.2
public class Test {

    private static volatile int sum = 0;

    public static void main(String[] args) {

        for (int i = 0;i<20;i++){
            Thread myThread = new Thread(()->{
                for (int j =0;j<1000;j++){
                    accumulation();
                }
            });
            myThread.start();
        }

        while (Thread.activeCount()>1){
            Thread.yield();
        }

        System.out.println(sum+"");
    }

    static void accumulation(){
        sum++;
    }
}

看下上面的代码,我已经用 volatile 修饰了 sum 变量,按理说我解决了内存可见性问题,确保了 sum 变量在一个线程中被改变了值其它线程中都能拿到改变后的值,那么按照我代码中的写法最终的值应该是 20000 ,实际运行结果却是千奇百怪,有 20000 的也有 18951 的或是其它的值,为什么呢 ? 实际上我们写的java代码最终会被编译成 Class 文件,java程序最终执行的是它的字节码指令,使用 jclasslib 可以帮助我们查看字节码,这里我们看 accumulation 函数的字节码,如下程序清单1.3

//程序清单1.3
0 getstatic #11 <Test.sum>
3 iconst_1
4 iadd
5 putstatic #11 <Test.sum>
8 return

通过 getstatic 指令将 sum 的值取到操作数栈的栈顶,volatile 可以保证在这一步中 sum 的值是正确的,但是在执行 iconst_1 、 iadd 指令即压栈和相加操作的时候其它线程很可能已经先一步执行了这两步指令将 sum 的值加大了所以也就导致了最终的值与我们所期望的值不一致的原因,通过这个例子你会发现 volatile 确实可以解决内存可见性问题但不表示使用它修饰的变量是线程安全的。

双重校验锁的单例设计模式为什么需要加 volatile

/**
 * @author by tengfei on 2021/11/16.
 * @description:双重校验锁的单例设计模式 程序清单1.4
 */
public class Singleton {

    private static volatile Singleton singleton;

    private  Singleton(){

    }

    public static Singleton getSingleton(){
        if (singleton == null){
            synchronized (Singleton.class){
                if (singleton == null){
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

我们来解决下在前言中提到过的单例设计模式加 volatile 的问题,synchronized 加锁的目的自然是为了在多线程的情况下避免 Singleton 对象被重复创建的情况,但问题就出在如果不加 volatile 那么 new 的操作会出现“半初始化”的问题,简单来讲就是你初始化的过程出现了bug,是失败的初始化,可以从字节码的角度来理解为什么会出现半初始化的情况。

//程序清单1.5
10 monitorenter
11 getstatic #2 <Singleton.singleton>
14 ifnonnull 27 (+13)
17 new #3 <Singleton>
20 dup
21 invokespecial #4 <Singleton.<init>>
24 putstatic #2 <Singleton.singleton>
27 aload_0
28 monitorexit

核心在第 17、20、21、24 这四行指令当中

  1. 执行 new 指令通过 #3 符号引用去常量池中找到 Singleton 这样一个 Class 来创建一个 Singleton 对象,但此刻这个对象仅仅只是个壳子,还没有执行构造器函数。

  2. 执行 dup 指令分配内存空间。

  3. 执行 invokespecial 调用 init 方法,也就是执行构造函数。

  4. putstatic 给 singleton 变量赋值

执行 new 操作创建一个对象的流程大致就是这4步,问题出在第3、4两步,你可以发现 invokespecial 和 putstatic 这两个指令处理的都是符号引用,并没有直接的依赖关系,所以从JVM的角度来讲它两是没什么关联的,因此这里会出现指令重排的可能性,先执行第4步再执行第3步,顺序也就变成了 1 2 4 3,当执行完 4 还没 开始执行 3 的时候,另一个线程开始调用 getSingleton 方法,此刻因为 singleton 已经被赋值了,所以当程序直接拿过去用的时候因为还没执行 init 方法,对象内部假设在构造函数中定义了一些代码此刻还没被执行,所以就会出现空指针的情况,为了避免这种情况发生就需要加上 volatile 来禁止指令重排序。

JMM控制总线与总线嗅探

控制总线.png 首先我们来了解下什么是控制总线,控制总线用二进制信号对所有连接在系统总线上的部件的行为进行同步。

现代计算机CPU运算速度是极高的,从主内存中拿数据的操作往往跟不上CPU的运算速度从而造成CPU资源极大的浪费,为了避免这种浪费会在主内存和CPU之间加上一个速度能跟上CPU的高速缓存区,内存中的数据会copy到高速缓存区中,CPU直接跟高速缓存区打交道,这样做无疑可以提高CPU的效率,但是在多CPU的情况下就会出现缓存一致性的问题,当多个CPU的运算任务涉及到同一块内存区域的时候将可能导致各自的缓存数据不一致的情况,为了规避这种情况就需要定义一个“缓存一致性协议”。

缓存一致性协议规定了处理器在读写数据的时候需要遵循的一些规范,当CPU写数据的时候如果发现其它的CPU中也包含这个数据的副本拷贝那么会发出通知其它的CPU这个数据已经过时了是无效的,其它的CPU会把这个数据设置为无效,当它们需要这个数据的时候会从内存中重新读取。

每个处理器通过监听在总线上传播的数据来检查自己的缓存值是不是过期了,如果处理器发现自己缓存行对应的内存地址修改,就会将当前处理器的缓存行设置无效状态,当处理器对这个数据进行修改操作的时候,会重新从主内存中把数据读到处理器缓存中。(参考自其它文章,引用在文末参考资料中)

vloatile 的原子性

原子性指你的操作过程是不受外界干扰可以一次性操作完成的,在前面单例设计模式的例子中已经证明了 volatile 不是原子性的,它仅仅只是帮助我们实现了内存可见性以及禁止指令重排序,在程序清单1.2中我用 sum++ 的操作验证了如果在JVM中你的操作步骤是受干扰的那么 volatile 不能保证线程安全,但是针对一些原子性操作如程序清单1.1仅仅只是只是对 volatile 修饰的变量进行读写的操作是线程安全的,所以仅仅只是对 volatile 修饰的变量进行单纯的赋值是安全的,但是进行一些复合性的操作比如 i++ 或者 flag=!flag 的操作无法确保原子性自然也就会出现线程安全问题。

指令重排序问题

在 as-if-serial 语意的规则下(不管怎么重排序在单线程下程序执行的结果都是不可变的),编译器和处理器会为了提高程序执行的效率对指令进行重排,但是在并发的情况下会带来二义性按照不同的执行逻辑会带来不同的结果。

//程序代码 2-1 
public  void test(){
    int a = 1;
    int b = 8;
    System.out.println(a+b); 
}

//2-2
public void test2() {
    
    int a = 0;

    int b = a;
}

如上代码 2-1 a 变量和 b 变量仅仅只是赋值最后相加输出的操作,所以从逻辑上而言两者之间没有任何依赖关系,这种情况下为了提高执行效率可能会对指令重排序,在 2-2 中 a 和 b 之间存在依赖关系,b 的值区别与 a 的值所以 2-2 不会发生重排,简单的归纳就是如果两个指令之间存在依赖关系那么就不会发生重排,下面的表格中归纳了在哪些情况下不会发生指令重排。

名称代码示例说明
写后读a=1;b=a写一个变量后在读这个变量
写后写a=1;a=2写一个变量后在写这个变量
读后写a=b;b=1读一个变量后在写这个变量

happens-before

可以使用 happens-before 关系来描述两个操作之间的执行顺序,由于这两个操作可以在一个线程之内,也可以是在不同线程之间。因此,JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证,如果 A happens-before B 那么可以认定 A 对 B 可见,简单来讲 happens-before 描述的是程序间可见性的一种规则,具体的场景如下:

1、程序次序规则:在一个线程内一段代码的执行结果是有序的。就是还会指令重排,但是随便它怎么排,结果是按照我们代码的顺序生成的不会变。

2、管程锁定规则:就是无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果!(管程是一种通用的同步原语,synchronized就是管程的实现)

3、volatile变量规则:就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作的结果一定对读的这个线程可见。

4、线程启动规则:在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见。

5、线程终止规则:在主线程A执行过程中,子线程B终止,那么线程B在终止之前对共享变量的修改结果在线程A中可见。也称线程join()规则。

6、线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程代码检测到中断事件的发生,可以通过Thread.interrupted()检测到是否发生中断。

7、传递性规则:这个简单的,就是happens-before原则具有传递性,即hb(A, B) , hb(B, C),那么hb(A, C)。

8、对象终结规则:这个也简单的,就是一个对象的初始化的完成,也就是构造函数执行的结束一定 happens-before它的finalize()方法。

volatile特性的实现原理(何为内存屏障)

如果你看过JVM的源码(反正我没看),在HotSpot源码中针对 volatile 做了特殊处理,也就是发现当前变量是被 volatile 修饰的那么会在汇编指令前面就加上 lock 指令,该指令在执行过程中会生成相应的内存屏障,lock 指令是确保 volatile 实现可见性以及禁止指令重排的关键。

1、内存屏障会禁止指令重排序,即不会把前面的指令重排到内存屏障之后也不会把后面的指令排到内存屏障之前。

2、lock 指令同时会确保将当前处理器缓存行的数据立即写回系统内存,该操作本身会引起其它CPU缓存的数据失效,写回操作会通过总线来传递数据,CPU通过总线嗅探来检查自己缓存的值是不是过期了,如果发现过期了会强制重新从系统内存里把数据读到处理器缓存。

Java内存模型把内存屏障分为了以下几个类型:

屏障类型指令示例说明
LoadLoad BarriersLoad1;LoadLoad;Load2该屏障确保Load1数据的装载先于Load2及其后所有装载指令的的操作
StoreStore BarriersStore1;StoreStore;Store2该屏障确保Store1立刻刷新数据到内存(使其对其他处理器可见)的操作先于Store2及其后所有存储指令的操作
LoadStore BarriersLoad1;LoadStore;Store2确保Load1的数据装载先于Store2及其后所有的存储指令刷新数据到内存的操作
StoreLoad BarriersStore1;StoreLoad;Load2该屏障确保Store1立刻刷新数据到内存的操作先于Load2及其后所有装载装载指令的操作。它会使该屏障之前的所有内存访问指令(存储指令和访问指令)完成之后,才执行该屏障之后的内存访问指令

内存屏障.png

1、 volatile 的写操作是在指令前后分别插入指令。

2、 volatile 的读操作则是在指令后面插入两个指令。

3、 StoreLoad 屏障是一个全能型的屏障,它同时具有其他三个屏障的效果。所以执行该屏障开销会很大,因为它使处理器要把缓存中的数据全部刷新到内存中。

参考资料

1、阿里面试官没想到,一个Volatile我能跟他扯半个小时

2、Volatile如何保证线程可见性之总线锁、缓存一致性协议

3、volatile 关键字,你真的理解吗?

4、深入理解Java虚拟机

5、happens-before 规则

6、volatil和内存屏障

7、一文理解内存屏障