2、volatile关键字

烟雨 5年前 (2021-06-13) 阅读数 338 #Java并发
文章标签 并发Java

一、认识volatile关键字

通过一段代码认识volatile关键字。
public class VolatileDemo01 {

    final static int MAX = 50;
    static int initValue = 0;

    public static void main(String[] args) {

        //读取值的线程
        new Thread(() -> {
            int localValue = initValue;
            while (localValue < MAX) {
                if (localValue != initValue) {
                    System.out.println("Reader:" + initValue);
                    localValue = initValue;
                }
            }
        }, "Reader").start();

        //修改值的线程
        new Thread(() -> {
            int localValue = initValue;
            while (localValue < MAX) {
                System.out.println("Updater:" + (++localValue));
                initValue = localValue;

                try {
                    TimeUnit.SECONDS.sleep(3);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "Updater").start();
    }
}
创建了2个线程一个用于读取值(Reader),一个用于写入值(Updater)。
理想情况下Updater线程修改一次initValue的值,Reader线程就输出修改过后的initValue值。

1.1、未加volatile关键字时,运行结果

image.png

只有第一次修改的时候处于理想状态下,修改一次读取一次。
后面都只是输出了Updater线程修改的提示。Reader线程出现了死循环,无法获得Updater线程所修改initValue的值。
这就是数据不一致性的问题

1.2、加volatile关键字时,运行结果

static volatile int initValue = 0;

image.png

Updater线程修改一次initValue的值,Reader线程就输出修改过后的initValue值。

二、CPU Cache

在计算机中,所有的运算操作都是由CPU的寄存器(工作内存就是放在这里面的)完成的,CPU指令的执行过程需要涉及数据的读取和写入操作,CPU访问的所有数据都来自主存。
随着技术进步,CPU的处理速度与内存的访问速度之间的差距越来越大,此时CPU直连内存的访问方式会限制CPU速度,降低CPU整体的吞吐量,于是就产生了缓存的设计。

image.png

CPU中的缓存概念:
CPU Cache的读写速度远高于内存的速度,缓解了CPU直接访问内存效率低下的问题,极大提高了CPU吞吐能力。
在程序运行过程中,会将运算所需要的数据从主存复制一份到CPU Cache中,CPU进行计算时直接对Cache中的数据进行读写,当运算结束后,再将Cache中的最新数据刷新到主内存中。
通常我们现在用的CPU都存在3级缓存,1级缓存最快但容量小,3级缓存稍慢但容量大。

2.1、CPU通过Cache与主内存的交互方式

image.png

当CPU发出指令想要获取并修改某个资源时(CPU Cache可以理解成JMM的工作内存,每个线程会在CPU Cache分配一小块空间来存放数据),步骤大致如下:
  1. CPU先从CPU寄存器获取资源。若没获取到,通过Cache中获取。若没获取到,通过主内存获取。

  2. 获取到主内存资源时,复制一份到Cache中,再复制一份到寄存器中(寄存器存放的是当前CPU环境以及任务环境的数据,也就是需要修改的数据)。

  3. CPU修改数据后,结果回写到CPU Cache中。

  4. CPU Cache再将数据刷新到主内存中。

2.2、数据不一致问题

通过了解CPU通过Cache与主内存的交互方式。在多线程环境中
每个线程都有自己的工作内存,则一个变量会在多个线程下都存在一个副本。
上面VolatileDemo01例子中,2个线程都从主内存中读取了initValue的值。Updater线程读取initValue自增后写入主存。而Reader线程未能获得Updater线程所修改的initValue值,导致死循环无法输出内容。这就是数据不一致的问题。

三、解决数据不一致问题方案

3.1、总线加锁方式

image.png

在多CPU下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个 LOCK# 信号,这个信号使得其他处理器无法通过总线来访问到共享内存中的数据,总线锁定把 CPU 和内存之间的通信锁住了。
锁定期间,其他处理器不能操作其他内存地址的数据,总线锁定的开销比较大(效率低下)。

3.2、缓存一致性协议(MESI)

image.png

为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常见的协议有 MSI、MESI、MOSI 等。
常见的就是 MESI 协议
M(Modify) 
表示共享数据只缓存在当前 CPU 缓存中, 并且是被修改状态,也就是缓存的数据和主内存中的数据不一致。
E(Exclusive) 
表示缓存的独占状态,数据只缓存在当前 CPU 缓存中,并且没有被修改。
S(Shared) 
表示数据可能被多个 CPU 缓存,并且各个缓存中的数据和主内存数据一致。
I(Invalid)
表示缓存已经失效。

其大致思想是,当CPU在操作Cache中的数据时,如果发现该变量是一个共享变量,也就是在其他CPU Cache中也存在一个副本,那么执行以下操作:

  1. 读取操作时:不做任何处理,只是将Cache中的数据读取到寄存器。

  2. 写入操作时:发出信号通知其他CPU将该变量的Cache line置为无效状态,写入成功后,此时其他CPU通过总线嗅探机制得知写入成功,就再次从主存中获取该共享变量。

缓存一致性协议内容很复杂,这里只是大致描述了过程。

四、volatile的禁止重排优化

volatile关键字另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象,关于指令重排优化前面已详细分析过,这里主要简单说明一下volatile是如何实现禁止指令重排优化的。先了解一个概念,内存屏障(Memory Barrier)。

4.1、硬件层的内存屏障

Intel硬件提供了一系列的内存屏障,主要有:
  1. lfence:是一种Load Barrier读屏障。

  2. sfence:是一种Store Barrier 写屏障。

  3. mfence:是一种全能型的屏障,具备ifence和sfence的能力。

  4. Lock前缀:Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。

不同硬件实现内存屏障的方式不同,Java内存模型屏蔽了这种底层硬件平台的差异,由JVM来为不同的平台生成相应的机器码。 JVM中提供了四类内存屏障指令:
屏障类型指令示例说明
LoadLoadLoad1; LoadLoad; Load2保证load1的读取操作在load2及后续读取操作之前执行。
StoreStoreStore1; StoreStore; Store2在store2及其后的写操作执行前,保证store1的写操作已刷新到主内存。
LoadStoreLoad1; LoadStore; Store2在stroe2及其后的写操作执行前,保证load1的读操作已读取结束。
StoreLoadStore1; StoreLoad; Load2保证store1的写操作已刷新到主内存之后,load2及其后的读操作才能执行。
内存屏障(Memory Barrier),又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。
由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条内存屏障(Memory Barrier)指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化
内存屏障(Memory Barrier)的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。
下面看一个非常典型的禁止重排优化的例子DCL,如下:
public class DoubleCheckLock {
    private static DoubleCheckLock instance;
    private DoubleCheckLock(){}
    public static DoubleCheckLock getInstance(){
        //第一次检测
        if (instance==null){
            //同步
            synchronized (DoubleCheckLock.class){
                if (instance == null){
                    //多线程环境下可能会出现问题的地方
                    instance = new DoubleCheckLock();
                }
            }
        }
        return instance;
    }
}
上述代码一个经典的单例的双重检测的代码,这段代码在单线程环境下并没有什么问题,但如果在多线程环境下就可以出现线程安全问题。原因在于某一个线程执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有完成初始化。
因为instance = new DoubleCheckLock();可以分为以下3步完(伪代码)
// 1.分配对象内存空间
memory = allocate();
// 2.初始化对象
instance(memory);
// 3.设置instance指向刚分配的内存地址,此时instance!=null
instance = memory;

步骤1和步骤2间可能会重排序,如下:

// 1.分配对象内存空间
memory=allocate();
// 3.设置instance指向刚分配的内存地址,此时instance!=null,但是对象还没有初始化完成!
instance=memory;
// 2.初始化对象
instance(memory);

由于步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。但是指令重排只会保证串行语义的执行的一致性(单线程),但并不会关心多线程间的语义一致性。所以当一条线程访问instance不为null时,由于instance实例未必已初始化完成,也就造成了线程安全问题。
避免这个问题,使用volatile禁止instance变量被执行指令重排优化即可。
// volatile禁止指令重排优化
private volatile static DoubleCheckLock instance;

4.2、volatile内存语义

前面提到过重排序分为编译器重排序和处理器重排序。为了实现volatile内存语义,JMM(Java内存模型)会分别限制这两种类型的重排序类型。
第一个操作
第二个操作:普通读写
第二个操作:volatile读
第二个操作:volatile写
普通读写
可以重排
可以重排
不可以重排
volatile读
不可以重排
不可以重排
不可以重排
volatile写
可以重排
不可以重排
不可以重排
从上图可以看
  • 当第一个操作为普通变量的读或写时,如果第二个操作为volatile写,则编译器不能进行重排。

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

  • 当第一个操作是volatile写,第二个操作是volatile读volatile写时,不能进行重排。

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序
对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守的JMM内存屏障插入策
  1. 在每个volatile写操作的前面插入一个StoreStore屏障

  2. 在每个volatile写操作的后面插入一个Store Load屏障

  3. 在每个volatile读操作的后面插入一个Load Load屏障Load Store屏障

volatile写插入内存屏障后生成的指令序列示意图

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

image.png

上图中StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。
volatile写后面的StoreLoad屏障。此屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序。因为编译器常常无法准确判断在一个volatile写的后面是否需要插入一个StoreLoad屏障。

volatile读插入内存屏障后生成的指令序列示意图

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

image.png

上图中LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。LoadStore屏障用来禁止处理器把上面的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()方法,编译器在生成字节码时可以做如下的优化:

image.png

注意:最后的StoreLoad屏障不能省略。因为第二个volatile写之后,方法立即return。此时编 译器可能无法准确断定后面是否会有volatile读或写,为了安全起见,编译器通常会在这里插 入一个StoreLoad屏障
上面的优化针对任意处理器平台,由于不同的处理器有不同“松紧度”的处理器内存模型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以X86处理器为例,图下图中除最后的StoreLoad屏障外,其他的屏障都会被省略。

image.png

X86处理器仅会对写-读操作做重排序。X86不会对读-读、读-写和写-写操作做重排序.
因此在X86处理器中会省略掉这3种操作类型对应的内存屏障。在X86中,JMM(Java内存模型)仅需在volatile写后面插入一个StoreLoad屏障即可正确实现volatile写-读的内存语义。这意味着在X86处理器中,volatile写的开销比volatile读的开销会大很多(因为执行StoreLoad屏障开销会比较大)。
版权声明

非特殊说明,本文由Zender原创或收集发布,欢迎转载。

上一篇:1、JMM模型 下一篇:3、多线程基础

发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

作者文章
热门