浅谈volatile关键字

浅谈volatile关键字

在我们编写软件程序然后编译或者执行的过程中,编译器或者处理器会为我们的软件进行各种优化,在一般情况下,这种优化都是有益的,不过有些时候可能会出现一些奇奇怪怪的问题。

而缓存和重排序就是JAVA中常见的优化,但在并发场景下,这些优化也会出现问题,而volatile关键字就是JAVA中提供的一种解决可见性和有序性问题的方案。

1 共享多核处理器

在开始讲volatile关键字之前,我们先来了解一下共享多核处理器的架构。

我们知道CPU作用是负责执行程序指令,所以,他们需要从内存中检索程序指令和所需要的数据;

但是由于CPU处理程序指令的速度非常快,导致从内存中读取数据的速度赶不上CPU处理指令的速度;

所以为了改善这种情况,CPU就需从更高速的存储介质中读取数据,也就是缓存。

而引入缓存之后,随之带来的就是缓存一致性问题。

比如从上面的架构图中可以看到缓存分为三个等级L1、L2和L3,L1分为数据缓存和指令缓存,每个核心分别有一个L1和L2缓存,而L3缓存被多个核心共享;而在多线程的情况下,如果其中一个核心更新了L1中某个变量的值,但是另一个核心读取到的还是更新之前的值,就会造成缓存不一致的现象,而在JAVA程序中,出现这种情况我们就需要用到volatile关键字。

2 如何使用volatile关键字

为了更加直观的了解volatile关键字,我们引入下面的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class TaskRunner {

private static int number;
private static boolean ready;

private static class Reader extends Thread {

@Override
public void run() {
while (!ready) {
Thread.yield();
}

System.out.println(number);
}
}

public static void main(String[] args) {
new Reader().start();
number = 7;
ready = true;
}
}

如上所示,我们创建一个线程类,在线程类中,会先读取ready的值,如果为否,则会让渡CPU时间片,如果为真,就会打印出number的值。

如果所有指令全都顺序执行,这个程序应该会在短暂的延迟之后打印7这个结果。但是实际情况可能是,程序挂起的时间非常长,或者永远挂起,也有可能打印出0这个结果。

2.1 内存可见性

从上面的例子可以看到,我们创建了两个线程,一个是主线程,还有一个是主线程开启的线程,而在操作系统执行上面的程序时,可能会将这两个线程分配到不同的核心或者CPU上执行。

而在这种情况下,主线程会在它和核心缓存中创建这两个变量的副本,副线程也会在其核心缓存中创建副本,而主线程在执行完成后才会将这两个变量的值更新到主存中。

在大多数处理器中,写指令不会立即执行,而是将这些指令写入到缓冲区中,然后一段时间后,再将其写入主存中。

所以当主线程更新number和ready后,副线程可能会立即读取到更新之后的值,也有可能读取不到。

2.2 指令重排序

为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入的代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果重组,并确保这一结果和顺序执行结果是一致的,但是这个过程并不保证各个语句计算的先后顺序和输入代码中的顺序一致,这就是指令重排序。

1
2
3
new Reader().start();
number = 7;
ready = true;

比如上面的这三行代码,处理器会在保证当前线程最终结果不变的情况下,乱序执行上面的代码。

2.3 添加volatile关键字

所以为了保证变量在其他线程中的可见性、以及指令执行的顺序,我们就需要用volatile来修饰变量

1
2
private static volatile int number;
private static volatile boolean ready;

这样,我们在执行程序时,处理器就不会对涉及volatile的指令进行重排序;而且,处理器也会立即将变量更新之后的值刷新到主存中。

3 volatile关键字是如何实现的

3.1 可见性实现

通过上面的研究,我们已经知道了线程本身不会直接和主存进行数据交互,而是通过高速缓存作为中间层来完成相应的操作。而这也是导致线程之间数据不可见的原因,所以要实现volatile变量的可见性,我们可以从一下这两个方式入手:

  • 在修改完volatile修饰的变量后,强制将修改后的值推送到主存中
  • 在修改volatile变量时,让其他线程缓存中的对应变量失效,从而让其他线程强制读取主存中的值

3.2 有序性实现

在了解有序性实现的原理之前,我们先来了解一下JAVA中的Happens-Before规则,通过JSR-133中的规则:

通过上面的JAVA内存模型中关于重排序的规定,我们可以总结出下面的表格:

是否允许重排序 普通读/普通写 volatile读 volatile写
普通读/普通写 不允许
volatile读 不允许 不允许 不允许
volatile写 不允许 不允许

Tips: 第一列是第一项操作,后面三列是第二项操作

3.3 内存屏障

看到这里你可能会有点费解,上面的这几条规定是以什么为依据总结出来的,这个时候我们就需要引入内存屏障的概念。

内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。

而内存屏障主要分为如下几个类型:

  • LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  • LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
  • StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。

而通过上面的规则,我们可以总结出下面的表格:

内存屏障类型 普通读 普通写 volatile读 volatile写
普通读 LoadStore
普通写 StoreStore
volatile读 LoadLoad LoadStore LoadLoad LoadStore
volatile写 StoreLoad StoreStore

Tips: 第一列是第一项操作,后面四列是第二项操作

4 总结

从上面的研究中,我们了解的volatile关键字的应用场景以及实现原理,看似简单的volatile关键字,其实在底层基于内存屏障实现了许多操作;

但是否在所有需要可见性和有序性的情况下使用volatile都是最优解呢,其实不然,随着CPU架构的不断迭代,有些架构的CPU已经实现了一些防止重排序的规则,具体可以参考AMD64架构手册,这时我们就可以在某些场景下去除volatile关键字,从而提升运行效率。

作者

ero

发布于

2022-02-20

更新于

2022-06-11

许可协议

评论