volatile与可见性
我们还是先来看一个例子,这个例子在synchronized
这篇文章中用过:
1 | public class IntegerIncrementer { |
我们之前是用synchronized
关键字让现场在修改完变量后强制刷新内存,让修改结果对其他线程可见。其实volatile
可以达到同样的效果,即修改后的变量可以保证对其他线程可见。
具体实现的原理是,对volatile
进行的操作最后在汇编语言中会带有Lock前缀,Lock前缀具有以下的作用:
- 把当前变量所在CPU缓存行的数据写回内存。
- 让其他CPU包含了该地址的缓存行失效,也就是线程下一次读取共享变量必须强制去内存读取。
注意缓存行是CPU缓存中最小的读写单位,一般一个缓存行中会包含多个共享变量,并且这些变量之间可以没有任何关系。频繁地使缓存失效会降低缓存命中率,从而影响性能。这就是伪共享的问题,伪共享可以通过字节填充来解决,这里不展开讨论。
总之volatile
可以通过这样的机制来保证可见性。
volatile与有序性
volatile
的另一个特性是可以禁止指令的重排,我们来看这个例子:
1 | public class Demo { |
乍看之下,似乎最后打印出来的是42, 但是我们还是在上面那篇文章中介绍过,编译器和处理器会在遵从as-if-serial
的语义下对指令进行重排。代码14和15行之前没有任何依赖关系,那么完全可能出现:
- 线程A先执行15行,CPU time结束,发生context switch。
- 线程B执行
run()
方法,打印出来的结果是0。
volatile
是通过插入内存屏障来防止重排,这里我们不展开讨论。一般而言,其会禁止下列重排:
- 第二个操作是
volatile
写,第一个操作为普通读写时,禁止重排序。 - 第一个操作是
volatile
读,第二个操作位普通读写时,进制重排序。 - 禁止对
volatile
修饰的变量读写相关操作的重排序。
那么为什么要禁止普通读写和volatile
读写之间的重排序呢?我们还是举上面的例子:
1 | public class Demo { |
这里ready
已经被volatile
修饰,那么假设不禁止情况1下的重排,那么第14和15行由于没有数据依赖,仍然有可能重排,那么可能出现以下情况:
- 线程A先执行15行,发生context switch。
- 线程B执行
run()
方法,读取DB的时候key不存在,或者读到stale data。
对于情况2,类似地,第6行和第7行的顺序可能重排,第7行的语句可能先被执行,将结果缓存,等到第6行判断为真的时候再赋值,那么可能出现这种情况:
- 线程A执行第7行,有可能读到stale data,缓存结果,发生context switch。
- 线程B执行
main
方法,更新key的值。 - 线程A执行第6行,判断为真,将缓存的stale data赋值给
number
。
因为会出现以上的情况,所以我们需要禁止普通读写和volatile
读写的重排序。那么对volatile
修饰变量操作重排序的禁止,有一个大家耳熟能详的例子,就是DCL(Double Check Lock):
1 | public class DCLDemo { |
以上是线程安全版的Singleton的实现方法,其中有两个细节:
instance == null
的检查要做两次,因为第一次检查完到获取锁中间,可能有其他线程已经完成了实例化从同步块中出来了。instance
需要用volatile
修饰。
这里volatile
的作用是防止指令的重排序,因为new
的操作不是原子性的,它会分为三步:
- 分配内存区间。
- 调用构造函数初始化实例。
- 将初始化完毕的实例赋值给引用。
所以其中2和3是可能重排,那么一旦执行顺序变为1->3->2,那么其他线程进来的时候,instance
可能并不为null
,但是此时publish的实例是没有初始化完成的,就很有可能出错。所以我们必须用volatile
修饰instance
来防止可能的指令重排。
volatile与原子性
volatile
并不对原子性有任何保证,所以需要原子性的时候,需要和其他保证原子性的关键字搭配使用,比如synchronized
。
但是值得一提的是volatile
修饰的64位变量(如long,double),其读写操作在32位JVM中会具有原子性。