Java中的Volatile关键字

8次阅读

共计 4580 个字符,预计需要花费 12 分钟才能阅读完成。

Java 的 volatile 关键字用于标记一个 Java 变量为“在主存中存储”。更确切的说,对 volatile 变量的读取会从计算机的主存中读取,而不是从 CPU 缓存中读取,对 volatile 变量的写入会写入到主存中,而不只是写入到 CPU 缓存。

实际上,从 Java5 开始,volatile 关键字不只是保证了 volatile 变量在主存中写入和读取,我回在后面的部分做相关的解释。

变量可见性问题

Java 的 volatile 关键字保证了多个线程对变量值变化的可见性。这听起来有点抽象,让我来详细解释。

在一个多线程的程序中,当多个线程操作非 volatile 变量时,出于性能原因,每个线程会从主存中拷贝一份变量副本到一个 CPU 缓存中。如果你的计算机有多于一个 CPU,每个线程可能会在不同的 CPU 中运行。这意味着每个简称拷贝变量到不同 CPU 的缓存中,如下图:

对于非 volatile 变量,并没有保证何时 JVM 从主存中读取数据到 CPU 缓存,或者从 CPU 缓存中写出数据到主存。这会导致一些问题。

想象一种情况,多于一个线程访问一个共享对象,这个共享对象包含一个计数变量如下声明:

public class ShareObject {public int counter = 0;}

考虑只有一个线程 Thread1 增加 counter 这个变量的值,但是 Tread1 和 Thread2 可能有时会读取 counter 变量。

如果 counter 变量没有被声明为 volatile,就不能保证何时这个变量的值会从 CPU 缓存写回主存,这意味着,在 CPU 缓存中的 counter 变量的值可能和主存中的不一样。如下图所示:

线程没有看到一个变量最新更新的值的原因是这个变量还没有被一个线程写回到主存,这被称为“可见性”问题。一个线程对变量的更新对其他线程不可见。

Java 的 volatile 可见性保证

Java 的 volatile 关键字想要解决变量可见性问题。通过声明 counter 变量为 volatile,所有对 counter 变量的写入都回立即写回到主存,同时所有对 counter 变量也都会从主存中读取。

西面的代码展示了如何把 counter 变量声明为 volatile:

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

声明一个变量为 volatile 保证了对变量的写入对其他线程的可见性。

在上面的场景中,一个线程(T1)修改了 counter 变量的值,另一个线程(T2)读取 counter 变量(但是不修改它),声明 counter 变量为 volatile 足以保证对 counter 变量的写入对 T2 可见。

但是,如果 T1 和 T2 都去增加 counter 变量的只,name 声明 counter 变量为 volatile 是不够的,后面会说明。

全 volatile 可见性保证

实际上,Java 的 volatile 的可见性保证不止 volatile 变量本身。可见性保证如下:

  • 如果线程 A 写一个 volatile 变量,线程 B 随后读取这个 volatile 变量,那么在写这个 volatile 变量之前对线程 A 可见的所有变量,在线程 B 读取这个 volatile 变量之后对线程 B 也可见。
  • 如果线程 A 读取一个 volatile 变量,那么当 A 读取这个 volatile 变量时所有对线程 A 可见的变量也可以从主存中再次读取。

我用下面的代码来说明:

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 变量是 volatile 的。

全 volatile 可见性保证的意思是,当一个值写入到 days 变量,则所有对当前线程可见的变量也会都写入到主存,也就是当一个值写入到 days 变量,则 years 和 months 的只也被写入到主存。

当读取 years,months 和 days 的值,可以这样做:

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 的值时,months 和 years 的值也被读取到主存。因此可以保证你看到的是 days,months 和 years 的最新的值,前提是保证上面的读取顺序。

指令重排序挑战

出于性能的考量,JVM 和 CPU 允许对程序中的指令进行重排序,只要指令的语义不变。例如下面的指令:

int a = 1;
int b = 2;

a++;
b++;

这些指令可以按照下面的顺序重排,并不会丢失程序的语义:

int a = 1;
a++;

int b = 2;
b++;

但是,指令重排序对于其中一个变量是 volatile 变量这种情况是有挑战的。让我们看一下 MyClass 这个类:

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 变量写入一个值,years 和 months 新写入的只也刷入到主存,但是,如果有 JVM 指令重排序,像下面这样:

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

months 和 years 的只在 days 变量修改的情况下依然会写入到主存,但是这时将 years 和 days 变量值刷入主存这件事发生在对 months 和 years 写入新值之前,则对 years 和 days 的更新对其他线程来说就不可见了。这下指令重排序就改变了程序的语义。

Java 有一个应对此问题的解决方案,下面会讲到。

Java 的 volatile 的 Happens-Before 保证

为了解决指令重排序的挑战,Java 的 volatile 关键字除了可见性保证之外,给出了一个“happens-before”的保证。happens-before 保证如下情况:

  • 如果读取和写入其他非 volatile 变量发生在写入 volatile 变量之前(这种情况这些非 volatile 变量也会被刷入主存),则读取和写入这些变量不能被重排序为发生在写入这个 volatile 变量之后(禁止指令重排序)。在写入一个 volatile 变量之前的读取和写入非 volatile 变量被保证为“happen before”写入这个 volatile 变量。需要注意的是,例如在写入一个 volatile 变量之后读写其他变量可以被重排序到写入这个 volatile 变量之前。从“之后”重排序到”之前“是允许的,但是从”之前“重排序到”之后“是禁止的。
  • 如果读写其他非 volatile 变量发生在读取一个 volatile 变量之后(这种情况这些非 volatile 变量也会被刷到主存),则读写这些变量不能被重排序为发生在读取这个 volatile 变量之前。需要注意的是,读取其他变量发生在读取一个 volatile 变量之前能够被重排序为发生在读取这个 volatile 变量之后。从”之前“重排序到“之后”是允许的,但是从“之后”重排序到“之前”是被禁止的。

上面的 happens-before 保障保证的 volatile 关键字的可见性是强制的。

volatile 不总是足够的

尽管 volatile 关键字保证了所有对一个 volatile 变量的读取都是从主存中读取,所有对 volatile 关键字的写入都是直接到主存,但是仍有其他情况使得声明一个变量为 volatile 是不足够的。

在前面解释的情况,也就是只有 Thread1 写共享变量 counter,声明 counter 变量为 volatile 足以保证 Thread2 总是看到最新写入的值。

实际上,多线程都可以写一个共享的 volatile 变量,并且仍然在主存中存储正确的值,前提是写入变量的新值不依赖于它之前的值。也就是说,如果一个线程写入一个值到共享的 volatile 变量不需要先去读它的值去产出下一个值。

只要一个线程需要首先读取一个 volatile 变量的值,基于这个值生成一个新值,则一个 volatile 关键字不足以保证正确的可见性。在读取 volatile 变量然后写入新值的短暂的间隙,会产生竞态条件(race condition),这时多个线程可能读取到相同的 volatile 变量的值,生成这个变量的新值,当将新值写回主存时,会覆盖彼此的值。

多线程增加相同计数器的值就是这种情况,导致一个 volatile 声明不足够。下面详细解释这种情况。

想象如果 Thread1 读取一个值为 0 的共享的 counter 变量到它的 CPU 缓存,增加 1 并且不将这个改变的值写回主存。Thread2 然后从主存中读取相同的值仍为 0counter 变量到它的 CPU 缓存。Thread2 也为它增加 1,也不写回主存。这种情况如下图所示:

Thread1 和 Thread2 此时实际上已经不同步了。共享变量 counter 的值应该为 2,但是每个线程在 CPU 缓存中的这个变量的值都为 1,在主存中的值仍为 0,这就乱了!尽管这两个线程最终会将值写回主存中的共享变量,这个值也是不正确的。

何时 volatile 是足够的?

正如前面所说,如果两个线程都去读写同一个共享变量,只对这个共享变量使用 volatile 关键字是不够的。你需要使用一个 synchronized 关键字去保证读写相同变量是原子的。读写一个 volatile 变量不会阻塞线程的读写。

作为 synchronized 块替代方法,你可以使用 java.util.concurrent 包中的众多原子数据类型。比如,AtomicLong 或者 AtomicReference 或其他的类型。

只有一个线程读写一个 volatile 变量值,其他线程只读取变量,则这些读线程能够保证看到写入这个 volatile 变量的最新值,如果不声明为 volatile,则这种情况不能保证。

volatile 的性能考量

读写 volatile 变量会导致变量被读写到主存。读写主存比访问 CPU 缓存开销更大。访问 volatile 变量也会禁止指令重排序,而指令重排序是一个正正常的性能优化技术。因此,你应该只在真正需要保证变量可见性的时候使用 volatile 变量。

正文完
 0