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

简介: 单例模式相信大家都不陌生,学习设计模式的时候,往往第一个要学习的就是单例模式。单例模式在Java中有许多实现,最常见的是“双重锁检测”、“静态内部类”以及“枚举”的实现方式。《Effective Java》推荐使用枚举的方式。

关于单例模式


单例模式相信大家都不陌生,学习设计模式的时候,往往第一个要学习的就是单例模式。单例模式在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还没有执行完。这个时候线程B执行到了第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关键字可能会导致重排序,返回一个未初始化完成的实例。

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

目录
相关文章
|
6月前
|
缓存 编译器 C语言
一起来探讨volatile关键字
在C语言中,volatile是一个关键字,用于告诉编译器不要对被声明为volatile的变量做优化,以确保每次对该变量的读写都直接操作内存。
|
3月前
|
存储 Java 编译器
|
4月前
|
微服务
多线程内存模型问题之在单例模式中,volatile关键字的作用是什么
多线程内存模型问题之在单例模式中,volatile关键字的作用是什么
|
6月前
|
存储 缓存 Java
Java volatile关键字-单例模式的双重锁为什么要加volatile
Java volatile关键字--单例模式的双重锁为什么要加volatile
77 10
|
安全 Java 编译器
volatile 与 synchronized 关键字的区别?
volatile 与 synchronized 关键字的区别?
54 0
|
算法 安全 Java
多线程之volatile关键字
多线程之volatile关键字
|
存储 Java
浅谈Volatile关键字
该篇文章用来总结笔者对于Volatile关键字的理解,并不会太过深入的探讨。
135 0
浅谈Volatile关键字
|
存储 SQL 缓存
JUC系列(八)Java内存模型 volatile关键字与单例模式实践
JMM里有对于线程交换资源的一些约定 理解可以更好的参透JUC的内容 Volatile可以保证可见性和阻止操作系统的指令重排 理解多个不同的单例模式的实现方法
JUC系列(八)Java内存模型 volatile关键字与单例模式实践
|
存储 缓存 编译器
volatile关键字(1)
volatile关键字(1)
117 0
volatile关键字(1)
|
存储 缓存
volatile关键字(2)
volatile关键字(2)
99 0
volatile关键字(2)