详解Java并发编程volatile关键字
@[toc]
# 写在文章开头
volatile被称之为轻量级的synchronized,即通过无锁的方式保证可见性,而本文将通过自顶向下的方式深入剖析这个关键字的底层实现,希望对你有帮助。
我是 SharkChili ,Java 开发者,Java Guide 开源项目维护者。欢迎关注我的公众号:写代码的SharkChili,也欢迎您了解我的开源项目 mini-redis:https://github.com/shark-ctrl/mini-redis (opens new window)。
为方便与读者交流,现已创建读者群。关注上方公众号获取我的联系方式,添加时备注加群即可加入。
# 详解volatile关键字
# 普通变量并发操作的不可见性
我们编写一段多线程读写一个变量的代码,t1一旦感知num被t2修改,就会结束循环,然而事实却是这段代码即使在t2完成修改之后,t1也像是感知不到变化一样一直无限循环阻塞着:
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("执行结束");
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 最低线程安全和64位变量的风险
针对上述的情况,多线程在没有正确的同步情况下,可能拿到一个失效的变量值,但它并非是没有任何修改操作,我们称这种变量为最低线程安全,当然这种概念也仅仅是针对一些例如int这样的基本类型。
若是64位例如double和long,因为JMM内存模型上规定了该变量操作在不同的处理器上进行运算操作,这就是的64位操作无法保证原子性,更谈不上最低线程安全性了。

# 通过volatile修饰保证可见性
于是我们将代码增一个本文所引出的关键字volatile 加以修饰:
private volatile static int num = 0;
对应的我们给出输出结果,如预期一样线程修改完之后线程1就会感知到变化而结束循环,由此可知volatile关键字的第一个语义——保证并发场景下共享变量的可见性:
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 - 执行结束
2
3
# 基于JMM模型详解volatile的可见性
实际上,volatile底层实现和JMM内存模型规范息息相关,该模型规范了线程的本地变量(各个线程拿到共享变量num的副本)和主存(内存中的变量num)的关系,其规范通过happens-before等规约强制规范了JVM需要针对这几个原则要求做出相应的处理来配合处理器保证共享变量操作的可见性和有序性,这一点感兴趣的读者可以移步下面这篇文章了解一下JMM内存规范和避免指令重排序的实际落地实现:
按照JMM模型抽象的各种happens-before及其内存模型8大操作:
volatile的变量的写操作, happens-before后续读该变量的代码
这就要求t1和t2修改num的时候,都必须从主存中先加载才能进行修改,以上述代码为例,假设t1修改了num的值,完成后就必须将最新的结果写回主存中,而t2收到这个修改的通知后必须从主内存中拉取最新的结果才能进行操作:

关于JMM更多知识,感兴趣的读者可以看看笔者这篇文章:
详解JMM内存模型:https://mp.weixin.qq.com/s/r7e6J-Pch7pEd-iMrC4NJA (opens new window)
上述这个流程只是JMM模型的抽象,也就是JVM便于让程序员理解的一种抽象模型而实际的落地, 所以为了更好理解volatile关键字修饰的变量,我们还是以上述的例子了解一下
private volatile static int num = 0;
public static void main(String[] args) throws InterruptedException {
num = 24;
num++;
}
2
3
4
5
6
对应的我们给出JIT后的汇编码:
# num = 24;
0x000000000368cd76: mov $0x18,%edi # 将24加载到edi寄存器
0x000000000368cd7b: mov %edi,0x68(%rsi) # 将edi寄存器的值存储到内存地址为[rsi + 0x68] 也就是变量num
0x000000000368cd7e: lock addl $0x0,(%rsp) ;*putstatic num
; - org.example.Main::main@2 (line 13) # lock前缀起到类似内存屏障的作用,保证num=24这个写操作对内存中所有的处理器可见
# num++;
0x000000000368cd83: mov 0x68(%rsi),%edi ;*getstatic num
; - org.example.Main::main@5 (line 14) # 将num值加载到edi寄存器
0x000000000368cd86: inc %edi # 基于increase将寄存器上的值也就是24加上1
0x000000000368cd88: mov %edi,0x68(%rsi) # 将edi寄存器上的值赋值给num
0x000000000368cd8b: lock addl $0x0,(%rsp) ;*putstatic num
; - org.example.Main::main@10 (line 14) # 基于lock前缀实现JMM规范中的写回主存中,保证所有线程可见
2
3
4
5
6
7
8
9
10
11
12
13
针对num赋值为24这操作,汇编指令执行了如下三步:
- 通过
mov $0x18,%edi将24(0x18)加载到edi寄存器。 mov %edi,0x68(%rsi)将这个24复制给num。- 重点来了,
num=24即位于main 23行的代码,它的字节码为putstatic num这步本质就是完成变量的赋值,实际上在完成变量赋值之后,它通过lock前缀指令起到一个内存屏障的作用,保证上述的赋值操作对于所有的处理器可见,也就是实现JMM规范中的写入主存操作(下文会从硬件层面分析该指令),由此保证num++操作时会先通过getstatic到主存中获取最新值到本地内存中完成自增操作。
同样的num++也是同理,可以看到对应注释的汇编码,在完成自增即inc 操作后,同样执行lock前缀指令将数据写入主存。
# 关于volatile可见性在硬件层面的分析
上文我们以JMM规范粗略的讲解了lock前缀在规范层面上的可见性,查阅IA-32架构软件开发者手册可知,lock前缀的指令在多核处理器下会引发了两件事情:
- 将当前变量
num从当前处理器的缓存行(cache-line)数据写回内存。 - 此时,硬件层面上执行当前的CPU会通知其他处理器该变量已被修改,其他处理器
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协议的实现细节,感兴趣的读者可以参考笔者的这篇文章:https://mp.weixin.qq.com/s?__biz=MzkwODYyNTM2MQ==&mid=2247486863&idx=1&sn=58dd09b52e16fa59d7eacab0487373ee&chksm=c0c65931f7b1d0278ac049b2ce3f59bf7c839245c3205fddbff45886b7b8ad04852bdbbc6472#rd (opens new window)
# volatile无法保证原子性
我们不妨看看下面这段代码,首先我们需要了解一下的:
private static volatile int num;
public static void main(String[] args) throws InterruptedException {
num++;
}
2
3
4
5
因为这段代码位于笔者IDE的13行,基于该信息笔者拿到对应的字节码,可以看到num++这个操作在底层实现如下,大体来说分为三步:
GETSTATIC读取num的值推到栈顶。ICONST_1将常量1压入操作数栈。IADD将栈顶的num和1进行相加。- 写回内存中
PUTSTATIC写回主存。
LINENUMBER 13 L0
GETSTATIC org/example/Main.num : I
ICONST_1
IADD
PUTSTATIC org/example/Main.num : I
2
3
4
5
更进一步,基于jitwatch,我们看到的对应的汇编码如下,同样可以看到读取、自增、写回操作:
0x00000000038ca096: mov 0x68(%r10),%r8d
0x00000000038ca09a: inc %r8d
0x00000000038ca09d: mov %r8d,0x68(%r10)
2
3
很明显一个自增操作是由多条指令完成,这也就意味着,在上述指令执行期间,很可能出现其他线程读取到自增后但是还未写到内存的过期值:

这里蛮补充一句,关于jitwatch的安装使用,感兴趣的读者可以参考这篇文章:https://mp.weixin.qq.com/s/RDxQxVBx0X34qu_QYlPglg (opens new window)
我们查看代码的运行结果,可以看到最终的值不一定是10000,由此可以得出volatile并不能保证原子性
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
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
而对应的解决方案我们可以通过synchronized、原子类、或者Lock相关实现类解决问题。
# volatile如何禁止指令重排序
而volatile不仅可以保证可见性,还可以避免指令重排序,底层同样是通过JMM规约,禁止特定编译器进行有风险的重排序,以及在生成字节序列时插入内存屏障避免CPU重排序解决问题。
我们不妨看一段双重锁校验的单例模式代码,代码如下所示可以看到经过双重锁校验后,会进行new Singleton();
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;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
这一操作,这个对象创建的操作乍一看是原子性的,实际上编译后再执行的机器码会将其分为3个动作:
- 为引用
uniqueInstance分配内存空间 - 初始化
uniqueInstance uniqueInstance指向分配的内存空间
所以如果没有volatile 禁止指令重排序的话,1、2、3的顺序操作很可能变成1、3、2,进而可能出现下面这种情况:
- 线程1执行步骤1分配内存空间。
- 线程1执行步骤3让引用指向这个内存空间。
- 线程2进入逻辑判断发现
uniqueInstance不为空直接返回,导致外部操作异常。
极端情况下,这种情况可能导致线程2外部操作到的可能是未初始化的对象,导致一些业务上的操作异常:

所以针对这种情况,我们需要增加volatile 关键字让禁止这种指令重排序:
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,让第一个读先于第二个读,避免读取顺序重排序的异常。

回过头来,对于内存屏障的实现,以我们的单例模式初始化对象实例来说,其硬件架构的实现上,这个new的操作涉及多条指令,在处理器执行时可能会不按照规定顺序交由不同的电路单元执行,这就可能出现上述所谓1、3、2的情况。
对应的我们给出相应的汇编指令,可能看到其核心执行步骤为如下三步:
- 调用
JVM内部函数,在堆内存上分配Singleton内存并完成对象创建,也就是在堆内存中创建单例instance对象。 - 获取静态变量存储位置到
r11上,即将元空间的静态变量instance放到寄存器上为后续将步骤1所new的对象分配给该引用做好准备。 - 通过
cmpxchg源自指令比对r11对应的引用instance是否为null,若为null则说明没有被其他线程初始化过,则将r10创建的对象分配到该引用上,同时基于lock前缀将该引用的最近创建结果写入内存,交由CPU硬件层面的MESI协议让其他处理器可以看到最新结果。
对于避免指令重排序的语义,我们同第三条指令就能理解,即lock需要将更新操作写入内存这一特性,保证lock前缀之上的步骤1和步骤2的操作都必须完成之后,才能执行原子性的将创建的对象赋值给静态变量instance的操作,即通过硬件层面的lock前缀保证有数据的情况下才能完成对象复制,从而形成一种指令无法超越内存屏障的效果,由此具备避免指令重排序的语义:
# 调用JVM内部函数,在堆内存上分配Singleton内存并完成对象创建
0x0000000003d9300f: callq 0x00000000039057a0 ; OopMap{off=372}
;*new ; - org.example.Singleton::getUniqueInstance@17 (line 16)
; {runtime_call}
0x0000000003d93014: int3 ;*new ; - org.example.Singleton::getUniqueInstance@17 (line 16)
# 获取静态变量存储位置到r11上,即将元空间的静态变量instance放到寄存器上
L0009: movabs $0x76b95d828,%r11 ; {oop(a 'java/lang/Class' = 'org/example/Singleton')}
# 保证上述操作完成后,通过cmpxchg 源自指令比对r11对应的引用instance是否为null,若为null则说明没有被其他线程初始化过,则将r10创建的对象分配到该引用上,同时基于lock前缀做到一个类似内存屏障的作用,由此避免指令重排序
0x0000000003d9301f: lock cmpxchg %r10,(%r11)
# 执行后续操作
2
3
4
5
6
7
8
9
10
11
# 关于volatile一些更进一步的理解
# volatile在并发场景中的性能表现和运用
关于volatile性能的讨论,实际上在jdk8以上synchronized 关键字的锁升级的优化机制上很说明两者的差异,我们大体只能得出如下三个结论:
- 相较于普通变量
num和加上volatile修饰后的普通变量num,因为后者存在一致性问题需要lock前缀写回主存,所以后者性能表现比普通变量表现差。 - 对于单线程修改,多线程读取并发共享变量的场景,我们更建议使用
volatile,尽可能避免高并发场景下单修改多读取变量的重量级锁开销。 - 对于并发修改,建议使用
volatile配合锁来保证可见性和数据一致性。
# volatile与并发编程中三个重要特性
即原子性、有序性、可见性:
- 原子性:一组操作要么全部都完成,要么全部失败,
Java就是基于synchronized或者各种Lock实现原则性。 - 可见性:线程对于某些变量的操作,对于后续操作该变量的线程是立即可见的。
Java基于synchronized或者各种Lock、volatile实现可见性,例如声明volatile变量这就意味着Java代码在操作该变量时每次都会从主内存中加载。 - 有序性:指令重排序只能保证串行语义一致性,并不能保证多线程情况下也一致,
Java常常使用volatile禁止指令进行重排序优化。
# 小结
至此我们从几个简单的实践案例和volatile底层汇编码等多个角度为该关键字进行深入分析,希望对你有帮助。
我是 SharkChili ,Java 开发者,Java Guide 开源项目维护者。欢迎关注我的公众号:写代码的SharkChili,也欢迎您了解我的开源项目 mini-redis:https://github.com/shark-ctrl/mini-redis (opens new window)。
为方便与读者交流,现已创建读者群。关注上方公众号获取我的联系方式,添加时备注加群即可加入。
# 参考
CPU 缓存一致性:https://xiaolincoding.com/os/1_hardware/cpu_mesi.html#cpu-cache-的数据写入 (opens new window)
volatile可见性实现原理:https://blog.csdn.net/itakyubi/article/details/100527743 (opens new window)
吃透Java并发:volatile是怎么保证可见性的:https://zhuanlan.zhihu.com/p/250657181 (opens new window)
volatile 三部曲之可见性:https://mp.weixin.qq.com/s/2tuUq1QOtfhARfXh5VQx8A (opens new window)
透写和回写缓存(Write Through and Write Back in Cache):https://zhuanlan.zhihu.com/p/571429282 (opens new window)