volatile关键字究竟是什么?本文结构如下:
* 通过实现线程安全的单例模式来引出volatile的作用
* 为什么要禁止重排序
* 为什么禁止重排序可以保障有序性和可见性
* volatile究竟有没有保证原子性
线程安全的单例模式
下面这段看似简单单例模式的实现包含了很多。
public class MutiThreadSingleton {
private static volatile MutiThreadSingleton instance;
private MutiThreadSingleton(){}
public static MutiThreadSingleton getInstance(){
if(instance==null){ //操作(1)
synchronized (MutiThreadSingleton.class){
if(instance==null){
instance = new MutiThreadSingleton();//操作(2)
}
}
}
return instance;
}
}
实现了单例模式,我们可以通过调用MutiThreadSingleton.getInstance()
来获得程序的唯一实例,我们采用延迟加载的方式,但我们需要这个实例时才实例化它。为了保证在多线程下我们的单例模式实现了真正的单例,我们采用了Java的同步控制机制。我们知道多线程下的线程安全是有条件的,即当我们访问同一个共享变量时(上例中为instance)我们必须持有同一把锁(上例中为MutiThreadSingleton.class对象的锁),而且任意一个线程在读和写这个线程的时候都必须持有同一把锁。如果我们调用方法来拿到单例时都遵循这样的规则当然不会有问题,instance也不用volatile修饰,问题是在调用方法时就采用同步控制开销太大了。像这样:
public static synchronized MutiThreadSingleton getInstance(){
if(instance==null){
instance = new MutiThreadSingleton();
}
return instance;
}
所以我们采用双重检查的方式来节省开销。可是问题就出在检查这里,当我们在执行临界区代码时,有线程在读instance变量。这使得线程安全的规则(如上文所述)被破坏。
如果没有用volatile来修饰instance会产生什么后果?
当一个线程a在执行操作(1)第一次检查instance是否为null,另一个线程b在执行操作(2)new对象。那么线程a 可能 读到的instance不为null,但是还没进行初始化。因为临界区里面的代码可以重排序 ,这是jvm和编译器对代码的优化,因为我们上文已经说了,锁对多线程的保障是有条件的。所以在这些条件的基础上进行优化当然没有问题,只要这个优化不破坏happens-before关系。new这个操作会分解成多条指令,比如分配内存,初始化,将引用赋给instance。如果重排序将初始化和引用赋值调换了顺序,那么当线程a检查instance时,他不为null,但却都是默认值而没有初始值。造成出错。
说了半天,回到主题,使用volatile就可以解决这样的问题。当volatile修饰instance时,临界区内的重排序被禁止。怎么做到?
- 在写volatile修饰的变量时,禁止这个写操作与其之前的任何读和写操作重排序,禁止这个写操作与其之后的任何写操作重排序。
- 在读volatile修饰的变量时,禁止这个读操作与其之前的任何读操作重排序,禁止这个读操作与其之后的任何读操作和写操作重排序。
为什么要这么做?
因为要保障volatile变量的有序性和可见性。
volatile long a=0;
public void doSomething(){
a=1;
if(a==0){doThing();}
}
如果a=1这个写操作和a==0这个读操作进行重排序,那么显然都会造成错误的结果。(事实上上面这两个操作并不会进行重排序,因为happens-before有一条规则就是程序顺序的指令之间也有happens-before关系,即指令或内存操作只有在不影响程序结果的情况下才能进行重排序,这里只是举个例子)
为什么禁止重排序就能保证可见性和有序性?
我们说可见性就是说一个线程对一个共享变量做的更新能够及时让其他线程看见。为什么一个线程对共享变量做的更新其他线程会看不见呢?这牵扯到处理器的结构。
图好像转过来了,晕~
为了降低处理器速度与内存操作之间执行速度差距带来程序性能影响,每个处理器都有缓存机制,我们把他抽象成一个高速缓存吧。这个高速缓存维护了一个拉链式的散列表,每一个缓存条目都有相应的缓存行和对应的内存地址以及一个标记字段。这里不再拓展,我们只需要知道他是用来缓存的就行了。但是其实,只是写进高速缓存还是会被其他处理器看见的。因为他们之间遵循缓存一致性协议,一个常用的协议是MESI协议。问题就在于MESI协议的引进确实使缓存都可见但是存在可以优化的地方。我们知道协议就是这样,你要保证大家都看得到那你就得等啊,那总不能一直等吧。所以引入了写缓存器和无效队列。要写的东西如果别人还没回复那就写进写缓存器,别人通知无效的变量也不立即执行,只是简单放到无效队列就回复知道了。这种机制就带来了重排序从而带来了可见性和有序性的问题。具体情况是怎样的这里我们不展开,我们只需要记住:
- 处理器支持哪种重排序就会提供那种禁止重排序的指令
这种指令我们称之为内存屏障。所以通过加内存屏障,那么处理器就回禁止重排序,那么volatile修饰的变量的可见性和有序性就可以得到保证了。所以volatile的实现就是通过内存屏障实现的,内存屏障就是jvm、编译器这两者和处理器之间的沟通纽带。
volatile究竟有没有保证原子性
volatile保证了long和double类型变量的原子性,long和double类型的操作不具有原子性这个想必都知道。但是这个说法其实有误区。volatile只是保证了long、double类型操作有原子性,也就是
volatile long a=0;
a=0,这样的操作具有原子性。
a++
a++像这样的操作就没有了。为什么?volatile又不是锁,要啥自行车啊?volatile没有排他性。我们说了他功能的实现是通过内存屏障,而锁是语言层面来保证排他的。像a++这样的涉及共享变量非原子操作(读后写),如果是多线程进行访问,那volatile没有保证其原子性。
最后推荐一本书 Java多线程编程实战指南(核心篇)
完。