写在文章开头
volatile
被称之为轻量级的synchronized
,即通过无锁的方式保证可见性,而本文将通过自顶向下的方式深入剖析这个关键字的底层实现,希望对你有帮助。

Hi,我是 sharkChili ,是个不断在硬核技术上作死的技术人,是 CSDN的博客专家 ,也是开源项目 Java Guide 的维护者之一,熟悉 Java 也会一点 Go ,偶尔也会在 C源码 边缘徘徊。写过很多有意思的技术博客,也还在研究并输出技术的路上,希望我的文章对你有帮助,非常欢迎你关注我的公众号: 写代码的SharkChili 。
同时也非常欢迎你star我的开源项目mini-redis:github.com/shark-ctrl/...
因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 "加群" 即可和笔者和笔者的朋友们进行深入交流。
详解volatile关键字
共享变量操作不可见案例介绍
我们编写一段多线程读写一个变量的代码,t1
一旦感知num
被t2
修改,就会结束循环,然而事实却是这段代码即使在t2完成修改之后,t1也像是感知不到变化一样一直无限循环阻塞着:
ini
private static int num = 0;
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(2);
Thread t1 = new Thread(() -> {
while (num == 0) {
}
log.info("num已被修改为:1");
countDownLatch.countDown();
});
Thread t2 = new Thread(() -> {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
num++;
log.info("t2修改num为1");
countDownLatch.countDown();
});
t1.start();
t2.start();
countDownLatch.await();
log.info("执行结束");
}
volatile保证可见性
于是我们将代码增一个本文所引出的关键字volatile
加以修饰:
arduino
private volatile static int num = 0;
对应的我们给出输出结果,如预期一样线程修改完之后线程1就会感知到变化而结束循环:
ini
23:54:04.040 [Thread-0] INFO MultiApplication - num已被修改为:1
23:54:04.040 [Thread-1] INFO MultiApplication - t2修改num为1
23:54:04.042 [main] INFO MultiApplication - 执行结束
详解volatile工作原理
volatile
底层实现和JMM内存模型息息相关,该模型规范了线程的本地变量(各个线程拿到共享变量num
的副本)和主存(内存中的变量num)的关系,其规范通过happens-before
等规约强制规范了JVM
需要针对这几个要求要做出不同的处理来配合处理器保证共享变量操作的可见性和有序性,这一点感兴趣的读者可以移步下面这篇文章了解一下JMM
内存规范和避免指令重排序的实际落地实现:
按照JMM
模型抽象的各种happens-before
及其内存模型8大操作:
volatile的变量的写操作, happens-before后续读该变量的代码
这就要求t1和t2修改num
的时候,都必须从主存中先加载才能进行修改,以上述代码为例,假设t1修改了num
的值,完成后就必须将最新的结果写回主存中,而t2收到这个修改的通知后必须从主内存中拉取最新的结果才能进行操作:

上述这个流程只是JMM
模型的抽象,也就是JVM
便于让程序员理解的一种模型,不是实际的实现, 对应的我们通过jitwatch
查看volatile
修饰的变量num
进行累加的代码:
arduino
private volatile static int num = 0;
public static void main(String[] args) throws InterruptedException {
num++;
}
从汇编码可以看出,汇编指令用到了一个lock
的关键字,这就是保证并发编程可见性的关键:
perl
0x00000000038ca0a1: lock addl $0x0,(%rsp) ;*putstatic num
; - org.example.Main::main@5 (line 10)
0x00000000038ca0a6: mov 0x68(%r10),%r11d
0x00000000038ca0aa: inc %r11d
0x00000000038ca0ad: mov %r11d,0x68(%r10)
0x00000000038ca0b1: lock addl $0x0,(%rsp) ;*putstatic num
通过查IA-32
架构软件开发者手册可知,Lock
前缀的指令在多核处理器下会引发了两件事情:
- 将当前变量
num
从当前处理器的缓存行(cache-line)
写回内存。 - 通知其他处理器该变量已被修改,其他处理器
cache-line
中的num
值全部变为invalid(无效)
。
这也就是我们Intel 64
著名的MESI
协议,将该实现代入我们的代码,假设线程1的num
被CPU-0
的处理,线程2被CPU-1
处理,实际上底层的实现是:
- t1获取共享变量
num
的值,此时并没有其他核心上的线程获取,状态为E(exclusive)
。 - t2启动也获取到
num
的值,此时总线嗅探到另一个CPU
也有这个变量的缓存,所以两个CPU
缓存行都设置为S(shard)
。 - t2修改num的值,通过总线嗅探机制发起通知,t1的线程收到消息后,将缓存行变量设置为
I(invalid)
。 - t1需要输出结果,因为看到自己变量是无效的,于是通知总线让t1将结果写回内存,自己重新加载。

更多关于MESI协议的实现细节,感兴趣的读者可以参考笔者的这篇文章:mp.weixin.qq.com/s?__biz=Mzk...
volatile如何禁止指令重排序
而volatile
不仅可以保证可见性,还可以避免指令重排序,底层同样是通过JMM
规约,禁止特定编译器进行有风险的重排序,以及在生成字节序列时插入内存屏障避免CPU
重排序解决问题。
我们不妨看一段双重锁校验的单例模式代码,代码如下所示可以看到经过双重锁校验后,会进行new Singleton();
csharp
public class Singleton {
private static Singleton uniqueInstance;
private Singleton() {
}
public static Singleton getUniqueInstance() {
//先判断对象是否已经实例过,没有实例化过才进入加锁代码
if (uniqueInstance == null) {
//类对象加锁
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
这一操作,这个对象创建的操作乍一看是原子性的,实际上编译后再执行的机器码会将其分为3个动作:
- 为引用
uniqueInstance
分配内存空间 - 初始化
uniqueInstance
uniqueInstance
指向分配的内存空间
所以如果没有volatile
禁止指令重排序的话,1、2、3的顺序操作很可能变成1、3、2,进而可能出现下面这种情况:
- 线程1执行步骤1分配内存空间。
- 线程1执行步骤3让引用指向这个内存空间。
- 线程2进入逻辑判断发现
uniqueInstance
不为空直接返回,导致外部操作异常。
极端情况下,这种情况可能导致线程2外部操作到的可能是未初始化的对象,导致一些业务上的操作异常:

所以针对这种情况,我们需要增加volatile
关键字让禁止这种指令重排序:
arduino
private volatile static Singleton uniqueInstance;
按照JMM
的happens-before
原则volatile的变量的写操作, happens-before后续读该变量的代码
,这就会使的volatile
操作可能实现如下几点:
- 第二个针对
volatile
写操作时,不管第一个操作是任何操作,都不能发生重排序。 - 第一个针对
volatile
读的操作,后续volatile
任何操作都不能重排序。 - 第一个
volatile
写操作,后续volatile读,不能进行重排序。
基于这套规范,在编译器生成字节码时,就会通过内存屏障的方式告知处理器禁止特定的重排序:
- 每个
volatile
写后插入storestore
,让第一个写优先于第二个写,避免重排序后的写(可以理解未变量计算)顺序重排序导致的计数结果异常。 - 每个
volatile
写后插入storeload
,让第一个写先于后续读,避免读取异常。 - 每个
volatile
读后加个loadstore
,让第一个读操作先于第二个写,避免读写重排序的异常。 - 每个
volatile
读后加个loadload
,让第一个读先于第二个读,避免读取顺序重排序的异常。

volatile无法保证原子性
我们不妨看看下面这段代码,首先我们需要了解一下num++
这个操作在底层是如何实现的:
- 读取
num
的值 - 对
num
进行+1 - 写回内存中
基于jitwatch
,我们看到的对应的汇编码如下:
perl
0x00000000038ca096: mov 0x68(%r10),%r8d
0x00000000038ca09a: inc %r8d
0x00000000038ca09d: mov %r8d,0x68(%r10)
这里蛮补充一句,关于jitwatch的安装使用,感兴趣的读者可以参考这篇文章:mp.weixin.qq.com/s/RDxQxVBx0...
我们查看代码的运行结果,可以看到最终的值不一定是10000
,由此可以得出volatile
并不能保证原子性
ini
public class VolatoleAdd {
private static int num = 0;
public void increase() {
num++;
}
public static void main(String[] args) {
int size = 10000;
CountDownLatch downLatch = new CountDownLatch(1);
ExecutorService threadPool = Executors.newFixedThreadPool(size);
VolatoleAdd volatoleAdd = new VolatoleAdd();
for (int i = 0; i < size; i++) {
threadPool.submit(() -> {
try {
downLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
volatoleAdd.increase();
});
}
downLatch.countDown();
threadPool.shutdown();
while (!threadPool.isTerminated()) {
}
System.out.println(VolatoleAdd.num);//9998
}
}
而对应的解决方案我们可以通过synchronized
、原子类、或者Lock
相关实现类解决问题。
并发编程中三个重要特性
即原子性、有序性、可见性:
- 原子性:一组操作要么全部都完成,要么全部失败,
Java
就是基于synchronized
或者各种Lock实现原则性。 - 可见性:线程对于某些变量的操作,对于后续操作该变量的线程是立即可见的。
Java
基于synchronized
或者各种Lock
、volatile
实现可见性,例如声明volatile变量这就意味着Java代码在操作该变量时每次都会从主内存中加载。 - 有序性:指令重排序只能保证串行语义一致性,并不能保证多线程情况下也一致,
Java
常常使用volatile
禁止指令进行重排序优化。
小结
我是 sharkchili ,CSDN Java 领域博客专家 ,mini-redis 的作者,我想写一些有意思的东西,希望对你有帮助,如果你想实时收到我写的硬核的文章也欢迎你关注我的公众号: 写代码的SharkChili 。
同时也非常欢迎你star我的开源项目mini-redis:github.com/shark-ctrl/...
因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 "加群" 即可和笔者和笔者的朋友们进行深入交流。
参考
CPU 缓存一致性:xiaolincoding.com/os/1_hardwa...
volatile可见性实现原理:blog.csdn.net/itakyubi/ar...
吃透Java并发:volatile是怎么保证可见性的:zhuanlan.zhihu.com/p/250657181
volatile 三部曲之可见性:mp.weixin.qq.com/s/2tuUq1QOt...
透写和回写缓存(Write Through and Write Back in Cache):zhuanlan.zhihu.com/p/571429282
本文使用 markdown.com.cn 排版