大家都知道,java中的volatile具有:保证可见性、禁止指令重排序的功能,在面试中经常被问到DCL单例中加volatile关键字的作用,今天就此剖析一下它的原理。
首先贴一段DCL的代码:
public class Singleton {
private static volatile Singleton singleton;
private Singleton() {}
public static Singleton getInstance() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
在上述代码中:
1.第一个singleton==null的作用是判断是否为空,不为空直接返回单例。
2.添加synchronized关键字的作用是保证只有一个线程参与单例的初始化。
3.第二个singleton==null的作用是保证整个环境中只有一个单例被初始化,避免重复初始化:
譬如:某一时刻有N个线程调用此方法,其中只有一个线程可以进入被synchronized修饰的代码块中,其他N-1个线程都被阻塞在外,当第一个线程初始化单例完成后,释放锁,如果不加singleton==null的话就会造成重复。
那么如果不添加volatile关键字的话,就可能会出现指令重排序的问题;
当然,指令重排序在单线程的中不会有问题,因为指令重排序的定义就是:指令重排序不会影响单线程环境下的结果最终一致性。
其实也就是CPU为了提升执行指令的效率,交换指令的执行顺序,这在单线程环境下没有问题,但可能导致在多线程环境下无法保证一致性。
所以,此处的volatile关键字的作用就是禁止重排序。
那么,为什么有这个问题,我们通过getInstance的字节码来看一下:
0 getstatic #2 <Singleton;>
3 ifnonnull 37 (+34)
6 ldc #3 <Singleton>
8 dup
9 astore_0
10 monitorenter
11 getstatic #2 <Singleton;>
14 ifnonnull 27 (+13)
17 new #3 <Singleton>
20 dup
21 invokespecial #4 <Singleton.<init> : ()V>
24 putstatic #2 <Singleton;>
27 aload_0
28 monitorexit
29 goto 37 (+8)
32 astore_1
33 aload_0
34 monitorexit
35 aload_1
36 athrow
37 getstatic #2 <Singleton;>
40 areturn
其中,我们只需要看这部分代码就行了,下面这部分代码是singleton==new Singleton()的字节码
17 new #3 <Singleton>
20 dup
21 invokespecial #4 <Singleton.<init> : ()V>
24 putstatic #2 <Singleton;>
1.第17行的new指令代表为singleton对象在内存中开辟一块该对象所占大小的内存空间,在堆中。
2.第20行的dup指令的全称是duplicate,代表在线程栈中再复制一份刚开辟的内存空间的物理地址。为什么要复制物理内存地址?因为21行执行的invokespecial指令会弹出一个,如果只有一个并弹出,后续的引用赋值指令就没办法执行了。
3.第21行的invokespecial指令:调用对象的init方法,也就是无参构造方法。将一个内存地址从栈中弹出。
4.第24行的putstatic指令,赋值引用。
其中,第21行和24行可能会发生指令重排序,从而使singleton变量指向一个半初始化状态的对象,volatile关键字可以禁止指令重排序,从而保证了多线程环境下的结果最终一致性。
那么,volatile的原理是什么捏?
首先说一下,在CPU层面可以使用两种方式实现volatile的功能:
1.lfence(读屏障), sfence(写屏障), mfence(读写屏障),这三个指令貌似只有intel的CPU才有。
2.lock指令:总线锁:在同一时刻只有一个线程能操作内存(主存)中的数据,会导致其他CPU核心中的缓存失效(缓存一致性协议:MESI),缓存锁:锁住缓存行,其它核心不能缓存这个数据。所有cpu都有这个指令。
这是在CPU层面的,那么在JVM中,是通过内存屏障实现的。在虚拟机规范中,该规范要求所有的VM必须实现自己的内存屏障,并且该规范规定了8种不可指令重排序的场景,个人感觉不重要,看看了解即可:
- 单线程happen-before原则:在同一个线程中,书写在前面的操作happen-before后面的操作。
- 锁的happen-before原则:同一个锁的unlock操作happen-before此锁的lock操作。
- volatile的happen-before原则:对一个volatile变量的写操作happen-before对此变量的任意操作(当然也包括写操作了)。
- happen-before的传递性原则:如果A操作 happen-before B操作,B操作happen-before C操作,那么A操作happen-before C操作。
- 线程启动的happen-before原则:同一个线程的start方法happen-before此线程的其它方法。
- 线程中断的happen-before原则:对线程interrupt方法的调用happen-before被中断线程的检测到中断发送的代码。
- 线程终结的happen-before原则:线程中的所有操作都happen-before线程的终止检测。
- 对象创建的happen-before原则:一个对象的初始化完成先于他的finalize方法调用。
在hotspot虚拟机中,内存屏障的实现并没有使用lfence、sfence、mfence这种开销少的指令来实现内存屏障,而是选择了lock来实现。
具体代码在jdk源码中的bytecodeinterpreter.cpp中
if (cache->is_volatile()) {
if (support _IRIW_for_not_multiple_copy_atomic_cpu) {
OrderAccess::fence();
}
inline void OrderAccess::fence() {
if (os::is_MP()) {
// always use locked addl since mfence is sometimes expensive
#ifdef AMD64
__asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
__asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
}
}
如上,若是amd的cpu,向rsp寄存器加0
若是其他的cpu,向esp寄存器加0
上面的重点是lock指令,但是lock指令后面必需要跟一个指令才能执行,但是lock指令不能和nop指令一起执行,所以只能通过曲线救国,向某个寄存器中加0来实现。
若有错误,请指出,谢谢。