1 引言

多个线程共用一个共享变量,会遇到并发读写的问题,volatile 关键字就是来解决这个问题的。

本文将会从以下几个方面来讲解 volatile:

  • cpu 缓存模型
  • Java 内存模型
  • 原子性、可见性、有序性
  • volatile 的作用
  • volatile 的底层原理

2 CPU 缓存模型

现代的计算机技术,内存的读写速度没什么突破,cpu如果要频繁的读写主内存的话,会导致性能较差,计算性能就会低,不适应现代计算机技术的发展,于是又在 CPU 中加了几层缓存,如下图所示:

这样 CPU 可以直接操作自己对应的高速缓存,不需要直接频繁的跟主内存通信,这样可以保证 cpu 的计算效率。

但是这样会产生并发问题:假设某个时刻 CPU a 更新了本地缓存的 flag,但此时还没更新到主内存,CPU b 此时读取到的值还是旧值。这便产生了一致性的问题。

其实上述场景只是并发问题中的一个,本质上都是因为各个 CPU 的本地缓存跟主内存之间没有同步,一个数据,在各个地方,可能都不一样,这样就导致了数据的不一致。

3 MESI

对于这个缓存不一致的问题,在计算机上古时期,采用了一种总线加锁机制。简单来说就是,某个cpu如果要修改一个数据,会通过一个总线,对这个数据加一个锁,其他的cpu就没法去读和写这个数据了,只有当这个cpu修改完了以后,其他 cpu 才可以读到最新的数据。

但这种总线加锁机制显然太过粗暴,可以想象的是,如果并发数比较大,那么效率肯定很低。

现在计算机流行的是 MESI 协议:

缓存行有4种不同的状态:

  • 已修改Modified (M)

    缓存行是脏的(dirty),与主存的值不同。如果别的CPU内核要读主存这块数据,该缓存行必须回写到主存,状态变为共享(S).

  • 独占Exclusive (E)

    缓存行只在当前缓存中,但是干净的(clean)--缓存数据同于主存数据。当别的缓存读取它时,状态变为共享;当前写数据时,变为已修改状态。

  • 共享Shared (S)

    缓存行也存在于其它缓存中且是干净的。缓存行可以在任意时刻抛弃。

  • 无效Invalid (I)

    缓存行是无效的

线程修改了缓存行,会刷到主内存中,CPU 会采用嗅探机制,将其他 CPU 的对应缓存行设置为无效状态,强制其他 CPU 从主内存中读取最新的值。

通过这样的机制,我们才能保证线程工作内存和主内存是一致的。

4 Java 内存模型

Java内存模型是跟cpu缓存模型是类似的,基于cpu缓存模型来建立的java内存模型,只不过java内存模型是标准化的,屏蔽掉底层不同的计算机的区别。

首先我们来看看几个概念:

  1. read:从主存读取

  2. load:将主存读取到的值写入工作内存

  3. use:从工作内存读取数据来计算

  4. assign:将计算好的值重新赋值到工作内存中

  5. store:将工作内存数据写入主存

  6. write:将store过去的变量值赋值给主存中的变量

可以参考下图来记忆:

5 可见性、原子性、顺序性

  • 可见性:前述问题讲述了主内存和工作内存中最新修改的值不可见的问题,其实就是可见性的问题,voilatile 可以解决此问题。

  • 原子性:volatile 一般意义上并不能保证原子性(i++ 的操作不是原子性操作)。

  • 有序性:还有一个问题是指令重排序,编译器和指令器,有的时候为了提高代码执行效率,会将指令重排序,就是说比如下面的代码

flag = false;
// 线程1:
prepare();  // 准备资源
flag = true;   

//线程2:
while(!flag){
 Thread.sleep(1000);
}
execute(); // 基于准备好的资源执行操作

重排序之后,让flag = true先执行了,会导致线程2直接跳过while等待,执行某段代码,结果prepare()方法还没执行,资源还没准备好呢,此时就会导致代码逻辑出现异常。

6 happens-before 原则

上文说过,编译器、指令器可能会对代码进行重排序,但是不能乱排,要遵守一定的规则,这个规则就是 happens-before 原则,只要符合 happens-before 的原则,那么就不能胡乱重排,反之,那就可以进行重排序。

happens-before原则:

  1. 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作

  2. 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作

  3. volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作。volatile变量写,再是读,必须保证是先写,再读。

  4. 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C

  5. 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作

  6. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生

  7. 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行

  8. 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

7 volitile 底层实现原理:lock 指令以及内存屏障

前文已经讲述过,volatile 可以保证可见性和有序性,那么留给我们的就有两个问题:

  1. volatile 是如何保证可见性的?
  2. volatile 是如何保证有序性的?

7.1 volatile 是如何保证可见性的

对 volatile 修饰的变量,执行写操作的话,JVM会发送一条lock前缀指令给CPU,CPU在计算完之后会立即将这个值写回主内存,同时因为有MESI缓存一致性协议,所以各个CPU都会对总线进行嗅探,自己本地缓存中的数据是否被别人修改。

如果发现别人修改了某个缓存的数据,那么CPU就会将自己本地缓存的数据过期掉,然后这个CPU上执行的线程在读取那个变量的时候,就会从主内存重新加载最新的数据了。

7.2 volatile 是如何保证有序性的

Java 内存模型里有 4 种内存屏障。

  1. LoadLoad
  2. StoreStore
  3. LoadStore
  4. StoreLoad
Load1:
int localVar = this.variable

LoadLoad屏障

Load2:
int localVar = this.variable2

LoadLoad屏障:Load1;LoadLoad;Load2,确保Load1数据的装载先于Load2后所有装载指令,他的意思,Load1对应的代码和Load2对应的代码,是不能指令重排的

Store1:
this.variable = 1

StoreStore屏障

Store2:
this.variable2 = 2

StoreStore屏障:Store1;StoreStore;Store2,确保Store1的数据一定刷回主存,对其他cpu可见,先于Store2以及后续指令

LoadStore屏障:Load1;LoadStore;Store2,确保Load1指令的数据装载,先于Store2以及后续指令

StoreLoad屏障:Store1;StoreLoad;Load2,确保Store1指令的数据一定刷回主存,对其他cpu可见,先于Load2以及后续指令的数据装载。

对于volatile修改变量的读写操作,都会加入内存屏障

每个volatile写操作前面,加StoreStore屏障,禁止上面的普通写和他重排;每个volatile写操作后面,加StoreLoad屏障,禁止跟下面的volatile读/写重排。

每个volatile读操作后面,加LoadLoad屏障,禁止下面的普通读和voaltile读重排;每个volatile读操作后面,加LoadStore屏障,禁止下面的普通写和volatile读重排。

8 经典的双重检查锁

不使用 volatile 的双重检查锁

public class DoubleCheckSingleton {

    // 私有变量
    private static DoubleCheckSingleton instance;

    // 公共方法
    public DoubleCheckSingleton getInstance() {
        if(instance == null) {
            synchronized (DoubleCheckSingleton.class) {
                if(instance == null) {
                    instance = new DoubleCheckSingleton();
                }
            }
        }
        return instance;
    }
}

在执行这一行代码的时候:

instance = new DoubleCheckSingleton();

实际上这个步骤并不是原子性的,有三个过程:

  1. 分配内存空间
  2. 调用构造器方法,执行初始化
  3. 将对象指向刚分配的内存空间

但是有些编译器为了性能的原因,可能会将第二步和第三步进行重排序,顺序就成了:

  1. 分配内存空间
  2. 将对象指向刚分配的内存空间
  3. 调用构造器方法,执行初始化

这样就会造成多线程在调用改方法时,有可能会得到一个未被初始化的对象,此时也就是说没有保证可见性。

我们可以使用 volatile 来实现可见性。

public class DoubleCheckSingleton {

    // 私有变量
    private volatile static DoubleCheckSingleton instance;

    // 公共方法
    public DoubleCheckSingleton getInstance() {
        if(instance == null) {
            synchronized (DoubleCheckSingleton.class) {
                if(instance == null) {
                    instance = new DoubleCheckSingleton();
                }
            }
        }
        return instance;
    }
}