volatile 的语义

volatile 是 java 中一个非常常见,功能非常强大的一个关键字,大家用的最多的地方可能就是单例模式的双重检查锁的写法中。提到 volatile ,不得不提 synchronized , synchronized 是一个重量级锁,那么 volatile 是一个轻量级锁吗?并不是, volatile 是一个轻量级的同步关键字,那么 volatile 的语义到底是什么呢?这就是这篇文章要介绍的内容。

从单例模式的双重检查锁写法说起

首先我们看一下常见的单例模式的双重检查锁写法。更多单例模式的写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class SingleInstance {
private static SingleInstance sSingleInstance;

private SingleInstance() {
}

public static SingleInstance getInstance() {
if (sSingleInstance == null) {
synchronized (SingleInstance.class) {
if (sSingleInstance == null) {
sSingleInstance = new SingleInstance();
}
}
}
return sSingleInstance;
}
}

上面这种写法在 《Java 并发编程实战》 一书中的评价是【臭名昭著】 可见这种写法是有非常大的问题,不过幸运的是我们有 volatile 关键字,在 Java 5 以上将 sSingleInstance 用 volatile 关键字修饰就可以解决(具体问题我们下面再讨论)。 那么 volatile 为什么能解决问题, volatile 又为程序员保证了什么,这就是下面要讨论的问题。

可见性

在 JMM 中,为了提高程序性能,线程对于变量的读写不会直接作用于主存,而是会先作用于相对应的本地内存,最后会在合适的时机再同步到主存。而 volatile 的可见性是指,线程每次在更新本地内存的变量之后,会同步刷新到主存中去,同样的线程每次在读 volatile 变量时都会将本地内存中的值置为无效。然后线程会直接去主存中读取相应的值 。下面我们用一个例子再加深点认识。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//程序片段1
class VolatileExample{
int a = 0;
boolean flag = false;

public void write(){
a = 1; //1
flag = true; //2
}

public void read(){
if(flag){
int i = a; //3
... //4
}
}

}

假设 write 方法在 线程 A 中执行, read 方法在线程 B 中执行,我们不考虑其他因素(重排序),假设 A 线程先执行, B 线程后执行,那么当 B 线程执行时, B 线程能否正确读取到 flag 和 a 的值呢?很可惜,答案是不一定。因为 JVM 不保证何时会将本地内存中的值同步到主存中去。如果我们将程序改成下面这样,结果又是如何呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//程序片段2
class VolatileExample{
int a = 0;
volatile boolean flag = false;

public void write(){
a = 1; //1
flag = true; //2
}

public void read(){
if(flag){
int i = a; //3
... //4
}
}

}

同样的我们暂时不考虑重排序,将 flag 使用 volatile 修饰之后, volatile 可以保证在线程 A 修改了 falg 值之后会将 flag 的值同步到主存中去,同样的在 B 线程读取 flag 的时候也会去主存中读取。那么我们还有一个问题,这个时候虽然线程 B 可以正确读取到 flag 的值,那么线程 B 还能正确读取到 a 的值吗?答案是:可以。 volatile 会保证在同步 flag 的值到主存的同时会将写 volatile 变量之前的操作同时同步到主存中去。同样的当线程 B 开始去主存中读取 volatile 时,也会去主存中读取 a 的值。
JMM 的抽象示意图如下:
JMM-ABS.png

阻止重排序

重排序:编译器和处理器有可能会对不存在数据依赖的两条指令进行重排序,这里的数据依赖仅指单线程或单个处理器。还是以上面的程序片段1为例,重排序是指 1 和 2 以及 3 和 4 的执行顺序不可预测。可能的执行顺序有:1->2->3->4;2->1->3->4;1->2->4->3;2->1->4->3;
通过将程序片段1改为程序片段2就可以阻止重排序,最终的执行结果就是:1->2->3->4 ;
为了实现 volatile 内存语义, JMM 针对编译器制定的重排序规则如下:
reorder_rule.png
从上表我们可以看出:

  1. 当第二个操作是 volatile 写时,不管第一个操作是什么,都不会重排序
  2. 当第一个操作是 volatile 读时,不管第一个操作是什么,都不会重排序
  3. 当第一个操作是 volatile 写,第二个操作是 volatile 读是不会重排序

再探双重检查锁

下面我们在来看看为什么下面这种双重检查锁写法就不是【臭名昭著】的了呢?更多单例模式的写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class SingleInstance {
private static volatile SingleInstance sSingleInstance;

private SingleInstance() {
}

public static SingleInstance getInstance() {
if (sSingleInstance == null) {
synchronized (SingleInstance.class) {
if (sSingleInstance == null) {
sSingleInstance = new SingleInstance();
}
}
}
return sSingleInstance;
}
}

首先,我们要讨论的是开头的那种写法存在的问题。
其实 sSingleInstance = new SingleInstance(); 这句代码看起来只有一句但是,被编译成指令时是3句,分别是

  1. 为 SingleInstance 分配内存空间
  2. 调用 SingleInstance 的构造函数,初始化成员变量
  3. 为 sSingleInstance 赋值

根据前面的知识我们知道第 2 步和第 3 步可能会重排序,这样的话有可能某个线程获取到的单例就是未完全初始化的实例,为了解决这个问题,我们用 volatile 修饰 sSingleInstance 之后,根据上面的阻止重排序规则我们知道 volatile 写和前面的一条指令不会进行重排序,所以也就不会有问题了,这就是 volatile 的妙用。其实 volatile 远不止这点用处,在 Java 提供的并发包中有很多工具类的实现基础就是 volatile ,这些大家可以进一步了解。

参考文献

Java Memory Model
Java Volatile Keyword