Java Volatile Keyword

这几天学习 Java 内存模型,查看文章:JSR 133 (Java Memory Model) FAQ

里面介绍了新的 Java 内存模型对 volatile 关键字的修订,因为只是一个 FAQ,并没有很详细的解析 volatile 关键字的用法,找到一篇文章

Java Volatile Keyword

详细的介绍了 volatile 适用的场景以及不适用的场景,翻译一下


主要内容:

  1. 引言
  2. 变量可见性问题(Variable Visibiltiy Problems
  3. Java volatile 可见性保证(The Java volatile Visibility Guarantee
  4. 指令重排序挑战(Instruction Reordering Challenges
  5. Java volatile Happens-before 保证(The Java volatile Happens-before Guarantee
  6. volatile 并不总是足够(volatile is Not Always Enough
  7. 什么情况下 volatile 就足够了?(When is volatile Enough?
  8. volatile 性能考虑(Performance Considerations of volatile

引言

Java 关键字 volatile 被用于标记一个 Java 变量,表示 “存储在主内存”。更精确的说,对 volatile 变量的每一次读都来自于主内存而不是缓存;对 volatile 变量的每一次写也将会刷新到主内存,而不是保存在缓存中。

事实上,自从 Java 5 以来,volatile 关键字有了更多的作用,不仅仅是对 volatile 变量的读写都来自内存。我将在接下来的章节介绍。


变量可见性问题

Java volatile 关键字保证了线程之间对变量改变的可见性。这听起来有点抽象,让我来详细说明。

在多线程应用中,因为性能关系,线程操作的非 volatile 变量都会从主内存复制到缓存中。如果是多 CPU 机器,那么每个线程运行在不同的 CPU 上。这样的话,每个线程都会复制变量到不同的 CPU 缓存中,如下图所示:

这里写图片描述

volatile 变量无法保证 JVM 何时从主内存读数据或者何时将缓存数据刷新回主内存。这将造成下面几个问题。

想象这种场景,多个线程访问同一个共享对象,该对象包含了一个计数器变量:

public class SharedObject {
    public int counter = 0;
}

如果只有线程 1 会对变量 counter 执行增量操作,线程 1 和 线程 2 偶尔会读取变量 counter

如果 counter 没有被定义为 volatile,那么无法保证写入的 counter 变量从缓存刷新回内存。这也意味着,缓存中的 counter 变量的值和主内存中的不一样。如下图所示:

这里写图片描述

因为变量的最新值没有被写入主内存,导致线程无法看见变量的最新值的问题称为可见性问题 - 即一个线程的更新对其它线程不可见。


Java volatile 可见性保证

Java volatile 关键字旨在解决变量可见性问题。通过声明变量 countervolatile,所有写入 counter 变量的数据将马上被刷新到主内存。同样的,所有读取 counter 变量的数据都来自主内存。

修改后的代码如下:

public class SharedObject {
    public volatile int counter = 0;
}

声明变量为 volatile,因此保证了线程对变量的可见性。

在上面的场景中,线程 T1 修改计数器然后线程 T2 读取计数器,声明 counter 变量为 volatile 足够保证 T2 对写入 counter 变量数据的可见性。

然而,如果 T1T2 同时对 counter 变量进行增量操作,那么仅仅声明 counter 变量为 volatile 是不够的。

完整的 volatile 可见性保证(Full volatile Visibility Guarantee

事实上,Java volatile 的可见性保证超出了 volatile 变量本身。可见性保证如下:

  1. 如果线程 A 写入变量 volatile,随后线程 B 读取相同的 volatile 变量,那么在写入 volatile 变量之前对线程 A 可见的变量,同样对读取 volatile 变量后的线程 B 可见;(If Thread A writes to a volatile variable and Thread B subsequently reads the same volatile variable, then all variables visible to Thread A before writing the volatile variable, will also be visible to Thread B after it has read the volatile variable.
  2. 如果线程 A 读取一个 volatile 变量,那么对线程 A 可见的变量在读取 volatile 变量的时候重新从主内存读取。(If Thread A reads a volatile variable, then all all variables visible to Thread A when reading the volatile variable will also be re-read from main memory.

通过一段代码验证:

public class MyClass {
    private int years;
    private int months
    private volatile int days;

    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

方法 update 更新了 3 个变量,其中变量 daysvolatile 的。

完整的 volatile 可见性保证的含义是当 days 写入一个值,那么所有对这个线程可见的变量都将写入主内存。所以,当 days 写入一个值的时候,变量 yearsmonths 的值都将写入主内存。

修改代码,加入读取函数:

public class MyClass {
    private int years;
    private int months
    private volatile int days;

    public int totalDays() {
        int total = this.days;
        total += months * 30;
        total += years * 365;
        return total;
    }

    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

注意,totalDays() 方法首先读取 days 的值到变量 total。当读取 days 值的时候,也会从主内存读取 monthsyears 的值。所以就能保证在上面读函数的中得到最新的 daysmonthsyears 的值。


指令重排序挑战

JVMCPU 可以通过重排序程序指令来提高性能,只要指令的语义仍旧保持不变。举个例子,看下面指令:

int a = 1;
int b = 2;

a++;
b++;

这些指令可以被重排序为下面序列,而且并没有失去程序语义:

int a = 1;
a++;

int b = 2;
b++;

然而,如果其中一个变量是 volatile,指令重排序将带来一个挑战。之前的示例代码如下:

public class MyClass {
    private int years;
    private int months
    private volatile int days;

    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

一旦方法 update 写入变量 days 一个值,那么新写入变量 yearsmonths 的值也将刷新回内存。但是,如果 JVM 重排序了指令如下:

public void update(int years, int months, int days){
    this.days   = days;
    this.months = months;
    this.years  = years;
}

monthsyears 的值仍旧会在变量 days 修改的时候写回主内存,但是这个动作发生在 monthsyears 修改之前。因此,新的值不能正确的显示给其它线程。这一次,重排序指令的语义改变了。

Java 针对这个问题提出一个解决方案,如下节所示。


Java volatile Happens-before 保证

为了解决指令重排序的挑战,Java volatile 关键字除了可见性保证外,还给予了一个 "happens-before" 保证。这个 happens-before 保证如下:

  1. 如果对其它变量的读写原先就发生在 volatile 变量的写操作之前,那么它们不会被重排序到 volatile 变量的写操作之后。在 volatile 变量的写操作之前的读写操作保证 happens-before 对这个 volatile 变量的读操作。注意,本身就在 volatile 变量写操作之后的读写操作仍旧有可能被重排序到之前执行。所以,在 volatile 变量读操作之后的读取操作重排序到之前执行是允许的,但是在 volatile 变量读操作之前的读取操作重排序到之后执行是不允许的。
  2. volatile 变量读操作之后的读取操作不允许重排序到之前发生。同样的,volatile 变量读操作之前的读取操作是可能被重排序到之后发生。

上面的 happens-before 保证确保了 volatile 关键字的可见性保证被强制执行。


volatile 并不总是足够

即使 volatile 关键字保证了所有对 volatile 变量的读写操作都从主内存读取数据,仍旧存在仅声明变量为 volatile 不能保证安全性的场景。

之前的场景中,只有线程 1 写入共享变量 counter,所以声明 counter 变量为 volatile 能够确保线程 2 总是看见最新的值。

在多线程读取和写入 volatile 变量的短时间间隔内会创造一个竞争条件,即多线程可能会读取到同一个 volatile 变量值,生成了一个变量的新值,当新值写回到主内存时会被重载。

当多线程对同一个计数器进行递增操作,那么仅靠一个 volatile 不能满足这种场景。下面小节将详细讨论这种情况。

想象一下,线程 1 读取了共享变量 counter 的值 0 到缓存,进行递增操作;在线程 1 将数据写回主内存之前线程 2 也读取了 counter,同样进行递增操作。如下图所示:

这里写图片描述

线程 12 实际上没有进行同步。变量 counter 值应该是 2,但是每个线程在缓存中的结果为 1,而内存中的值为 0。即使线程将数据写回到主内存,结果仍旧是错误的。

个人见解

上面这种情况表明 volatile 无法保证非原子性操作不会受到其他线程的干扰(同步能保证),所以,遇到这种情况应该进行同步操作。


什么情况下 volatile 就足够了?

将向我之前提到的,如果两个线程对一个共享变量进行读写操作,那么仅使用 volatile 关键字不能保证安全性。在这种情况下,需要使用 synchronized 保证对变量的读写是原子性的。对 volatile 变量的读写不会阻塞线程,需要在临界区使用 synchronized 关键字。

除了使用 synchronized 以外,还可以使用 java.util.concurrent 包中的任何一种原子数据类型(atomic data type)。比如,AtomicLong 或者 AtomicReference 等等。

如果仅有一个线程对 volatile 变量执行读写操作,其它线程仅执行读操作,那么使用 volatile 即可保证每次线程读的变量数据都是最新值。如果没有 volatile 声明,无法保证安全性。

volatile 关键字保证可以工作在 32 位和 64 位变量(各种基本数据类型均可使用)。

个人见解

如果多线程应用,仅有一个线程进行写操作,其它进行读操作,那么只需要声明变量为 volatile 就能保证线程安全。

除了这种情况,其它情况下仅声明 volatile 不能保证线程安全,有两种方法:

  1. 使用 synchronized 关键字保证原子性;
  2. 使用原子数据类型

volatile 性能考虑

volatile 变量的读写将造成从主内存的读写操作,这比访问缓存更加耗费时间。对 volatile 变量的访问也阻止了指令重排序,影响了性能的优化。因此,只有强制需要变量的可见性情况下,才使用 volatile 声明

猜你喜欢

转载自blog.csdn.net/u012005313/article/details/81173115