关于单例模式
单例模式相信大家都不陌生,学习设计模式的时候,往往第一个要学习的就是单例模式。单例模式在Java中有许多实现,最常见的是“双重锁检测”、“静态内部类”以及“枚举”的实现方式。《Effective Java》推荐使用枚举的方式。
但今天要讨论是使用“双重锁检测”实现单例的时候,关于volatile关键字引发的一些探索和思考。限于篇幅原因,本文假设你已经了解以下知识:
- Java内存模型
- volatile关键字的内存语义
- synchronized同步锁的内存语义
- volatile和synchronized同步锁的happens-before规则
不使用volatile会有什么问题?
一个不使用volatile的双重锁检验单例模式大概长这样:
public class Singleton {
private static Singleton instance; // 不使用volatile关键字
// 双重锁检验
public static Singleton getInstance() {
if (instance == null) { // 第7行
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // 第10行
}
}
}
return instance;
}
}
复制代码
这个代码会有什么问题?我们知道,对一个锁的解锁happens-before随后对这个锁的加锁。粗略一看,上述代码是没有太大问题的。加锁操作并不能保证同步区内的代码不会发生重排序。对于第10行,是可能会被JVM分解和重排序的,也就是说:
instance = new Singleton(); // 第10行
// 可以分解为以下三个步骤
1 memory=allocate();// 分配内存 相当于c的malloc
2 ctorInstanc(memory) //初始化对象
3 s=memory //设置s指向刚分配的地址
// 上述三个步骤可能会被重排序为 1-3-2,也就是:
1 memory=allocate();// 分配内存 相当于c的malloc
3 s=memory //设置s指向刚分配的地址
2 ctorInstanc(memory) //初始化对象
复制代码
而一旦假设发生了这样的重排序,比如线程A在第10行执行了步骤1和步骤3,但是步骤2还没有执行完。这个时候线程A执行到了第7行,它会判定instance不为空,然后直接返回了一个未初始化完成的instance!
volatile如何解决这个问题?
针对上述问题,在Java 5 以后,JMM模型允许我们使用volatile关键字禁止这样的重排序。对于JMM的happens-before规则,即对一个volatile修饰的变量的写操作,happens-before随后对这个变量的读操作。所以我们可以在声明instance的时候,给它加上volatile关键字。
public class Singleton {
private static volatile Singleton instance; // 使用volatile关键字
// 双重锁检验
public static Singleton getInstance() {
if (instance == null) { // 第7行
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // 第10行
}
}
}
return instance;
}
}
复制代码
OK,问题似乎解决了。但是笔者心底仍然有一个疑问:假设没有使用volatile,真的会返回一个未初始化完成的实例吗?实例未初始化完成会怎样?
如果不加volatile,到底会发生什么?
先来看看一个Java对象实例化的过程:
1.先为对象分配空间,并按属性类型默认初始化
ps:八种基本数据类型,按照默认方式初始化,其他数据类型默认为null
2.父类属性的初始化(包括代码块,和属性按照代码顺序进行初始化)
3.父类构造函数初始化
4.子类属性的初始化(同父类一样)
5.子类构造函数的初始化
在好奇心的驱使下,我写了一个Demo代码做了一个实验:
// 单例代码
public class Singleton {
private static Singleton instance; // 不加volatile
private volatile boolean flag = false; // 一个flag来标识初始化是否完成
private Singleton() {
try {
Thread.sleep(1000);
flag = true;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 给客户端调用的,如果初始化未完成,应该返回false,如果完成,返回true
public boolean isFlag() {
return flag;
}
// 双重锁检查实现单例模式
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
复制代码
// 客户端代码
public class SingletonDemo {
private final static int THREAD_NUMBER = 1000; // 线程数量
private static class MyThread implements Runnable {
@Override
public void run() {
Singleton singleton = Singleton.getInstance();
if (!singleton.isFlag()) {
System.out.println("I am false!!!");
}
}
}
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < THREAD_NUMBER; i++) {
new Thread(new MyThread()).start();
}
}
}
复制代码
如果按照上述推断,有可能返回一个未初始化完成的实例的话,客户端调用isFlag()
方法是有可能返回false
的。
神奇的事情发生了,我反复调整了各种参数(线程数量和睡眠时间)并运行了多次,发现并没有打印出“I am false!!!”这句话!也就是说,那个地方没有发生我们理论上说的重排序!
究竟是什么原因呢?为什么没有发生重排序呢?
在网上找到这篇文章:The "Double-Checked Locking is Broken" Declaration,其中说到:如果使用Symantec JIT(一个基于句柄方式访问对象的编译器),它编译出来的代码就会发生上述的重排序。
笔者没有能够找到Symantec JIT或一个其它的基于句柄方式访问对象的编译器来实验。不过看了一下HotSpot的反编译结果。
我们用HotSpot的javap工具来反编译一下:
javac Singleton.java
javap -l -v Singleton.class
复制代码
public static communication.Singleton getInstance();
descriptor: ()Lcommunication/Singleton;
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=0
0: getstatic #8 // Field instance:Lcommunication/Singleton;
3: ifnonnull 37
6: ldc #9 // class communication/Singleton
8: dup
9: astore_0
10: monitorenter
11: getstatic #8 // Field instance:Lcommunication/Singleton;
14: ifnonnull 27
17: new #9 // class communication/Singleton
20: dup
21: invokespecial #10 // Method "<init>":()V
24: putstatic #8 // Field instance:Lcommunication/Singleton;
27: aload_0
28: monitorexit
// 省略
复制代码
从序号17到序号24应该就是new一个对象的过程。逐一解释一下:
- new: 在java堆上为对象分配内存空间,并将地址压入操作数栈顶;
- dup:复制操作数栈顶值,并将其压入栈顶,也就是说此时操作数栈上有连续相同的两个对象地址
- invokespecial:用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。
- putstatic:从栈顶取值,存入静态变量中
- aload_0:把this引用推入操作数栈
- monitorexit:释放锁
可以看到,它是先进行实例化,再存入到静态变量instance中。也就是说,这个地方没有发生之前说的重排序。
结论
再来看看Java访问对象的两种方式:使用句柄访问和直接访问。
再联想到之前说的可能出现的重排序结果,我们可能有这样一个猜想:只有句柄访问方式才有可能发生那种重排序。
如果我们使用一个基于直接访问对象的编译器(如HotSpot默认编译器),这个地方不加volatile关键字也不会出现问题。
而如果我们使用一个基于句柄方式访问对象的编译器(如Symantec JIT),不加volatile关键字可能会导致重排序,返回一个未初始化完成的实例。
此结论并不保证一定正确,只是基于目前现有的信息进行的猜想,如果要证实,可能还需要进一步实验。如果您有严瑾的理论或更详尽的实验数据,欢迎联系笔者。