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 关键字解决了变量的可见性问题。通过把变量 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 变量本身,这种完全可见性表现为以下两个方面:
很抽象?让我们举例说明:
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 有现成的解决方法,我们会在下一小节讨论这个问题。
为了解决指令重排带来的困扰,Java volatile 关键字在可见性的基础上提供了 happens-before 这种担保机制。happens-before 保证了如下方面:
happens-before 机制确保了 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 变量的读写并不会阻塞其他线程对该变量的读写。你需要使用 synchronized 关键字保证读写操作的原子性,或者使用 java.util.concurrent 包下的原子类型代替 synchronized 代码块,例如:AtomicLong, AtomicReference 等。
如果只有一个线程对变量进行 读写 操作,其他线程仅有 读 操作,这时使用 volatile 关键字就能保证每个线程都能读到变量的最新值,即保证了可见性。
volatile 变量的读写操作会导致对主存的直接读写,对主存的直接访问比访问 CPU 缓存开销更大。使用 volatile 变量一定程度上影响了指令重排,也会一定程度上影响性能。所以当迫切需要保证变量可见性的时候,你才会考虑使用 volatile。