0%

深入理解volatile关键字

volatile与可见性

我们还是先来看一个例子,这个例子在synchronized这篇文章中用过:

1
2
3
4
5
6
7
8
9
10
11
public class IntegerIncrementer {
private int counter = 0;

public int getCount() {
return counter;
}

public void increase() {
counter++;
}
}

我们之前是用synchronized关键字让现场在修改完变量后强制刷新内存,让修改结果对其他线程可见。其实volatile可以达到同样的效果,即修改后的变量可以保证对其他线程可见。

具体实现的原理是,对volatile进行的操作最后在汇编语言中会带有Lock前缀,Lock前缀具有以下的作用:

  1. 把当前变量所在CPU缓存行的数据写回内存。
  2. 让其他CPU包含了该地址的缓存行失效,也就是线程下一次读取共享变量必须强制去内存读取。

注意缓存行是CPU缓存中最小的读写单位,一般一个缓存行中会包含多个共享变量,并且这些变量之间可以没有任何关系。频繁地使缓存失效会降低缓存命中率,从而影响性能。这就是伪共享的问题,伪共享可以通过字节填充来解决,这里不展开讨论。

总之volatile可以通过这样的机制来保证可见性。

volatile与有序性

volatile的另一个特性是可以禁止指令的重排,我们来看这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Demo {
private static boolean ready;
private static int number;

private static class ReaderThreader extends Thread {
public void run() {
while (!ready);
System.out.println(number);
}
}

public static void main(String[] args) {
new ReaderThread().start();
number = 42;
ready = true;
}
}

乍看之下,似乎最后打印出来的是42, 但是我们还是在上面那篇文章中介绍过,编译器和处理器会在遵从as-if-serial的语义下对指令进行重排。代码14和15行之前没有任何依赖关系,那么完全可能出现:

  1. 线程A先执行15行,CPU time结束,发生context switch。
  2. 线程B执行run()方法,打印出来的结果是0。

volatile是通过插入内存屏障来防止重排,这里我们不展开讨论。一般而言,其会禁止下列重排:

  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 Demo {
private static volatile boolean ready;

private static class ReaderThreader extends Thread {
public void run() {
while (!ready);
int number = readDB("key");
System.out.println(number);
}
}

public static void main(String[] args) {
new ReaderThread().start();
writeDB("key", 42);
ready = true;
}
}

这里ready已经被volatile修饰,那么假设不禁止情况1下的重排,那么第14和15行由于没有数据依赖,仍然有可能重排,那么可能出现以下情况:

  1. 线程A先执行15行,发生context switch。
  2. 线程B执行run()方法,读取DB的时候key不存在,或者读到stale data。

对于情况2,类似地,第6行和第7行的顺序可能重排,第7行的语句可能先被执行,将结果缓存,等到第6行判断为真的时候再赋值,那么可能出现这种情况:

  1. 线程A执行第7行,有可能读到stale data,缓存结果,发生context switch。
  2. 线程B执行main方法,更新key的值。
  3. 线程A执行第6行,判断为真,将缓存的stale data赋值给number

因为会出现以上的情况,所以我们需要禁止普通读写和volatile读写的重排序。那么对volatile修饰变量操作重排序的禁止,有一个大家耳熟能详的例子,就是DCL(Double Check Lock):

1
2
3
4
5
6
7
8
9
10
11
12
13
public class DCLDemo {
private static volatile DCLDemo instance;

public static DCLDemo getInstance() {
if (instance == null) {
synchronized(DCLDemo.class) {
if (instance == null) {
instance = new DCLDemo();
}
}
}
}
}

以上是线程安全版的Singleton的实现方法,其中有两个细节:

  1. instance == null的检查要做两次,因为第一次检查完到获取锁中间,可能有其他线程已经完成了实例化从同步块中出来了。
  2. instance需要用volatile修饰。

这里volatile的作用是防止指令的重排序,因为new的操作不是原子性的,它会分为三步:

  1. 分配内存区间。
  2. 调用构造函数初始化实例。
  3. 将初始化完毕的实例赋值给引用。

所以其中2和3是可能重排,那么一旦执行顺序变为1->3->2,那么其他线程进来的时候,instance可能并不为null,但是此时publish的实例是没有初始化完成的,就很有可能出错。所以我们必须用volatile修饰instance来防止可能的指令重排。

volatile与原子性

volatile并不对原子性有任何保证,所以需要原子性的时候,需要和其他保证原子性的关键字搭配使用,比如synchronized
但是值得一提的是volatile修饰的64位变量(如long,double),其读写操作在32位JVM中会具有原子性。