JAVA volatile 关键字

49次阅读

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

volatile 关键字能把 Java 变量标记成 ” 被存储到主存中 ”。这表示每一次读取 volatile 变量都会访问计算机主存,而不是 CPU 缓存。每一次对 volatile 变量的写操作不仅会写到 CPU 缓存,还会刷新到主存中。实际上从 Java 5 开始,volatile 变量不仅会在读写操作时访问主存,他还被赋予了更多含义。
变量的可见性问题
Java volatile 关键字保证了线程对变量改动的可见性。举个例子,在多线程 (不使用 volatile) 环境中,每个线程会从主存中复制变量到 CPU 缓存 (以提高性能)。如果你有多个 CPU,不同线程也许会运行在不同的 CPU 上,并把主存中的变量复制到各自的 CPU 缓存中,像下图画的那样
若果不使用 volatile 关键字,你无法保证 JVM 什么时候从主存中读变量到 CPU cache,或把变量从 CPU cache 写回主存。这会导致很多并发问题,我会在下面的小节中解释。想像一下这种情形,两个或多个线程同时访问一个共享对象,对象中包含一个用于计数的变量:
public class SharedObject {
public int counter = 0;
}

如果 Thread-1 会增加 counter 的值,而 Thread-1 和 Thread-2 会不时地读取 counter 变量。在这种情形中,如果变量 counter 没有被声明成 volatile,就无法保证 counter 的值何时会 (被 Thread-1) 从 CPU cache 写回到主存。结果导致 counter 在 CPU 缓存的值和主存中的不一致:
Thread-2 无法读取到变量最新的值,因为 Thread-1 没有把更新后的值写回到主存中。这被称作 “ 可见性 ” 问题,即其他线程对某线程更新操作不可见。
volatile 保证了变量的可见性
volatile 关键字解决了变量的可见性问题。通过把变量 counter 声明为 volatile,任何对 counter 的写操作都会立即刷新到主存。同样的,所有对 counter 的读操作都会直接从主存中读取。
public class SharedObject {
public volatile int counter = 0;
}

还是上面的情形,声明 volatile 后,若 Thread-1 修改了 counter 则会立即刷新到主存中,Thread-2 从主存读取的 counter 是 Thread-1 更新后的值,保证了 Thread-2 对变量的可见性。
volatile 完全可见性
volatile 关键字的可见性生效范围会超出 volatile 变量本身,这种完全可见性表现为以下两个方面:

如果 Thread-A 对 volatile 变量进行写操作,Thread-B 随后该 volatile 变量进行读操作,那么 (在 Thread-A 写 volatile 变量之前的) 所有对 Thread-A 可见的变量,也会 (在 Thread-B 读 volatile 变量之后) 对 Thread-B 可见。
当 Thread-A 读一个 volatile 变量时,所有其他对 Thread-A 可见的变量也会重新从主存中读一遍。

很抽象?让我们举例说明:
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;
}
}

上面的 update() 方法给三个变量赋值 ( 写操作),其中只有 days 是 volatile 变量。完全可见性在这的含义是,当对 days 进行写操作时,线程可见的其他变量 (在写 days 之前的变量) 都会一同回写到主存,也就是说变量 months 和 years 都会回写到主存。
上面的 totalDays() 方法一开始就把 volatile 变量 days 读取到局部变量 total 中,当读取 days 时,变量 months 和 years ( 在读 days 之后的变量) 同样会从主存中读取。所以通过上面的代码,你能确保读到最新的 days, months 和 years。
指令重排的困扰
为了提高性能,JVM 和 CPU 会被允许对程序进行指令重排,只要重排的指令语义保持一致。举个例子:
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 时,对变量 years 和 months 的写操作同样会刷新到主存中。但如果 JVM 执行了指令重排会发生什么情况?就像下面这样:
public void update(int years, int months, int days){
this.days = days;
this.months = months;
this.years = years;
}

当变量 days 发生改变时,months 和 years 仍然会回写到主存中。但这一次,days 的更新发生在写 months 和 years 之前,导致 months 和 years 的新值可能对其他线程不可见,使程序语义发生改变。对此 JVM 有现成的解决方法,我们会在下一小节讨论这个问题。
volatile 的 Happen-before 机制
为了解决指令重排带来的困扰,Java volatile 关键字在可见性的基础上提供了 happens-before 这种担保机制。happens-before 保证了如下方面:

如果其他变量的读写操作原本发生在 volatile 变量写操作之前,他们不能被指令重排到 volatile 变量的写操作之后。注意,发生在 volatile 变量写操作之后的读写操作仍然可以被指令重排到 volatile 变量写操作之前。happen-after 重排到 (volatile 写操作) 之前是允许的,但 happen-before 重排到之后是不允许的。
如果其他变量的读写操作原本发生在 volatile 变量读操作之后,他们不能被指令重排到 volatile 变量的读操作之前。注意,发生在 volatile 变量读操作之前的读操作仍然可以被指令重排到 volatile 变量读操作之后。happen-before 重排到 (volatile 读操作) 之后是允许的,但 happen-after 重排到之前是不允许的。

happens-before 机制确保了 volatile 的完全可见性
volatile 并不总是行得通
虽然关键字 volatile 保证了对 volatile 变量的读写操作会直接访问主存,但在某些情况下把变量声明为 volatile 还不足够。回顾之前举过的例子 —— Thread-1 对共享变量 counter 进行写操作,声明 counter 为 volatile 并不足以保证 Thread-2 总是能读到最新的值。
实际上,可能会有多个线程对同一个 volatile 变量进行写操作,也会把正确的新值写回到主存,只要这个新值不依赖旧值。但只要这个新值依赖旧值 (也就是说线程先会读取 volatile 变量,基于读取的值计算出一个新值,并把新值写回到 volatile 变量),volatile 关键字不再能够保证正确的可见性 (其他文章会把这称为原子性)。
在多线程同时共享变量 counter 的情形下,volatile 关键字已不足以保证程序的并发性。设想一下:Thread-1 从主存中读取了变量 counter = 0 到 CPU 缓存中,进行加 1 操作但还没把更新后的值写回到主存。Thread-2 同一时间从主存中读取 counter (值仍为 0) 到他所在的 CPU 缓存中,同样进行加 1 操作,也没来得及回写到主存。情形如下图所示:
Thread-1 和 Thread-2 现在处于不同步的状态。从语义上来说,counter 的值理应是 2,但变量 counter 在两个线程所在 CPU 缓存中的值却是 1,在主存中的值还是 0。即使线程都把 counter 回写到主存中,counter 更新成 1,语义上依然是错的。(这种情况应该使用 synchronized 关键字保证线程同步)
什么时候使用 volatile
像之前的例子所说:如果有两个或多个线程同时对一个变量进行读写,使用 volatile 关键字是不够用的,因为对 volatile 变量的读写并不会阻塞其他线程对该变量的读写。你需要使用 synchronized 关键字保证读写操作的原子性,或者使用 java.util.concurrent 包下的原子类型代替 synchronized 代码块,例如:AtomicLong, AtomicReference 等。
如果只有一个线程对变量进行读写操作,其他线程仅有读操作,这时使用 volatile 关键字就能保证每个线程都能读到变量的最新值,即保证了可见性。
volatile 的性能
volatile 变量的读写操作会导致对主存的直接读写,对主存的直接访问比访问 CPU 缓存开销更大。使用 volatile 变量一定程度上影响了指令重排,也会一定程度上影响性能。所以当迫切需要保证变量可见性的时候,你才会考虑使用 volatile。

正文完
 0