volatile 关键字可以说是 Java 虚拟机提供的最轻量级的同步机制,但是他并不容易完全被正确、完整的理解,以至于许多程序员都习惯不去使用它,遇到需要处理多线程数据竞争问题的时候一律使用 synchronized 来进行同步。了解 volatile 变量的语义对后面了解多线程操作的其他特性很有意义。
volatile 关键字具备两种特性
保证此变量对所有线程的可见性
- 第一条特性保证所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,
新值对于其他线程来说是可以立即得知的
。 - 被 volatile 修饰的元数据在线程操纵它们的时候 JMM 有两个执行机制(load、store)可以保证元数据在内存中对所有线程的可见性
- load 机制:在每次有线程读取元数据的时候 JMM 都会执行load 操作把当前将要被操纵的元数据的最新值更新到内存中
- store 机制:线程每次操作完元数据之后 JMM 都会执行 store 操作把当前操纵的元数据更新到内存中
- 而普通变量不能做到这一点,普通变量的值在线程间传递均需要通过主内存来完成,例如,线程 A 修改一个普通变量的值,然后向主内存进行回写,另外一条线程 B 在线程 A 回写完成了之后再从主内存进行读取操作,新变量值才会对线程 B 可见。
- 关于 volatile 变量的可见性,经常会被开发人员误解,认为以下描述成立:
volatile 变量对所有线程立即可见,对 volatile 变量所有的写操作都能立刻反应到其他线程之中,换句话说,volatile 变量在各个线程中是一致的,所以基于 volatile 变量的运算在并发下是安全的。
- 这句话的论据没有错,但是其论据并不能得出“基于 volatile 变量的运算在并发下是安全的”这个结论。volatile 变量在各个线程的工作内存中不存在一致性问题(在各个线程的工作内存中,volatile 变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,一次可以认为不存在一致性问题),
但是 Java 里面的运算并非原子操作,导致 volatile 变量的运算在并发下一样是不安全的
,我们可以通过一段简单的演示来说明原因:
public class VolatileTest {
public static volatile int race = 0;
public static void increase() {
race++;
}
private static final int THREADS_COUNT = 20;
@Test
public void demo() {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0; i < THREADS_COUNT; i++) {
threads[i] = new Thread(
() -> {
for (int j = 0; j < 10000; j++) {
increase();
}
}
);
threads[i].start();
}
//等待所有累加线程都结束
while (Thread.activeCount() > 1) {
Thread.yield();
}
System.out.println(race);
}
}
- 这段代码发起了 20 个线程,每个线程对 race 变量进行 10000 次自增操作,如果这段代码能够正确并发的话,最后结果应该是 200000。但是实际上结果都是一个小于 200000 的数字。
- 由于 volatile 变量只能保证可见性,在不符合以下两条规则的运算场景中,我们
仍然要通过加锁来保证原子性。
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
- 变量不需要与其他的状态变量共同参与不变约束。
- 比如说以下代码的场景就很适合使用 volatile 变量来控制并发,当 shutdown() 方法被调用时,能保证所有线程中执行的 doWork() 方法都立即停下来。
volatile boolean shutdownRequested;
public void shutdown() {
shutdownRequested = true;
}
public void doWork() {
while (!shutdownRequested) {
// do stuff
}
}
禁止指令重排序优化
使用 volatile 变量的第二个语义是禁止指令重排序优化,普通变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致
。因为在一个线程的方法执行过程中无法感知到这点,这也就是 Java 内存模型中描述的所谓的“线程内表现为串行的语义”,我们可以通过一段代码来理解。
Map configOptions;
char[] configText;
//此变量必须定义为volatile
volatile boolean initialized = false;
//假设以下代码在线程A中执行
//模拟读取配置信息,当读取完成后将initialized设置为true以通知其他线程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
//假设以下代码在线程B中执行
//等待initialized为true,代表线程A已经把配置信息初始化完成
while(!initialized){
sleep();
}
//使用线程A中初始化好的配置信息
- 这是一段伪代码,这里如果定义 initialized 变量时没有使用 volatile 修饰,就可能会由于指令重排序的优化,导致位于线程 A 中最后一句的代码 “initialized=true” 被提前执行,这样在线程 B 中使用配置信息的代码就可能出现错误,而
volatile 关键字则可以避免此类情况的发生
。
实现原理
volatile 内存屏障,分为两种:
Load Barrier 读屏障
- 在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制重新从主内存加载数据;
Store Barrier 写屏障
- 利用缓存一致性机制强制将对变量的修改操作立即写入主内存,并且让其他线程缓存中变量失效,并且缓存一致性机制会阻止同时修改由两个以上CPU缓存的内存区域数据。
内存屏障的作用:
- 确保指令重排序时不会把屏障后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障后面。
- 强制把写缓冲区/高速缓存中的数据等写回主内存,让缓存中相应的数据失效;
使用 volatile 的意义
- 它能让我们的代码比使用其他的同步工具更快吗?在某些情况下,volatile 的同步机制的性能确实要优于锁,但由于虚拟机对锁实行的许多消除和优化,使得我们很难量化地认为 volatile 就会比 synchronized 快多少。如果让 volatile 变量与普通变量比较,那可以确定一个原则:volatile 变量
读操作的性能消耗与普通变量几乎没有什么差别
,但是写操作则可能会慢一些,因为他需要在本地代码中插入许多内存屏障指令
来保证处理器不发生乱序执行。不过即便如此,大多数场景下 volatile 的总开销仍然要比锁低,我们在 volatile 与锁之间选择的唯一依据仅仅是volatile 的语义能否满足使用场景的需求
。
总结
- volatile 保证对所有线程的可见性,但是不能得出“基于 volatile 变量的运算在并发下是安全的”结论。
- volatile 禁止指令重排序优化。