Java Core 「3」volatile 关键字

253 阅读3分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第13天,点击查看活动详情

当我们提到 volatile 关键字的作用时,想到的是可见性、原子性、禁止重排序。

01-可见性

可见性问题指一个线程修改了共享变量的值,而另外一个线程却看不到。造成这个问题的原因是线程中存在一个高速缓存区(working memory)。

我们从 Java Memory Model(JMM)和硬件角度分析下可见性出现的原因。

Untitled.png

图1.JMM 与 硬件架构之间的关系(右图来自于 jenkov.com

线程中的 working memory 对应的是计算机硬件中的 CPU cache memory,用来解决内存与 CPU 之间访问速度的差异,提高运算速度。

在图1.的基础上,我们举例说明为什么会出现可见性问题?假设主存中存在一个变量obj.count,它的当前值为1。

  • 线程 A 在访问该变量时,会将其载入到自己的 working memory。随后,如果修改该变量的值,也并不会立刻写回到主存中(写回时机由操作系统控制)。
  • 假设线程 A 将obj.count的值修改为2,而 CPU cache memory 又恰巧尚未写回到主存,那么线程 B 此时从主存中读取的obj.count的值就仍然是1。

上面这个过程我们可以通过一个程序来验证下:

public class VolatileExamples {
    private int a = 1;
    private int b = 2;

    public void change() {
        this.a = 3;
        this.b = a;
    }

    public void lookup() {
        System.out.printf("a = %s, b= %s%n", a, b);
    }

    public static void main(String[] args) {
        while (true) {
            final VolatileExamples example = new VolatileExamples();
            new Thread() {
                @Override
                public void run() {
                    try {
                        TimeUnit.MILLISECONDS.sleep(10);
                    } catch (InterruptedException ignored) {}
                    example.change();
                }
            }.start();

            new Thread() {
                @Override
                public void run() {
                    try {
                        TimeUnit.MILLISECONDS.sleep(10);
                    } catch (InterruptedException ignored) {}
                    example.lookup();
                }
            }.start();
        }
    }
}

运行足够时间后,输出中会有如下的结果:

...
a = 1, b= 3        // 这里
a = 3, b= 3
a = 1, b= 2
a = 1, b= 2
...

了解到可见性问题产生的原因,我们来看一下 volitale 是如何实现可见性的。volatile 指令实际上是通过 JVM lock指令添加内存屏障实现可见性的[1]。

lock 前缀的指令在多核处理器下会引发两件事情:

  1. 将当前处理器缓存行的数据写回到系统内存。
  2. 写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效。

[1] Java Memory Model

[2] Threads and Locks

02-原子性

volatile 是无法保证i++操作的原子性的,因为i++是一个复核操作,包含了:1)读取 i 的值;2)对 i 执行加1操作;3)将 i 的值写回内存。

但是对于 double 和 long 类型的变量,是鼓励使用 volatile 修饰的。因为 JLS 中解释:

Writes and reads of volatile long and double values are always atomic.

如果不使用 volatile ,在图1.中的主存与 working memory 之间的 read/write 和 load/store 操作都是将其当作是两个对立的32位变量来对待。

不过,现在 JVM 普遍都将64位数据的读写当作是原子操作。一般情况下,不使用 volatile 修饰 long 或 double 变量也不会出错。

03-有序性

JLS 中关于 volatile 变量有一条 happens-before 规则:

A write to a volatile field happens-before every subsequent read of that field.

从前面的章节中了解到,volatile 实现可见性是通过插入内存屏障。内存屏障还有一个其他的作用就是,禁止指令重排序。

Untitled 1.png

04-volatile 的应用场景

在单例模式中,单例对象一般会被 volatile 修饰。例如:

public class Singleton {

    public static volatile Singleton singleton;

    private Singleton() {
    }
    
    public static Singleton getInstance() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

这里使用 volatile 的主要原因是防止指令重排序。因为,singleton = new Singleton();是一个复合操作,它包括:

  • 分配内存空间
  • 初始化对象
  • 将对象地址的引用赋值给singleton

禁止指令重排序是为了避免对象在初始化之前被返回。


历史文章