很多编程语言中都有 volatile 关键字,但是不同语言下其语义并不完全相同。本文将就其在 Java 中的语义作具体说明、解释
Visibility 可见性
很多人对 volatile 关键字的基本理解是每次读取该关键字所修饰的变量时,可以保证能够获取到其在内存中最新的值。首先说明这个理解并没有错,其说明了 volatile 具有可见性。下面我们通过一个例子来验证 volatile 的可见性。下面的代码中,我们定义了一个普通的共享变量 isLoop 用于控制线程 A 的 while 循环。我们期望当其被线程 B 修改为 false 时,线程 A 跳出 while 循环
1 | public class VolatileTest { |
现在,我们来实际验证下这个代码的效果。可以看到线程 B 虽然将 isLoop 修改为 false 了,但是从下图黄框我们可以发现,线程 A 依然处于 While 循环中没有结束。显然这里与我们的预期期望并不相符。其实原因也很简单,我们知道 JMM 要求,线程不可以直接操作主内存中的共享变量,其需要拷贝共享变量的副本至线程的工作内存中才能使用。在这里的示例代码中,虽然线程 B 将其工作内存中的共享变量 isLoop 副本的值修改为 false 了,但是并没有立即同步到主内存中。换句话说主内存中共享变量 isLoop 的值依然是 true
如果要求测试结果符合我们的预期,也很简单,只需使用 volatile 关键字修饰 isLoop 变量
1 | // 共享变量,判定是否继续循环 |
从修改后程序的测试结果,我们可以得出一个结论,当线程 B 将共享变量修改为 false 后,线程 A 能够立刻感知到其他线程对该变量所做的修改。即 volatile 具备可见性
前面我们是通过程序的现象了解了 volatile 关键字的可见性,现在我们来具体看看 Java 底层是如何实现的,这里我们借助 HSDIS 进行反汇编并通过 JITWatch 工具进行可视化分析
可以看到线程 B 在修改了共享变量后,在汇编指令中插入了一条 lock 前缀指令。根据 Intel 手册对 lock 指令的解释,其作用如下:
- 会将当前处理器 Cache 中的数据立即回写到 Ram 中。在此过程中,CPU 会适当地选取总线加锁或缓存锁等形式来保证该过程不会被打断
- 该数据的回写操作同时会引起其他处理器 Cache 中相应数据的缓存无效 (MESI 协议)
- 强制要求 lock 指令之前的操作都必须在此之前执行完成、lock 指令之后的操作都必须在此之后执行。因此该指令虽然不是内存屏障,但其却具有该语义,相当于是一个内存屏障
现在我们就可以很好的解释 volatile 关键字的可见性了。当线程 B 工作内存中的共享变量 isLoop 副本被修改后会立即刷新回主内存,即主内存中共享变量 isLoop 的值被更新为 false。与此同时对于线程 A 而言,虽然其工作内存中已经有了共享变量 isLoop 的副本,但是由于 volatile 关键字的作用,会使得其每次进行 while 循环判定时,都会从主内存中重新拷贝共享变量 isLoop 的副本,而不会使用工作内存中之前的旧副本 (旧副本数据已经失效,不可用)。所以说 volatile 关键字的所修饰共享变量,一方面可以保证共享变量的更新、修改能够立即被同步回主内存中,另一方面可以保证每次都是从主内存中读取该共享变量的最新值,而不是使用其工作内存中已有的副本数据
Atomicity 原子性
既然 volatile 具备可见性,那是不是就是说其在并发下就是安全的呢?现在我们先来看一个例子
1 | public class VolatileTest { |
其意图很简单,启动 20 个线程,每个线程分别对共享变量 count 进行 1 万次自增操作。我们期望这 20 个线程完成自增任务后,共享变量的值最终应该是 20 万 (20*10000=200000)。现在让我们来看下实际的测试结果,发现 count 值远小于 20 万。实际上该程序不论测试执行多少次基本都会小于 20 万
咦,之前明明不是说 volatile 具备可见性么?线程每次进行 count++ 自增时,应该都是获取到主内存中共享变量 count 的最新值进行操作的啊,为什么实际测试结果总是比预期小呢?这里我们依然通过反汇编来进行分析、解释
可以看到虽然在 Java 代码中 count++ 只是一行代码,但是实际上转化为字节码、汇编后却是多条指令。当线程 1 先从主内存中拷贝共享变量 count 最新值 (假设此时为 10) 到其工作内存后。正当 CPU 准备执行自增操作时。可能另外一个线程 (记为线程 2) 已经完成了共享变量的自增操作,并将更新后的值 11 写入到主内存中去了。此时线程 1 中 count 值却依然还是 10 (因为线程 1 已经完成了共享变量 count 的加载拷贝操作,故并不会重新从主内存中拷贝共享变量 count 最新值 11),当其执行完自增操作变为 11 后并回写到主内存。可以看到,虽然线程 1、2 分别完成了一次自增操作,但 count 却并没有加 2 只加了 1。其原因就在于 volatile 不具备原子性,当线程 1 从主内存加载完 count 变量值后,本该立即执行的修改、赋值操作却被其他线程所打断,导致接下来修改、赋值操作所使用的数据是旧数据
由于 volatile 不具备原子性,所以我们使用时需要特别小心。一般地,如果 运算结果不依赖变量当前的值 或 确保只有一个线程修改变量 的场景下,才会使用 volatile 关键字
Ordering 有序性
而在 JMM 中,volatile 同样具备有序性。具体地,其是通过插入内存屏障指令实现,其具体规则如下所示
从上表我们可以看出:
- 当第一个操作是 volatile 变量读时,无论第二个操作是什么,均不允许重排序。即其保证了 volatile 变量读之后的操作不会被重排序到 volatile 变量读之前
- 当第二个操作是 volatile 变量写时,无论第一个操作是什么,均不允许重排序。即其保证了 volatile 变量写之前的操作不会被重排序到 volatile 变量写之后
- 当第一个操作是 volatile 变量写、第二个操作是 volatile 变量读时,不允许重排序
在 JDK1.5 之前的 Java 内存模型中,虽然不允许 volatile 变量之间进行重排序,但却允许普通变量与 volatile 变量之间的重排序。所以在 JSR 133 中对 volatile 变量的内存语义进一步增强,即限制了普通变量与 volatile 变量之间是否可以重排序的具体场景。这也是为什么在 JDK 1.5 之前无法通过 DCL (Double-checked locking, 双锁检查锁) 实现一个线程安全的单例模式
1 | public class Singleton { |
在 instance = new Singleton () 过程中,其大致可分为三步:
- 分配内存空间
- 通过构造器进行对象初始化
- 将 volatile 变量 instance 指向相应的内存空间
按理说正常的执行顺序应该是 1->2->3,但是在 JDK 1.5 之前,2 和 3 可能会发生重排序,即可能出现 1->3->2 这样的执行顺序。当线程 A 按此顺序 (1->3->2) 执行完成 3 (此时 instance 不为 null 了) 正准备执行 2 时,恰好此时另外一个线程 B 调用了 Singleton.getInstance () 方法,由于此时 instance 已经不为 null 了,线程 B 就会拿到一个未进行初始化的对象,从而引发了错误,即所谓的安全发布失败。而在 JSR 133 中,Java 内存模型对 volatile 变量与普通变量之间重排序的规则进行了完善,故可以保证在 JDK 1.5 及其之后的版本中,2 和 3 不会发生重排序。即保证了基于 DCL 的单例模式是线程安全的
参考文献
- Java 并发编程之美 翟陆续、薛宾田著
- 深入理解 Java 虚拟机・第 2 版 周志明著
- JSR-133: Java Memory Model and Thread Specification
v1.5.2