双重检查锁模式为何会导致空指针异常及解决方案
单例模式的双重检查模式的一般实现如下。
/**
* 双重检查方式
*/
public class Singleton {
//私有构造方法
private Singleton() {}
private static Singleton instance;
//对外提供静态方法获取该对象
public static Singleton getInstance() {
//第一次判断,如果instance不为null,不进入抢锁阶段,直接返回实例
if(instance == null) {
synchronized (Singleton.class) {
//抢到锁之后再次判断是否为null
if(instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
这种实现方式在多线程高并发下可能会出现空指针异常,根本原因是JVM在编译或执行字节码时会优化进行指令重排。
如果构造函数中有很多操作,JVM可能会在实例对象所有属性未完全初始化时将对象返回。在对线程中可能导致其他线程获取的并不是完整的实例化对象,此时就会出现空指针异常。
要解决这个问题可以使用 volatile 关键字, volatile遵行happens-before原则,即:在读操作前,写操作必须全部完成。
/**
* 双重检查方式
*/
public class Singleton {
//私有构造方法
private Singleton() {}
private static volatile Singleton instance;
//对外提供静态方法获取该对象
public static Singleton getInstance() {
//第一次判断,如果instance不为null,不进入抢锁阶段,直接返回实际
if(instance == null) {
synchronized (Singleton.class) {
//抢到锁之后再次判断是否为空
if(instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
下面来详细介绍一下JVM的指令重排
每条指令都可以分为以下五个步骤
- 取指令
- 指令译码
- 执行指令
- 内存访问
- 数据写回
在不改变程序结果的情况下可以通过重排序来优化执行速度。
例如下面的例子
int a = 1;
int b = 2;
a = a + 1;
b = b + 1;
(我们先只考虑指令的存取操作)执行这段代码需要一下几条指令:Load a、Set to 1、Store a、Load b、Set to 2、Store b ......
经过指令重排后可以优化为
int a = 1;
a = a + 1;
int b = 2;
b = b + 1;
优化后可以减少一次Load a、Store a以及Load b、Store b
在单例模式中可能会出现如下情况
- 线程A执行getInstance()方法,发现instance为空,进入同步块。
- 线程A在同步块中创建instance对象,并赋值给instance变量。
- 由于JVM的指令重排优化,instance对象的初始化过程可能被分为三个步骤:(1)分配内存空间;(2)初始化对象;(3)将内存地址赋值给instance变量。但是这三个步骤的执行顺序并不一定按照1-2-3来进行,有可能是1-3-2。
- 如果线程A执行了1-3-2的顺序,那么在第三步完成后,instance变量就不再为空了,但是此时对象还没有完全初始化。
- 线程B执行getInstance()方法,发现instance不为空,直接返回instance对象。
- 线程B使用返回的instance对象调用其方法时,就可能出现空指针异常,因为此时对象还没有被正确初始化。