1、介绍
单例模式是最常用的设计模式,并且双锁单例模式也是面试的常考题目。本文详细介绍了双锁单例模式,并总结了双锁单例模式的三个常考问题。
public class Singleton {
private volatile static Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();//erro
}
}
}
return instance;
}
public static void main(String[] args) {
Singleton.getInstance();
}
}
2、提出问题
- 为什么双重检验 (即为什么使用了两个if语句)
- 为什么加双锁(即为什么同时使用了synchronized关键字和volatile关键字)
- JDK版本为什么要大于1.5
2.1 为什么双重检验
public static Singleton getInstance() {
if (instance == null) {//线程1,2同时到达,均通过(instance == null)判断。
// 线程1进入下面的同步块,线程2被阻塞
synchronized (Singleton.class) {
if (instance == null) {//线程1执行发现instance为null,初始化实例后,释放锁。
// 线程2进入同步块,此次instance已经被初始化。无法通过if条件,避免多次重复初始化。
instance = new Singleton();
}
}
}
return instance;
}
执行双重检测是因为,如果多个线程通过了第一次检测,此时因为synchronized,其中一个线程会首先通过了第二次检测并实例化了对象,剩余的线程不会再重复实例化对象。这样,除了初始化的时候会加锁,后续的调用都是直接返回,解决了多余的性能消耗。
小结:
- 外层判断:完成实例化后,之后的线程就不需要再执行synchronized等待,提高效率。
- 内层判断:防止多次实例化。
2.2 为什么加双锁
if (instance == null) {
instance = new Singleton();//erro
}
如果不使用volatile关键字,隐患来自于上述代码中注释了 erro 的一行,这行代码大致有以下三个步骤:
- 在堆中开辟对象所需空间,分配地址
- 根据类加载的初始化顺序进行初始化
- 将内存地址返回给栈中的引用变量
由于 Java 内存模型允许“无序写入”,有些编译器因为性能原因,可能会把上述步骤中的 2 和 3 进行重排序,顺序就成了
- 在堆中开辟对象所需空间,分配地址
- 将内存地址返回给栈中的引用变量(此时变量已不在为null,但是变量却并没有初始化完成)
- 根据类加载的初始化顺序进行初始化
通过对比发现,关键变化在于有volatile修饰的变量,赋值后(前面mov%eax,0x150(%esi)这句便是赋值操作)多执行了一个“lock addl$0x0,(%esp)”操作,这个操作的作用相当于一个内存屏障(Memory Barrier或Memory Fence,指重排序时不能把后面的指令重排序到内存屏障之前的位置)。
小结:
这里简单解释一下,在putstatic操作之前设置内存屏障,保证之前的3步骤无法重排在2步骤之前。
2.3JDK版本大于1.5
- volatile关键字的屏蔽指令重排的语义在JDK1.5中才被完全修复。
- JDK5 以及后续版本扩展了volatile语义,不再允许volatile写操作与其前面的读写操作重排序,也不允许volatile读操作与其后面的读写操作重排序。