线程安全之 volatile 关键字

在阅读本文前,建议先看下面两篇文章:

Java 内存模型

线程安全之 synchronized 关键字

Java 中 volatile 是轻量级的 synchronized,不会引起线程上下文的切换和调度。虽然 volatile 关键字只能修饰变量,但是要用好它并不容易,下面分析它的原理及注意事项。

volatile 关键字的两层语义

  • 保证变量对所有线程的可见性,即一个线程修改了某个值,新值对其他线程来说是立即可见的

  • 禁止指令重排序(Java 5 后才有的)

volatile 的可见性保证

要明白可见性语义,需要先了解 Java 内存模型的概念,具体可看 Java 内存模型,简单的说就是一个公共变量在每个线程都有线程私有的副本,所以正常情况下,一个线程修改了某个变量的值,只是修改其副本,其他线程不能得知该值的修改。

先看下面这段代码:

1
2
3
4
5
6
7
boolean count = 0; // 线程共享变量
// 线程1
count = 1;
// 线程2
System.out.println(count);

线程1 先执行count = 1;,然后线程2 中读取count的值时可能为 0 也可能为 1。因为线程2 无法知道线程1对count变量的修改,但是也不确定的是也不知道该修改什么时候同步到共享内存,不知道何时把共享内存的值更新到线程2 的工作内存中。

而 volatile 关键字修饰变量后,每个线程对 volatile 变量的读取都要从共享内存中读,而每个线程对 volatile 变量的写不仅要更新工作内存还要刷新共享内存。而普通变量没法保证 JVM 何时把工作内存的值更新到共享内存,以及把共享内存的值读取到工作内存。所以用 volatile 关键字修饰count变量后,线程2 就可以知道线程1 对其的修改了。

volatile 关键字不具备原子性

从上面知道 volatile 关键字保证了操作的可见性,但是 volatile 能保证对变量的操作是原子性吗?

看下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Test {
public volatile int count = 0;
public void increase() {
count ++;
}
public static void main(String[] args) {
final Test test = new Test();
for(int i = 0; i < 10; i ++){
new Thread(){
public void run() {
for(int j = 0; j< 1000; j ++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println(test.count);
}
}

上面这段程序的输出结果很多朋友会认为是 10000,因为每个线程自增 1000 次,10线程的话就是 自增 10000 次,结果就是 10000。但是实际上它每次的运行结果都不一定一致,但都是一个小于 10000 的数值。

原因是 volatile 关键字保证了 count 变量的可见性,但是并没有保证increase()方法的原子性。自增操作是不具备原子性的,编译成 class 文件的话,它分成三个指令:读取变量的值、进行加 1 操作、写入工作内存(因为是 volatile 变量,所以也会同步到共享内存)。大家可以想象一下这个场景:

某一时刻 count 的值为 10, 线程1对其进行自增操作,读取了值后就被阻塞了,切换到线程2对其进行自增操作,读取到的值为 10,然后进行加 1 操作并写入工作内存,同时同步到共享内存。然后切换回线程1,虽然共享内存中 count 的值为 11, 但是线程1下面的操作没有去读 count 的值,线程1的副本中 count的值还是 10,所以继续自增操作的下面两步后也把 11 更新到共享内存。这样执行两次自增操作,结果 count 只增加了 1。volatile 只能保证每次读取的是最新的值,但是线程没读取时是不知道其他线程修改的值的。

上面的问题的根源就是自增操作不是原子性操作,有下面几种方式把它变为原子性:(1)用 synchronized 修饰 increase() 方法(2)使用 ReentrantLock 把自增操作加锁(3)把 count 改为 AtomicInteger 类型,用 getAndIncrement() 原子操作自增。

volatile 禁止指令重排序

volatile 关键字能禁止指令重排序,所以 volatile 能在一定程度上保证有序性。

volatile 关键字禁止指令重排序有两层含义:

  • 当程序执行到 volatile 变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行

  • 在进行指令优化时,不能将在对 volatile 变量访问的语句放在其后面执行,也不能把 volatile 变量后面的语句放到其前面执行

看下单例模式的双重检查锁定写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Singleton {
private static volatile Singleton sInstance;
private Singleton() {}
public static synchronized Singleton getInstance() {
if(null == sInstance) { //第一次检查
synchronized(Singleton.class) {
if(null == sInstance) { //第二次检查
sInstance = new Singleton();
}
}
}
return sInstance;
}
}

为什么sInstance需要 volatile 关键字修饰,如果不修饰会可能会出现得到未初始化完成的对象,原因就是因为指令重排序。把sInstance = new Singleton();换成字节码伪代码:

1
2
3
memory=allocate(); //1: 分配对象的内存空间
ctorInstance(memory); //2: 初始化对象
sInstance=memory; //3: 设置sInstance指向刚分配的内存地址

但是在一些 JIT 编译器上,2 和 3 可能会重排序,变成下面这样:

1
2
3
memory=allocate(); //1: 分配对象的内存空间
sInstance=memory; //3: 设置sInstance指向刚分配的内存地址
ctorInstance(sInstance); //2: 初始化对象

重排序后,sInstance 可能会为未调用构造函数的对象,假设线程1 执行到上面代码第二步时,还没构造完成,但是 sInstance 非空,切换到线程2 调用getInstance()方法,因为 sInstance 非空就直接返回了,这样就会使用还没构造完成的对象。

但是用 volatile 关键字修饰了 sInstance 变量后,语句3 是 volatile 变量的写操作,不能将在对 volatile 变量访问的语句放在其后面执行,也就不允许语句2 放在语句3 后面,这样就能避免返回还未初始化完成的对象。

volatile 的实现原理

下面这段话摘自《深入理解 Java 虚拟机》:

“观察加入 volatile 关键字和没有加入 volatile 关键字时所生成的汇编代码发现,加入 volatile 关键字时,会多出一个 lock 前缀指令”

lock 前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供 3 个功能:

1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

2)它会强制将对缓存的修改操作立即写入主存;

3)如果是写操作,它会导致其他 CPU 中对应的缓存行无效。

参考文章

END
Johnny Shieh wechat
我的公众号,不只有技术,还有咖啡和彩蛋!