一个单例模式中volatile关键字引发的思考

关于单例模式

单例模式相信大家都不陌生,学习设计模式的时候,往往第一个要学习的就是单例模式。单例模式在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访问对象的两种方式:使用句柄访问和直接访问。

《一个单例模式中volatile关键字引发的思考》

《一个单例模式中volatile关键字引发的思考》

再联想到之前说的可能出现的重排序结果,我们可能有这样一个猜想:只有句柄访问方式才有可能发生那种重排序。

如果我们使用一个基于直接访问对象的编译器(如HotSpot默认编译器),这个地方不加volatile关键字也不会出现问题。

而如果我们使用一个基于句柄方式访问对象的编译器(如Symantec JIT),不加volatile关键字可能会导致重排序,返回一个未初始化完成的实例。

此结论并不保证一定正确,只是基于目前现有的信息进行的猜想,如果要证实,可能还需要进一步实验。如果您有严瑾的理论或更详尽的实验数据,欢迎联系笔者。

点赞