
往期文章:
- Java 并发编程基础 ① - 线程
- Java 并发编程 ② - 线程生命周期与状态流转
- Java 并发编程 ③ - ThreadLocal 和 InheritableThreadLocal 详解
- Java 并发编程 ④ - Java 内存模型
开篇
在开始之前,想先普及下volatile的发音哈,因为我身边的同事们好像没有读准的,所以想先普及一下。
美音音标是[ˈvɑlətl],可不要读错了哦。可惜不能发语音,不然肯定让你们听听我的标准读音(🐶狗头保命)。
好了,皮完了,接下开始进入正式的内容。
volatile 是Java 并发编程中很重要的一个知识点,应该也是普通Java面试常常会考察的点。今天我们的切入点就从面试切入。
一般面试官会提问的套路就是:
“我看你写着熟悉并发编程,那你知道volatile 吗,作用是什么?”
这个时候,熟悉volatile 的人应该能脱口而出volatile 的作用:
- 保证共享变量的内存可见性
- 能产生内存屏障,禁止指令重排序
PS:如果是要面试的小伙伴,注意熟记这两点哦。
在上一节内容Java 内存模型中我们就讲到过并发编程中的三大特性:
- 可见性 - Visibility
- 原子性 - Atomicity
- 有序性 - Ordering
那篇文章中也提到了,volatile 可以解决可见性问题和有序性问题。
下面我们就按照这个思路下来,讲讲volatile 是解决这两个问题的具体细节。
volatile 之于 内存可见性
前面“科普”了volatile 的发音,现在我再来科普下volatile 的英文本义,即 “挥发、不稳定的” ,延伸意义为敏感的。
当使用 volatile 修饰变量时,这个变量就会变得很“敏感”,每个相关的线程都密切关注它,它的喜怒哀乐对其他线程马上可见,具体表现在:
当一个变量被声明为volatile 时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存。当其他线程读取该共享变量时,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值。
volatile 常会和synchronized 放在一起对比讨论,因为volatile 的内存语义和synchronized 有相似之处,具体来说就是,当线程写入了volatile 变量值时就等价于线程退出synchronized 同步块(把写入工作内存的变量值同步到主内存),读取volatile 变量值时就相当于进入同步块(先清空本地内存变量值,再从主内存获取最新值)。
底层原理
volatile 的底层原理对于本文来说略微超纲,这里就抛砖引玉,放上方腾飞大大的一篇文章:深入分析 Volatile 的实现原理。有兴趣的读者可以去学习一下。这篇文章深入分析了硬件层面上处理器是如何实现Volatile 的。
实际应用
有些地方会说 volatile 是一种轻量级的同步方式,实际上这里指的是它对于内存可见性的作用。如果要更准确的表达的话,volatile 应该成为是轻量级的线程操作可见方式。如果是在多写场景下的话,他并不能提供所谓的“同步”功能,还是会产生原子性的问题。
但是,如果是一写多读的场景,使用volatile 会变得十分的合适,在保证内存可见性的同时,不会像synchronized 那样会引起线程上下文的切换和调度(独占锁,会阻塞其他线程),相较起来使用和执行成本会更低。
volatile 写多读少,典型的应用是 CopyOnWriteArrayList
。它在修改数据时会把整个集合的数据全部复制出来, 对写操作加锁,修改完成后, 再用 setArray()
把 array 指向新的集合。使用 volatile 可以使读线程尽快地感知 array 的修改, 不进行指令重排,操作后即对其他线程可见。
源码大致如下:
public class CopyOnWriteArrayList<E> {
private transient volatile Object[] array;
final transient ReentrantLock lock = new ReentrantLock();
public E set(int index, E element) {
final ReentrantLock lock = this.lock;
lock.lock();
//...
}
final void setArray(Object[] a) {
array = a;
}
}
另外,再说一句,单独的volatile 不能保证原子性,但是当它配合上CAS 之后,就能实现无锁的同步(乐观锁方式)
这种方式,在JUC
中有很多很多的例子,很经典的就是AtomicInteger
、LongAdder
之类的原子类。
这部分更多的内容会在CAS一节中讲。
public class AtomicInteger {
// ...
private static final Unsafe unsafe = Unsafe.getUnsafe();
// ...
private volatile int value;
// ...
}
在实际业务中 ,如何清晰地判断一写多读的场景显得尤为重要。如果不确定共享 变量是否会被多个线程并发写 ,保险的做法是使用同步代码块来实现线程同步。另外,因为所有的操作都需要同步给内存变量,所以 volatile 一定会使线程的执行速度变慢,故要审慎定义和使用 volatile 属性。
volatile 之于 有序性
volatile 另外一个用途就是 能产生内存屏障,禁止指令重排序。重排序的相关知识我们在上一节中有讲述过。这里就不赘述了。
volatile 具体怎么实现的呢?前面也说了,是内存屏障的方式。
那么什么是内存屏障呢?内存屏障是基于特定硬件的,具体展开来非常的复杂。简单来说,内存屏障分两种:读屏障和写屏障。内存屏障有两个作用:
-
阻止屏障两侧的指令重排序;
-
强制把写缓冲区/高速缓存中的脏数据等写回主内存,或者让缓存中相应的数据失效。
这里的缓存主要指的是CPU缓存,如L1,L2等
这里先给出JVM中提供的四类内存屏障指令:
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad | Load(A) ->LoadLoad ->Load(B) | 保证load(A)的读取操作在load(B)之前执行 |
StoreStore | Store(A)-> StoreStore->Store(B) | 保证在执行Store(B)之前,Store(A)的写操作已经刷新到主内存中 |
LoadStore | Load(A)->LoadStore->Store(B) | 保证在执行Store(B)之前,Load(A)已经读取结束 |
StoreLoad | Store(A)->StoreLoad->Load(B) | 保证Load(B)读操作之前,Store(A)的写操作已经刷新到主内存 |
熟悉了上面的内存屏障的内存语义之后,再来看看volatile 的内存屏障插入策略是怎么样的呢。这里用我们上面举的A、B两个变量做例子,其中B为volatile 修饰的变量。
这个策略是:
- 在每个volatile 写操作前插入一个StoreStore 屏障;
- 在每个volatile 写操作后插入一个StoreLoad 屏障;
- 在每个volatile 读操作后插入一个LoadLoad 屏障;
- 在每个volatile 读操作后再插入一个LoadStore 屏障。
到这里相信你已经明白了volatile 禁止重排序的原理细节。
实际应用
这里举一个重排序应用的实际例子,也就是我们熟悉的单例模式。
单例模式中有一种写法是双重检查DCL,代码如下:
public class Singleton {
private volatile static Singleton singleton;
private Singleton() {
}
public static Singleton getSingleton() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
这里的volatile 很关键,在这里的作用就是防止指令重排序。
代码中,singleton = new Singleton()
主要做了3件事情:
- 给instance 对象分配内存
- 调用Singleton 的构造函数来初始化成员变量
- 将instance 对象指向分配的内存空间(注意,做这一步之后,
instance != null
)
由于指令重排序优化的存在,step2 和 step3 的顺序是不能保证的。最终的执行顺序可能是 1-2-3
也可能是 1-3-2
。如果是后者,想象一下,如果线程A执行完step3 时,线程B抢占了CPU,按照1-3-2
的执行顺序,step3 执行之后singleton != null
,那个线程B执行getSingleton()
会直接返回未初始化的成员变量,使用就可能会报错。
小结
这里对volatile 总结一下:
- volatile 可以保证共享变量的可见性,线程在写入变量时总是会把值刷新回主内存;当其他线程读取该共享变量时,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值。
- volatile 在保证内存可见性的同时,不会像synchronized 那样会引起线程上下文的切换和调度,相较起来使用和执行成本会更低。在一写多读的场景下尤为合适。
- volatile 无法提供原子性的保证,但是配合CAS 可以实现无锁的同步,在并发包的源码中,使用得非常多。
- volatile 可以使得 long 和 double 的赋值是原子的。
- 普通变量被声明为volatile 变量之后,所有的操作都需要同步给内存变量,所以volatile 会使线程的执行速度变慢,故要审慎定义和使用volatile。