[译]Java Volatile Keyword

翻译自:Java Volatile Keyword
地址:http://tutorials.jenkov.com/java-concurrency/volatile.html

Java volatile关键字用于保证共享变量的可见性。原文用“存储在主内存中”来解释它:读变量时不使用线程保留的旧值,写变量时让其他线程能够看到修改。

译者注:文中的主存、CPU缓存和示意图是简化模型。Java内存模型规定的是可见性与执行顺序,不要求每次volatile访问都绕过缓存、直接读写物理RAM。具体实现还涉及编译器优化、内存屏障和硬件缓存一致性。

实际上,自Java 5起,volatile的保证不仅限于变量本身的可见性,还涉及前后操作的顺序。我将在下面解释。

变量可见性问题

Java volatile关键字保证跨线程对变量的更改可见性。这听起来可能有点抽象,所以让我来详细说明一下。

在对非volatile变量进行操作的多线程应用程序中,出于性能原因,每个线程在处理变量时可能会将它们从主存复制到CPU缓存中。如果您的计算机包含多个CPU,则每个线程可以在不同的CPU上运行。这意味着,每个线程可以将这些变量复制到不同CPU的CPU缓存中。如图所示:

java volatile 1

对于非volatile变量,无法保证Java虚拟机(JVM)何时将数据从主存读入CPU缓存,或何时将数据从CPU缓存写入主存。这可能会导致几个问题,我将在下面几节中解释这些问题。想象这样一种情况:两个或多个线程访问一个共享对象,该对象包含一个这样声明的计数器变量:

1
2
3
public class SharedObject {
public int counter = 0;
}

再想象一下,只有线程1增加了计数器变量,但是线程1和线程2都可以不时地读取计数器变量。如果计数器变量未声明为volatile,则无法保证何时将计数器变量的值从CPU缓存写入主存。这意味着,CPU缓存中的计数器变量值可能与主存中的不同。这里说明了这种情况:

java volatile 2

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

Java volatile可见性保证

Java volatile关键字旨在解决变量可见性问题。把计数器声明为volatile后,对它的写入与后续读取之间就有了Java内存模型规定的同步关系,读取线程不能一直使用先前保留的旧值。

下面是volatile声明计数器变量的样子:

1
2
3
public class SharedObject {
public volatile int counter = 0;
}

将变量声明为volatile,可以让其他线程看到对该变量的修改。

在上面的场景中,T1修改计数器,T2只读取,因此volatile可以解决计数器的可见性问题。但T2不一定能观察到每一次写入;两次读取之间可能已发生多次更新。

但是,如果T1和T2都在递增计数器变量,那么将计数器变量声明为volatile是不够的。稍后会详细介绍。

完整的volatile可见性保证

实际上,Java volatile的可见性保证超出了volatile变量本身:

  • 对一个volatile变量的写入,happens-before后续对同一变量的读取。线程A在这次写入之前的操作,在线程B完成对应读取后也具有可见性保证。
  • 这不等于“读取volatile时重新加载所有变量”。它保证的是相关操作之间的顺序和可见性,而不是任意共享数据的一致快照。

让我用一个代码示例来说明:

1
2
3
4
5
6
7
8
9
10
11
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变量。

在update()中,years和months先写入,days最后写入。读取线程若通过读取days与这次volatile写入建立同步关系,随后就能看到这次写入之前对years和months的修改。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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,再读取months和years。这个顺序可以获得上面所说的可见性保证。但如果另一个线程不断调用update(),这三个字段仍可能来自不同次更新;volatile不会把多个字段的读取变成原子快照。

指令重新排序的挑战

出于性能原因,允许Java VM和CPU对程序中的指令重新排序,只要指令的语义意义保持不变。例如,看看下面的说明:

1
2
3
4
5
int a = 1;
int b = 2;

a++;
b++;

这些指令可以被重新排序到以下序列,而不会失去程序的语义意义:

1
2
3
4
5
int a = 1;
a++;

int b = 2;
b++;

但是,当其中一个变量是volatile变量时,指令重新排序就会带来挑战。让我们看看之前Java volatile教程中的例子中的MyClass:

1
2
3
4
5
6
7
8
9
10
11
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的修改。如果没有相应的顺序约束,指令可能被重排成下面这样:

1
2
3
4
5
public void update(int years, int months, int days){
this.days = days;
this.months = months;
this.years = years;
}

这样,读取线程可能先看到新的days,却还没看到新的months和years。单线程中的计算结果可能没变,但原本期望的跨线程发布顺序被破坏了。days声明为volatile后,不允许发生这种重排。

Java有一个解决这个问题的方案,我们将在下一节中看到。

Java volatile Happens-Before Guarantee

为了解决指令重新排序的问题,Java volatile关键字除了提供可见性保证外,还提供了“happens-before”保证。happens-before 保证如下:

  • volatile写入之前的普通读写,不能被重排到这次写入之后,破坏原有的发布顺序。
  • volatile读取之后的普通读写,不能被重排到这次读取之前,破坏原有的读取顺序。

这描述了volatile写的release语义和volatile读的acquire语义,不代表volatile禁止所有指令重排。其他重排仍须满足程序语义和Java内存模型。

以上happens-before保证确保执行volatile关键字的可见性保证。

volatile 不适用的场景

即使volatile保证变量的可见性,也有仅靠volatile不够的情况。

只有线程1写入计数器、线程2读取时,volatile可提供所需的可见性保证,但不保证读取发生在某次特定更新之后。

如果只是独立赋值,新值不依赖旧值,也没有需要共同维护的其他状态,多个线程可以写同一个volatile变量。但业务上若需要保留每次更新、检查条件或维护多个字段之间的约束,仍需额外同步。

一旦操作需要先读旧值,再计算并写回新值,volatile就不能保证整个过程的原子性。多个线程可能读到相同的旧值,各自计算出新值,随后互相覆盖写入结果。这里的问题是更新丢失,而不是volatile的可见性失效。

多个线程递增同一个计数器的情况恰恰是一个volatile变量不够用的情况。下面几节将更详细地解释这种情况。

设想一下,线程1读到计数器值0,计算出1,但还没写回;线程2也读到0,并计算出1。随后两个线程都写入1,最终结果是1,而不是期望的2。即使计数器声明为volatile,这种交错也可能发生。下面的缓存示意图可以帮助理解各线程分别持有中间值的情况,但不应当作硬件读写过程的精确描述:

java volatile 3

两个线程都完成了一次递增,但其中一次更新被覆盖了。要得到正确结果,需要把读取、计算和写入作为一个整体进行同步,或者使用原子递增操作。

volatile 适用的场景

如前所述,多个线程执行counter++这类复合操作时,volatile不够用。可以用synchronized把整个临界区包起来,避免读取、计算和写入被其他线程打断。单次volatile读写并不提供这种互斥保护。

作为同步块的替代方法,您还可以使用java.util.concurrent中找到的许多原子数据类型之一。例如,AtomicLong或AtomicReference或其他的一个。

如果只有一个线程更新计数器,其他线程只读取,volatile通常足以满足这个单变量场景。若要等待某次更新或逐次处理所有更新,还需要相应的协调机制。

volatile读写本身是原子的,这也包括long和double;但counter++、条件判断后赋值等复合操作不在此保证范围内。

volatile 性能

volatile访问会限制编译器和处理器的部分优化,并可能引入内存屏障、缓存一致性通信等开销。具体成本取决于硬件、访问方式和竞争程度,不能简单理解为每次都访问RAM。因此,只有需要相应可见性和顺序保证时,才使用volatile。

译者注:原文还提到,L1、L2、L3缓存到RAM的同步由独立芯片完成,因此不会给CPU带来负担。这不是通用结论,缓存及内存控制器的组织取决于具体架构,相关访问仍可能产生等待和资源竞争。此处不沿用这一解释。内存语义以JLS 17.4为准。

即便如此,也请只在真正需要时才使用volatile变量。这也会迫使你去详细了解Java volatile变量的工作原理。


📚 系列文章 · Java 并发编程