在 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、设计模式、消息队列等模块。

面试官:说说volatile底层实现原理?的更多相关文章

  1. 面试官:volatile关键字用过吧?说一下作用和实现吧

    volatile    可见性的本质类似于CPU的缓存一致性问题,线程内部的副本类似于告诉缓存区 面试官:volatile关键字用过吧?说一下作用和实现吧 https://blog.csdn.net/ ...

  2. volatile底层实现原理

    前言 当共享变量被声明为volatile后,对这个变量的读/写操作都会很特别,下面我们就揭开volatile的神秘面纱. 1.volatile的内存语义 1.1 volatile的特性 一个volat ...

  3. 【面试题】手写async await核心原理,再也不怕面试官问我async await原理

    前言 async await 语法是 ES7出现的,是基于ES6的 promise和generator实现的 generator函数 在之前我专门讲个generator的使用与原理实现,大家没了解过的 ...

  4. 面试加分项---HashMap底层实现原理

    想必大家都知道HashSet和HashMap之间的关系,HashSet是依赖于HashMap的,HashSet集合就是HashMap的key所组成的集合,我们都知道HashMap的value是可以重复 ...

  5. 如何完美回答面试官问的Mybatis初始化原理!!!

    前言 对于任何框架而言,在使用前都要进行一系列的初始化,MyBatis也不例外.本章将通过以下几点详细介绍MyBatis的初始化过程. MyBatis的初始化做了什么 MyBatis基于XML配置文件 ...

  6. 看完这一篇,再也不怕面试官问到IntentService的原理

    IntentService是什么 在内部封装了 Handler.消息队列的一个Service子类,适合在后台执行一系列串行依次执行的耗时异步任务,方便了我们的日常coding(普通的Service则是 ...

  7. 面试官:说一下Synchronized底层实现,锁升级的具体过程?

    面试官:说一下Synchronized底层实现,锁升级的具体过程? 这是我去年7,8月份面试的时候被问的一个面试题,说实话被问到这个问题还是很意外的,感觉这个东西没啥用啊,直到后面被问了一波new O ...

  8. 面试官最爱的volatile关键字

    在Java相关的岗位面试中,很多面试官都喜欢考察面试者对Java并发的了解程度,而以volatile关键字作为一个小的切入点,往往可以一问到底,把Java内存模型(JMM),Java并发编程的一些特性 ...

  9. Java面试官最爱问的volatile关键字

    在Java的面试当中,面试官最爱问的就是volatile关键字相关的问题.经过多次面试之后,你是否思考过,为什么他们那么爱问volatile关键字相关的问题?而对于你,如果作为面试官,是否也会考虑采用 ...

  10. 并发之volatile底层原理

    15.深入分析Volatile的实现原理 14.java多线程编程底层原理剖析以及volatile原理 13.Java中Volatile底层原理与应用 12.Java多线程-java.util.con ...

随机推荐

  1. redis 设置密码之后,通过命令行一键刷新的办法

    之前以为很麻烦 发现还是自己太low了. redis-cli -a Test1127 flushall

  2. CS231N Assignment1 SVM 笔记

    svm.ipynb 为SVM实现一个完全矢量化的损失函数 为其解析梯度实现完全矢量化表达式 使用数值梯度检查实现结果 使用验证集调整学习率和正则化 使用 SGD 优化损失函数 可视化最终学习权重 第一 ...

  3. 基于Spring Cache实现Caffeine、jimDB多级缓存实战

    作者: 京东零售 王震 背景 在早期参与涅槃氛围标签中台项目中,前台要求接口性能999要求50ms以下,通过设计Caffeine.ehcache堆外缓存.jimDB三级缓存,利用内存.堆外.jimDB ...

  4. element-ui表格展开行每次只能展开一行

    element-ui表格展开行每次只能展开一行 <template> <el-table :data="tableData" :expand-row-keys=& ...

  5. 插件时间格式处理moment如何使用

    第1步下载插件 cnpm i moment -S 第2步 在main.js中去使用 在main.js中 注册全局过滤器 fmtdata是等会你用的 可以自定义 fmtdata直接可以调用.是一个过滤器 ...

  6. 【一个小发现】VictoriaMetrics 中 vmselect 的 `-search.denyPartialResponse` 选项不应该开启

    作者:张富春(ahfuzhang),转载时请注明作者和引用链接,谢谢! cnblogs博客 zhihu Github 公众号:一本正经的瞎扯 一直以为vmselect 的 -search.denyPa ...

  7. 【JS 逆向百例】转变思路,少走弯路,X米加密分析

    声明 本文章中所有内容仅供学习交流,抓包内容.敏感网址.数据接口均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关,若有侵权,请联系我立即删除! 逆向目标 目标:X米账号登 ...

  8. pymongo中针对指定集合更新validator规则

    问题描述: 针对mongo中已创建的集合,更新validator验证器规则 解决方法 在确保pymongo中所使用的用户对目标数据库具有dbAdmin之类的管理权限的前提下(若无权限,可在mongo中 ...

  9. Go - 基本数据类型和其字符串表示之间转换

    1. 基本数据类型和其字符串表示之间转换 基本类型的值,都有一个字符串表示,如数字类型值1 字符串表示为 "1",字符的编码为Unicode或者UTF-8,数字的编码是 int,底 ...

  10. 升级到win11 22h2的体验

    win11 22h2更稳定了 在win11 22h2发布后没多久,我就升级到了这个版本,截止目前已经使用半个月了,谈谈我的使用感受. 总体要比之前的版本更稳定,表现为笔记本风扇不会突然响,突然卡顿,不 ...