关于java:面试官说说volatile底层实现原理

3次阅读

共计 4704 个字符,预计需要花费 12 分钟才能阅读完成。

在 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 内存模型规定:所有的变量(实例变量和动态变量)都必须存储在主内存中,每个线程也会有本人的工作内存,线程的工作内存保留了该线程用到的变量和主内存的正本拷贝,线程对变量的操作都在工作内存中进行。线程不能间接读写主内存中的变量,如下图所示:

然而,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 会永远的执行上来,最终咱们看到的后果是这样的:

如何解决以上问题呢?只须要给变量 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();}

以上程序的执行后果如下图所示:

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、设计模式、音讯队列等模块。

正文完
 0