Java 并发编程 ⑤ - volatile 关键字

Richard_Yi 2020年05月22日 186次浏览

往期文章:

开篇

在开始之前,想先普及下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中有很多很多的例子,很经典的就是AtomicIntegerLongAdder之类的原子类。

这部分更多的内容会在CAS一节中讲。

public class AtomicInteger {
 	// ...
   	private static final Unsafe unsafe = Unsafe.getUnsafe();
    // ...
    private volatile int value;
    // ...
}

在实际业务中 ,如何清晰地判断一写多读的场景显得尤为重要。如果不确定共享 变量是否会被多个线程并发写 ,保险的做法是使用同步代码块来实现线程同步。另外,因为所有的操作都需要同步给内存变量,所以 volatile 一定会使线程的执行速度变慢,故要审慎定义和使用 volatile 属性。

volatile 之于 有序性

volatile 另外一个用途就是 能产生内存屏障,禁止指令重排序。重排序的相关知识我们在上一节中有讲述过。这里就不赘述了。

volatile 具体怎么实现的呢?前面也说了,是内存屏障的方式。

那么什么是内存屏障呢?内存屏障是基于特定硬件的,具体展开来非常的复杂。简单来说,内存屏障分两种:读屏障和写屏障。内存屏障有两个作用:

  1. 阻止屏障两侧的指令重排序;

  2. 强制把写缓冲区/高速缓存中的脏数据等写回主内存,或者让缓存中相应的数据失效。

    这里的缓存主要指的是CPU缓存,如L1,L2等

这里先给出JVM中提供的四类内存屏障指令:

屏障类型指令示例说明
LoadLoadLoad(A) ->LoadLoad ->Load(B)保证load(A)的读取操作在load(B)之前执行
StoreStoreStore(A)-> StoreStore->Store(B)保证在执行Store(B)之前,Store(A)的写操作已经刷新到主内存中
LoadStoreLoad(A)->LoadStore->Store(B)保证在执行Store(B)之前,Load(A)已经读取结束
StoreLoadStore(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件事情:

  1. 给instance 对象分配内存
  2. 调用Singleton 的构造函数来初始化成员变量
  3. 将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。

参考文章