面试官:说说volatile底层完结原理?

Java 并发编程中,有 3 个最常用的关键字:synchronized、ReentrantLock 和 volatile。

尽管 volatile 并不像其他两个关键字一样,能保证线程安全,但 volatile 也是并发编程中最常见的关键字之一。例如,单例形式、CopyOnWriteArrayList 和 ConcurrentHashMap 中都离不开 volatile。

那么,问题来了,咱们知道 synchronized 底层是经过监视器 Monitor 完结的,ReentrantLock 底层是经过 AQS 的 CAS 完结的,那 volatile 的底层是怎么完结的?

1.volatile 效果

在了解 volatile 的底层完结之前,咱们需求先了解 volatile 的效果,因为 volatile 的底层完结和它的效果休戚相关。

volatile 效果有两个:保证内存可见性和有序性(制止指令重排序)

1.1 内存可见性

提到内存可见性问题就不得不提 Java 内存模型,Java 内存模型(Java Memory Model)简称为 JMM,首要是用来屏蔽不同硬件和操作系统的内存拜访差异的,因为在不同的硬件和不同的操作系统下,内存的拜访是有一定的差异得,这种差异会导致相同的代码在不同的硬件和不同的操作系统下有着不一样的行为,而 Java 内存模型便是处理这个差异,一致相同代码在不同硬件和不同操作系统下的差异的。

Java 内存模型规定:一切的变量(实例变量和静态变量)都必须存储在主内存中,每个线程也会有自己的作业内存,线程的作业内存保存了该线程用到的变量和主内存的副本复制,线程对变量的操作都在作业内存中进行。线程不能直接读写主内存中的变量,如下图所示:

面试官:说说volatile底层完结原理?
然而,Java 内存模型会带来一个新的问题,那便是内存可见性问题,也便是当某个线程修正了主内存中同享变量的值之后,其他线程不能感知到此值被修正了,它会一直运用自己作业内存中的“旧值”,这样程序的履行成果就不符合咱们的预期了,这便是内存可见性问题,咱们用以下代码来演示一下这个问题:

private static boolean flag = false;
public static void main(String[] args) {
    Thread t1 = new Thread(new Runnable() {
        @Override
        public void run() {
            while (!flag) {
            }
            System.out.println("终止履行");
        }
    });
    t1.start();
    Thread t2 = new Thread(new Runnable() {
        @Override
        public void run() {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("设置 flag=true");
            flag = true;
        }
    });
    t2.start();
}

以上代码咱们预期的成果是,在线程 1 履行了 1s 之后,线程 2 将 flag 变量修正为 true,之后线程 1 终止履行,然而,因为线程 1 感知不到 flag 变量发生了修正,也便是内存可见性问题,所以会导致线程 1 会永远的履行下去,最终咱们看到的成果是这样的:

面试官:说说volatile底层完结原理?
怎么处理以上问题呢?只需求给变量 flag 加上 volatile 润饰即可,具体的完结代码如下:

private volatile static boolean flag = false;
public static void main(String[] args) {
    Thread t1 = new Thread(new Runnable() {
        @Override
        public void run() {
            while (!flag) {
            }
            System.out.println("终止履行");
        }
    });
    t1.start();
    Thread t2 = new Thread(new Runnable() {
        @Override
        public void run() {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("设置 flag=true");
            flag = true;
        }
    });
    t2.start();
}

以上程序的履行成果如下图所示:

面试官:说说volatile底层完结原理?

1.2 有序性

有序性也叫做制止指令重排序。

指令重排序是指编译器或 CPU 为了优化程序的履行性能,而对指令进行从头排序的一种手段。

指令重排序的完结初衷是好的,可是在多线程履行中,如果履行了指令重排序或许会导致程序履行犯错。指令重排序最典型的一个问题就发生在单例形式中,比方以下问题代码:

public class Singleton {
    private Singleton() {}
    private static Singleton instance = null;
    public static Singleton getInstance() {
        if (instance == null) { // ①
            synchronized (Singleton.class) {
            	if (instance == null) {
                	instance = new Singleton(); // ②
                }
            }
        }
        return instance;
    }
}

以上问题发生在代码 ② 这一行“instance = new Singleton();”,这行代码看似仅仅一个创立目标的进程,然而它的实际履行却分为以下 3 步:

  1. 创立内存空间。
  2. 在内存空间中初始化目标 Singleton。
  3. 将内存地址赋值给 instance 目标(履行了此步骤,instance 就不等于 null 了)。

如果此变量不加 volatile,那么线程 1 在履行到上述代码的第 ② 处时就或许会履行指令重排序,将原本是 1、2、3 的履行次序,重排为 1、3、2。可是特别情况下,线程 1 在履行完第 3 步之后,如果来了线程 2 履行到上述代码的第 ① 处,判断 instance 目标已经不为 null,但此时线程 1 还未将目标实例化完,那么线程 2 将会得到一个被实例化“一半”的目标,然后导致程序履行犯错,这便是为什么要给私有变量增加 volatile 的原因了。

要使以上单例形式变为线程安全的程序,需求给 instance 变量增加 volatile 润饰,它的最终完结代码如下:

public class Singleton {
    private Singleton() {}
    // 运用 volatile 制止指令重排序
    private static volatile Singleton instance = null; // 【首要是此行代码发生了改变】
    public static Singleton getInstance() {
        if (instance == null) { // ①
            synchronized (Singleton.class) {
            	if (instance == null) {
                	instance = new Singleton(); // ②
                }
            }
        }
        return instance;
    }
}

2.volatile 完结原理

volatile 完结原理和它的效果有关,咱们首要先来看它的内存可见性。

2.1 内存可见性完结原理

volatile 内存可见性首要经过 lock 前缀指令完结的,它会锁定当时内存区域的缓存(缓存行),而且立行将当时缓存行数据写入主内存(耗时十分短),回写主内存的时候会经过 MESI 协议使其他线程缓存了该变量的地址失效,然后导致其他线程需求从头去主内存中从头读取数据到其作业线程中。

什么 MESI 协议?

MESI 协议,全称为 Modified, Exclusive, Shared, Invalid,是一种高速缓存一致性协议。它是为了处理多处理器(CPU)在并发环境下,多个 CPU 缓存不一致问题而提出的。 MESI 协议界说了高速缓存中数据的四种状况:

  1. Modified(M):表明缓存行已经被修正,但还没有被写回主存储器。在这种状况下,只有一个 CPU 能独占这个修正状况。
  2. Exclusive(E):表明缓存行与主存储器相同,而且是主存储器的唯一复制。这种状况下,只有一个 CPU 能独占这个状况。
  3. Shared(S):表明此高速缓存行或许存储在计算机的其他高速缓存中,而且与主存储器匹配。在这种状况下,各个 CPU 能够并发的对这个数据进行读取,但都不能进行写操作。
  4. Invalid(I):表明此缓存行无效或已过期,不能运用。

MESI 协议的首要用途是保证在多个 CPU 同享内存时,各个 CPU 的缓存数据能够坚持一致性。当某个 CPU 对同享数据进行修正时,它会将这个数据的状况从 S(同享)或 E(独占)状况转变为 M(修正)状况,并等候恰当的时机将这个修正写回主存储器。一起,它会向其他 CPU 广播一个“无效音讯”,使得其他 CPU 将自己缓存中对应的数据状况转变为I(无效)状况,然后在下次拜访这个数据时能够从主存储器或其他 CPU 的缓存中从头获取正确的数据。

这种协议能够保证在多处理器环境中,各个 CPU 的缓存数据能够正确、一致地反映主存储器中的数据状况,然后避免因为缓存不一致导致的数据错误或程序反常。

2.2 有序性完结原理

volatile 的有序性是经过刺进内存屏障(Memory Barrier),在内存屏障前后制止重排序优化,以此完结有序性的。

什么是内存屏障?

内存屏障(Memory Barrier 或 Memory Fence)是一种硬件等级的同步操作,它强制处理器按照特定次序履行内存拜访操作,保证内存操作的次序性,阻挠编译器和 CPU 对内存操作进行不必要的重排序。内存屏障能够保证跨过屏障的读写操作不会穿插进行,以此保持程序的内存一致性模型。

在 Java 内存模型(JMM)中,volatile 关键字用于润饰变量时,能够保证该变量的可见性和有序性。关于有序性,volatile 经过内存屏障的刺进来完结:

  • 写内存屏障(Store Barrier / Write Barrier): 当线程写入 volatile 变量时,JMM 会在写操作前刺进 StoreStore 屏障,保证在这次写操作之前的一切一般写操作都已完结。接着在写操作后刺进 StoreLoad 屏障,强制一切后来的读写操作都在此次写操作完结之后履行,这就保证了其他线程能立即看到 volatile 变量的最新值。
  • 读内存屏障(Load Barrier / Read Barrier): 当线程读取 volatile 变量时,JMM 会在读操作前刺进 LoadLoad 屏障,保证在此次读操作之前的一切读操作都已完结。而在读操作后刺进 LoadStore 屏障,避免在此次读操作之后的写操作被重排序到读操作之前,这样就保证了对 volatile 变量的读取总是能看到之前对同一变量或其他相关变量的写入成果。

经过这种方式,volatile 关键字有效地完结了内存操作的次序性,然后保证了多线程环境下对 volatile 变量的操作遵循 happens-before 准则,保证了并发编程的正确性。

2.3 简单答复

因为内存屏障的效果既能保证内存可见性,一起又能制止指令重排序。因此你也能够抽象的答复 volatile 是经过内存屏障完结的。可是,答复的越细,面试的成绩越高,面试的经过率也就越高。

课后思考

什么是 happens-before 准则?除了 synchronized、ReentrantLock 和 volatile 之外,并发编程中还有哪些常见的关键字呢?它们背后的完结原理又是什么呢?

本文已收录到我的面试小站 www.javacn.site,其间包括的内容有:Redis、JVM、并发、并发、MySQL、Spring、Spring MVC、Spring Boot、Spring Cloud、MyBatis、规划形式、音讯行列等模块。