[Java并发编程(三)] Java volatile 关键字介绍

时间:2020-12-29 19:42:46

[Java并发编程(三)] Java volatile 关键字介绍

摘要

Java volatile 关键字是用来标记 Java 变量,并表示变量 “存储于主内存中” 。更准确的说就是对于 volatile 变量的每次读操作都是从计算机的主内存中读取,而不是 CPU 缓存,每次写操作也是将 volatile 变量写入主内存中,不是 CPU 缓存。

事实上,因为 Java 5 的 volatile 关键字保证的不止是从主内存读写。这点稍后会进行解释。

正文

Java volatile 可见性的保证

Java volatile 关键字保证了变量在跨线程时的变更可见性。这可能听起来比较抽象,所以下面举个例子来说明。

在多线程应用中,在线程操作 non-volatile 变量时,每个线程都会将变量从主内存拷贝到 CPU 缓存中然后进行处理,这主要是性能因素所决定的。如果计算机有不止一个 CPU ,每个线程会在不同的 CPU 上运行。也就是说,每个线程都会将变量拷贝至不同的 CPU 缓存中。如下图:

[Java并发编程(三)] Java volatile 关键字介绍

non-volatile 变量并不能保证 Java 虚拟机(JVM)将数据从主内存读入 CPU 缓存的时间,也无法确认 CPU 缓存的数据何时会被写入到主内存。这样会引发一些问题。

设想如果有两个或多个线程会访问一个共享对象如下:


public class SharedObject { public int counter = 0; }

如果只有 线程 1counter 变量进行自增操作,但 线程 1线程 2 都会时刻读取 counter 变量。

如果 counter 变量不是声明的 volatile ,那么并不能保证在写 counter 变量时,会将 CPU 缓存写会到主内存。也就是说, counter 变量在 CPU 缓存中的值与主内存中的值不一样。这种情况如下图所示:

[Java并发编程(三)] Java volatile 关键字介绍

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

通过为 counter 变量声明 volatile 关键字,所有对于 counter 变量的写操作都会立即被写回到主内存中。同样,所有 counter 变量的读操作也会直接从主内存中直接读取。为 counter 变量声明 volatile 关键字的方式如下:


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

Java volatile Happens-Before 保证

Java 5 的 volatile 关键字并不只是保证从主内存中读写变量。事实上, volatile 关键字还保证:

  • 如果 线程 A 写如一个 volatile 变量,线程 B 接着读取同一个 volatile 变量,那么在写 volatile 变量前所有对 线程 A 可见的变量也会在 线程 B 读取该 volatile 变量后对 线程 B 可见。

  • volatile 变量的读写指令不允许被 JVM 重排(JVM 会在不影响程序行为前提下,为了提升性能对指令进行重排)。指令前和指令后可以被重排,但是 volatile 读写操作不会与这些指令混合。在读写 volatile 变量后无论跟随什么指令,也保证之后可以读写。

以上的陈述需要更深的解释。

Thread A:
sharedObject.nonVolatile = 123;
sharedObject.counter = sharedObject.counter + 1; Thread B:
int counter = sharedObject.counter;
int nonVolatile = sharedObject.nonVolatile;

由于 线程 A 在写 volatile 变量 sharedObject.counter 前,先写 non-volatile 变量 sharedObject.nonVolatile 变量,那么当 线程 A 写 sharedObject.counter( volatile 变量)时,sharedObject.nonVolatile 与 sharedObject.counter 都会写入主内存。

由于 线程 B 先读取 volatile 变量 sharedObject.counter ,那么 sharedObject.counter 和 sharedObject.nonVolatile 都会从主内存读入 CPU 缓存中。在 线程 B 读取 sharedObject.nonVolatile 变量时,线程 A 的写入值已对其可见。

开发者可以利用这个扩展的可见性来优化线程间变量的可见性。无须为每个变量都声明 volatile 只需要对少数变量使用 volatile 。下面一个简单的例子 Exchanger 类就遵循了以上原则:


public class Exchanger { private Object object = null;
private volatile hasNewObject = false; public void put(Object newObject) {
while(hasNewObject) {
//wait - do not overwrite existing new object
}
object = newObject;
hasNewObject = true; //volatile write
} public Object take(){
while(!hasNewObject){ //volatile read
//wait - don't take old object (or null)
}
Object obj = object;
hasNewObject = false; //volatile write
return obj;
}
}

线程 A 会时不时通过调用 put() 方法设置对象。线程 B 会时不时通过调用 take() 方法获取对象。 Exchanger 可以只使用 volatile 变量(不使用 synchronized 块)就能保证程序的正确性,只要 线程 A 只调用 put() 而 线程 B 只调用 take() 。

但是,如果 JVM 在不改变语意的情况下,可能会为了优化性能对 Java 指令进行重排。如果 JVM 改变了 put() 和 take() 里的读写顺序会发生什么?如果 put() 的执行顺序是下面这样会怎样?


while(hasNewObject) {
//wait - do not overwrite existing new object
}
hasNewObject = true; //volatile write
object = newObject;

注意到写 volatile 变量 hasNewObject 发生在新对象设置前。对 JVM 来说这是完全有效的。两个写指令的值并不相互依赖。

但是,更改指令执行的顺序会损坏 object 变量的可见性。首先,线程 B 会在 线程 A 为 object 变量设置新值之前就看见 hasNewObject 设置成 true 。其次,这里无法确定新值是何时写回到主内存中的。(有可能是下次 线程 Avolatile 变量进行写操作时)。

为了防止以上情况的出现, volatile 关键字有 “发生前保证(happens before guarantee)”。 happens before guarantee 保证 volatile 变量的读写不能被重排。指令前和指令后可以被重排,但是 volatile 读写指令不能与在它之前或之后的指令重排。

看以下例子:


sharedObject.nonVolatile1 = 123;
sharedObject.nonVolatile2 = 456;
sharedObject.nonVolatile3 = 789; sharedObject.volatile = true; //a volatile variable int someValue1 = sharedObject.nonVolatile4;
int someValue2 = sharedObject.nonVolatile5;
int someValue3 = sharedObject.nonVolatile6;

JVM 会对前 3 个指令进行重排,因为它们对于 volatile 写指令都是 happens before (它们都必须在 volatile 写指令前执行)。

同样,只要 volatile 写指令在后 3 条指令前发生( happens before ),JVM 也可能对后 3 条指令进行重排。

以上是 Java volatile “happens before” 保证的基本含义。

volatile 并不总是有效

尽管 volatile 关键字可以保证所有 volatile 变量的读都直接访问主内存,所有 volatile 写都直接写入主内存,还是会有 volatile 失效的场景。

在之前描述的场景中,线程 1 写入共享变量 counter ,将 counter 变量声明 volatile 就可以保证 线程 2 总是可以看到最新的写入值。

事实上,多线程也可以写入同一 volatile 共享变量,如果新写入变量并不依赖于前序值,它仍然可以保证正确的值可以存入主内存。换句话说,如果线程将值写入共享 volatile 变量时不需要先读取它的值来计算下一个值时,就能有此保证。

只要线程需要先读取 volatile 变量的值,然后基于该值计算 volatile 变量的新值,那么 volatile 变量就无法保证它可见性的正确。在读取 volatile 变量与写入新值之间短暂的时间间隔会造成 竞争条件(Race Condition) ,这会导致多线程会读取 volatile 变量相同的值,并生成新值,当将值写回到主内存时,有可能会将它们生成的新值相互覆盖。

当多线程对相同的 counter 进行自增操作就是 volatile 变量无法保证可见性的典型场景。下面对这个例子进行更详细的解释。

设想 线程 1 读取共享变量 counter 的值 0 到 CPU 缓存,自增 1 后并没有将更新的值写回到主内存中。 线程 2 将相同的 counter 值 0 从主内存读入它自己的缓存,同时也将 counter 值自增到 1 ,并写回到主内存。这个场景下图所示:

[Java并发编程(三)] Java volatile 关键字介绍

线程 1线程 2 并不同步(out of sync)。这时共享变量 counter 的真实值应该是 2 ,但是每个线程在它们自己的 CPU 缓存中存放的值都是 1 ,但是在主内存中,该值仍然是 0 。这很混乱!如果线程最终将 counter 值写回到主内存,那么该值也是错误的。

volatile 何时有效?

正如之前提到的,如果两个线程同时对一个共享变量进行读写操作,那么使用 volatile 关键字并不有效。这时就需要使用 synchronized 关键字来保证读与写都是原子操作(atomic)。读写 volatile 变量不能阻塞线程的读写。为了能实现阻塞,必须使用 synchronized 关键字划定关键区(critical section)。

替代 synchronized 块的方法可以使用 java.util.concurrent 包中的许多原子数据类型。比如,AtomicLongAtomicReference 或其他类型中的一种。

在只有一个线程对 volatile 变量进行读写,而其他线程都仅对变量进行读取操作,那么可以保证 volatile 变量的最新写入值对读线程都可见。

volatile 关键字对于 32bit 和 64bit 变量都是有效的。

volatile 的性能考虑

volatile 变量的读写要求变量都从主内存中进行读写。读写主内存的代价要比访问 CPU 缓存高,访问 volatile 变量同样可以防止指令的重排(指令重排是一种常用的提高性能的技术)。因此,只有到真的需要保证变量的可见性的时候,才应该使用 volatile 变量。

附言

关于原子访问的解释,Oracle 官方有如下解释:

在程序中,原子操作(atomic action)可以保证所有的事情一并发生。原子操作不能在过程中停止:它要么完全发生,或要么完全不发生。在一个操作完成前,改原子操作产生的影响是不可见的。

在 c++ 中,自增表达式并不是原子操作。每个简单的表达式都可以是由多个复杂操作定义的,可以被分成多个操作。但是,原子操作是可以被区分的:

  • 引用变量和大多数原始类型变量(除了 long 和 double)的读写都是原子操作
  • 所有声明了 volatile 的变量(包括 long 和 double)的读写都是原子操作

原子操作不能重叠,这样它们就可以不受线程的干扰。不过,这并不能消除所有同步原子操作的需求,因为内存还是可能出现一致性错误。使用 volatile 变量可以降低内存一致性错误的风险,因为任何写 volatile 变量都会与之后续对相同变量的读操作建立 happens-before 的关系。这也意味着 volatile 变量对其他线程总是可见的。不仅仅如此,当一个线程对 volatile 变量进行读操作时,它看到的并不只是 volatile 变量的最新更改,同时还包括该更改所引起的副作用。

使用简单原子变量进行访问要比通过 synchronized 访问要更高效,但也需要更小心来避免内存一致性错误。可以更加应用的规模和复杂程度来判断额外的代价是否值得。

java.util.concurrent 包中的类提供了很多原子方法,并不依赖于 synchronization 。

参考

jenkov: Java Volatile Keyword

javamex: The volatile keyword in Java

oracle: Atomic Access

结束