关键字 volatile 可以说是 Java 虚拟机提供的最轻量级的同步机制,volatile 为实例字段的同步访问提供了一种免锁机制。当一个变量被定义成 volatile 之后,编译器和虚拟机就知道该字段可能被另一个线程并发更新了。为了确保这个变量被修改后,应用程序范围内的所有线程都能够看到这个改动,虚拟机就必须采用一些特殊的手段来保证这个变量的可见性等特点。
如果被修饰的变量是个数组,那么 volatile 关键字只能够对数组引用本身的操作(读取数组引用和更新数组引用)起作用,而无法对数组元素的操作(读取、更新数组元素)起作用。如果要使对数组元素的读、写操作也能够触发 volatile 关键字的作用,那我们可以使用 AtomicIntegerArray、AtomicLongArray 等原子操作类。
类似地,对于引用型 volatile 变量,volatile 关键字只是保证该线程能够读取到一个指向对象的相对新的内存地址(引用),而这个内存地址指向的对象的实例变量、静态变量值是否是相对新的则没有保障。
volatile 语义
1. 可见性
第一项是保证此变量对所有线程的可见性,这里的 “可见性” 是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量并不能做到这一点,普通变量的值在线程间传递时均需要通过主内存来完成。比如,线程 A 修改一个普通变量的值,然后向主内存进行回写,另外一条线程 B 在线程 A 回写完成了之后再对主内存进行读取操作,新变量值才会对线程 B 可见。
因此,volatile 变量在各个线程的工作内存中是不存在一致性问题的。因为从物理存储的角度看,各个线程的工作内存中 volatile 变量也可以存在不一致的情况,但由于每次使用前都要先从主内存中刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题。
如果 volatile 变量修饰符使用恰当的话,它比 synchronized 的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。
2. 非原子性
volatile 解决的是多线程共享变量的可见性问题,这一点类似于 synchronized,但不具备 synchronized 的互斥性,所以对 volatile 变量的操作并非都具有原子性。比如经典的 i++ 问题,虽然只有一行代码,但也并非是原子操作,而这会导致 volatile 变量的运算在并发下一样不是线程安全的。
public class VolatileTest {
public static volatile int race = 0;
public static void increase() {
race++;
}
private static final int THREAD_COUNT = 20;
private static final CountDownLatch countDownLatch = new CountDownLatch(THREAD_COUNT);
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[THREAD_COUNT];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
increase();
}
countDownLatch.countDown();
});
threads[i].start();
}
// 等待所有累加线程都结束
countDownLatch.await();
System.out.println(race);
}
}
这段代码发起了 20 个线程,每个线程对 race 变量进行 10000 次自增操作,如果这段代码能够正确并发的话,最后输出的结果应该是 200000。但我们编译执行后会发现每次输出的都是一个小于 200000 的数字。这是为什么呢?
问题就出在自增运算 “race++” 之中,我们用 Javap 反编译这段代码后得到下图的字节码指令,发现只有一行代码的 increase() 方法在 Class 文件中是由 4 条字节码指令构成(return 指令不是由 race++ 产生的,这条指令可以不计算),现在从字节码层面上已经很容易分析出并发失败的原因了:
- 当 getstatic 指令把 race 的值取到操作栈顶时,volatile 关键字保证了 race 的值在此时是正确的
- 但在执行 iconst _1、iadd 这些指令时,其他线程可能已经把 race 的值改了,而操作栈顶的值就变成了过期的数据
- 此时 putstatic 指令执行后就可能把较小的 race 值同步回主内存中。
由于 volatile 变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(通过使用 synchronized、java.util.concurrent 中的锁或原子类)来保证原子性:
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
- 变量不需要与其他的状态变量共同参与不变约束。
3. 禁止指令重排序优化
使用 volatile 变量的第二个语义是禁止指令重排序优化,普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因为在同一个线程的方法执行过程中无法感知到,这就是 Java 内存模型中描述的 “线程内表现为串行的语义”。实现机制
1. 写操作
对 volatile 变量的写操作,JVM 会在该操作之前插入一个释放屏障,并在该操作之后插入一个存储屏障。
其中,释放屏障禁止了 volatile 写操作与该操作之前的任何读、写操作进行重排序,从而保证了 volatile 写操作之前的任何读、写操作会先于 volatile 写操作被提交,即其他线程看到写线程对 volatile 变量的更新时,写线程在更新 volatile 变量之前所执行的内存操作的结果对于读线程必然也是可见的。这就保障了读线程对写线程在更新 volatile 变量前对共享变量所执行的更新操作的感知顺序与相应的源代码顺序一致,即保障了有序性。2. 读操作
对 volatile 变量的读操作,JVM 会在该操作之前插入一个加载屏障,并在该操作之后插入一个获取屏障。
其中,加载屏障通过冲刷处理器缓存,使其执行线程(读线程)所在的处理器将其他处理器对共享变量(可能是多个变量)所做的更新同步到该处理器的高速缓存中。读线程执行的加载屏障和写线程执行的存储屏障配合在一起使得写线程对 volatile 变量的写操作以及在此之前所执行的其他内存操作的结果对读线程可见,即保障了可见性。
因此 volatile 不仅保障了 volatile 变量本身的可见性,还保障了写线程在更新 volatile 变量之前执行的所有操作的结果对读线程可见。这种可见性保障类似于锁对可见性的保障,与锁不同的是 volatile 不具备排他性,因而它不能保障读线程读取到的这些共享变量的值是最新的,即读线程读取到这些共享变量的那一刻可能已经有其他写线程更新了这些共享变量的值。
另外,获取屏障禁止了 volatile 读操作之后的任何读、写操作与 volatile 读操作进行重排序。因此它保障了 volatile 读操作之后的任何操作开始执行之前,写线程对相关共享变量(包括 volatile 变量和普通变量)的更新已经对当前线程可见。
3. lock 指令
由于 x86 处理器仅支持 StoreLoad 重排序,因此在 x86 处理器下,Java 虚拟机会将 LoadLoad 屏障、 LoadStore 屏障以及 StoreStore 屏障映射为空指令。即 x86 处理器下的 Java 虛拟机无须在 volatile 读操作前、volatile 读操作后以及 volatile 写操作前插入任何指令,而只需要在 volatile 写操作后插入一个 StoreLoad 屏障,这个屏障在 Hotspot 虚拟机中是由一个 Lock 前缀的空操作指令充当的。在其他处理器下,Java 虛拟机则可能根据相应处理器对重排序的支持情况在 volatile 读、写前后的相应地方插入相应指令。
下面代码示例展示了一段标准的双锁检测单例代码,可以观察下加入 volatile 和未加入 volatile 关键字时所生成的汇编代码的差别:
public class Singleton {
private volatile static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
对其进行反编译后,查看其汇编代码,可以看到对 instance 变量赋值的部分如下图所示:
通过对比发现 volatile 修饰的变量,在赋值后多执行了一个 lock addl $0x0,(%rsp) 操作,这个指令的作用就相当于一个 内存屏障(memory barrier),该内存屏障将不允许 volatile 字段写操作之前的内存访问被重排序至其之后;也不允许 volatile 字段读操作之后的内存访问被重排序至其之前。只有一个处理器访问内存时,并不需要内存屏障;但如果有多个处理器访问同一块内存且其中有一个在观测另一个时,就需要内存屏障来保证一致性了。
lock addl $0x0,(%rsp) 中的 addl $0x0,(%rsp)(把 rsp 寄存器的值加 0)显然是一个空操作,之所以用这个空操作而不是空操作专用指令 nop,是因为规定 lock 前缀不允许配合 nop 指令使用。实际上,这里的关键还是在于 lock 前缀,它的作用是将当前处理器缓存行的数据写回到系统内存,该写回动作也会引起别的处理器或者别的内核无效化其缓存,这种操作相当于对缓存中的变量做了一次 JMM 中所说的 store 和 write 操作。所以通过这样一个空操作可以让前面 volatile 变量的修改对其他处理器立即可见。
那为何说它禁止指令重排序呢?
从硬件架构上讲,指令重排序是指处理器采用了允许将多条指令不按程序规定的顺序分开发送给各个相应的电路单元进行处理。但并非指令可以任意重排,处理器必须正确处理指令间的依赖以保障程序执行得到正确结果。所以在同一个处理器中,重排序过的代码看起来依然是有序的。因此 lock addl $0x0,(%rsp) 指令把变量更新的结果同步回内存时,意味着所有之前的操作都已经执行完成,这便形成了【指令重排序无法越过内存屏障】的效果。
总结
解决了 volatile 的语义问题,再来看看在众多保障并发安全的工具中选用 volatile 的意义——它能让我们的代码比使用其他的同步工具更快吗?
在某些情况下,volatile 的同步机制的性能确实要优于锁,因为 volatile 变量的读、写操作都不会导致上下文切换。但由于虚拟机对锁实行的许多优化,使得我们很难确切地说 volatile 就会比 synchronized 快上多少。
volatile 变量的开销包括读变量和写变量两个方面。写一个 volatile 变量会使该操作以及该操作之前的任何写操作的结果对其他处理器是可见的,因此 volatile 变量写操作的成本介于普通变量的写操作和在临界区内进行的写操作之间。读取 volatile 变量的成本也比在临界区中读取变量要低(因为没有锁的申请与释放以及上下文切换的开销),但其成本可能比读取普通变量要高一些。因为 volatile 变量的值每次都需要从高速缓存或者主内存中读取,而无法被暂存在寄存器中,从而无法发挥访问的高效性。
因此,大多数场景下 volatile 的总开销仍然要比锁来得更低。我们在 volatile 与锁中选择的唯一判断依据仅仅是 volatile 的语义能否满足使用场景的需求。