你的单例模式是真的线程安全吗?

  熟悉设计模式的朋友应该都知道单例模式,这里不再对单例模式的基础进行介绍,本文重点在于解释为什么双重检查没有达到真正意义上的线程安全,当然也要介绍怎么达到真正的线程安全。
  本文的知识点主要来源:

  1.方腾飞、魏鹏、程晓明三位老师的《Java 并发编程的艺术》
  2.周志明老师的《深入理解Java虚拟机》

1. 传统的单例模式

  大家都知道,单例模式主要分为:懒汉模式和饿汉模式。当我们在使用单例模式时,考虑到延迟加载,懒汉模式肯定是必须的。但是懒汉模式有一个很大的缺点,那就是线程不安全。我们为了解决这个问题,发明了双重检查锁定的写法,如下:

public class SingleTon {
    private static SingleTon instance = null;
    private SingleTon(){

    }

    public static SingleTon getInstance(){
        if(instance == null){                   // 1
            synchronized (SingleTon.class){
                if(instance == null){
                    instance = new SingleTon(); // 2
                }
            }
        }
        return instance;
    }
}

  如上代码所示,如果第一次检查instnace不为null的话,那么就不需要去获取锁来进行instance的初始化。因此,看上去可以大大的降低synchronized带来的性能开销。
  双重检查锁定看上去非常的完美,但是却是一个错误的优化。当一个线程在执行到1时,读取到instance不为null时,instance引用的对象可能还没有初始化完毕。

2.问题的根源

  在前面的代码中,instance = new SingleTon();是用来创建对象。这一行代码可以分解为如下三行伪代码:

memory = allocate();   //1.分配对象内存空间 
ctorInstance(memory);  //2.初始化对象
instance = memory;     //3.设置instance指向刚分配的内存地址

  因为2和3不存在数据依赖性,所以可能会被重排序。2和3重排序之后的执行顺讯可能如下:

memory = allocate();   //1.分配对象内存空间 
instance = memory;     //3.设置instance指向刚分配的内存地址,注意,此时对象还没有被初始化
ctorInstance(memory);  //2.初始化对象

  这里可能有人对重排序存在疑惑,如果想要理解什么是重排序,为什么要重排序等等原因,强烈推荐:方腾飞、魏鹏、程晓明三位老师的《Java 并发编程的艺术》。这里我就不对这部分进行展开,主要是自己太菜了,害怕对这部分的解释不好。
  我们知道instance = new SingleTon()这一步可能会被重排序之后,现在我们来看看什么情况下能够导致问题。假设有两个线程,ThreadA和ThreadB,这两个线程都在调用SingleTone的getInstance方法来获取一个SingleTon的对象。执行顺序可能出现如下情况:

你的单例模式是真的线程安全吗?_第1张图片

  由于单线程内需要遵守intra-thread semantics,从而能保证ThreadA的执行结果不会被改变(所有线程在执行Java程序必须遵守intra-thread semantics,而intra-thread semantics保证所有的重排序在单线程里内,程序的执行结果不会被改变)。但是当ThreadB在按照上图的顺序在执行时,ThreadB将看到一个还没有被初始化的SingleTon对象。
  从我们的程序代码中可以看出来,当instance = new SingleTon()发生了重排序,ThreadB在if(instance == null) 判断出false,接下来将访问instace所引用的对象,但是此时这个对象可能还没有被ThreadA初始化完毕。

时间 ThreadA ThreadB
t1 A1:分配对象的内存空间
t2 A2:设置instance的执行内存空间
t3 B1:判断是否为空
t4 B2:由于instance不为null,ThreadB将访问instance引用的对象
t5 A2:初始化对象
t6 A4:访问instance引用的对象

  上表就是对上面的流程图的一个总结。我们知道,最后ThreadB可能会返回一个为未初始化的对象。
  在知道了问题的根源之后,我们可以想出两个办法来实现线程安全的延迟初始化。
  1.不允许2和3重排序。
  2.允许2和3重排序,但是不允许其他线程“看到”这个重排序。

3.解决方案

  前面解释了双重检查锁定问题的根源,并且列出了两种解决思路。这里,我们将对这两种思路进行展开。

(1).基于volatile的解决方案

  这个解决方案是非常的简单,只需要将我们之前的那个instance变量使用volatile关键字来修饰就行了。如下:

public class SingleTon {
    private volatile  static SingleTon instance = null;
    private SingleTon(){

    }

    public static SingleTon getInstance(){
        if(instance == null){                   //1
            synchronized (SingleTon.class){
                if(instance == null){
                    instance = new SingleTon(); //2
                }
            }
        }
        return instance;
    }
}

  是不是非常的简单?当然我们这里的目的当然不是简单的实现解决方案,而是详细的解释为什么需要这样做。

memory = allocate();   //1.分配对象内存空间 
ctorInstance(memory);  //2.初始化对象
instance = memory;     //3.设置instance指向刚分配的内存地址

  由于instance是volatile变量,所以上面的代码中3相当于是对volatile变量进行写的操作,也就是所谓的volatile写。根据《Java 并发编程的艺术》的P43,我们知道对于一个volatile写,编译器会在volatile写的前面加入一个StoreStore内存屏障,用来防止前面的普通写与下面的volatile写进行重排序;在volatile写的后面加入一个StoreLoad屏障,主要是防止上面的volatile写与下面的可能有的volatile读/写进行重排序。如下图:


你的单例模式是真的线程安全吗?_第2张图片

  从而我们可以得出,instance = new SingleTon()指令执行顺序图:


你的单例模式是真的线程安全吗?_第3张图片

  所以,我们可以得出,只要instance被volatile修饰了,2和3就不能重排序。可以得出新的时序图:
你的单例模式是真的线程安全吗?_第4张图片

  这样,我们通过上面解决方案中第一个方案来保证了线程安全的延迟加载。

(3).基于类初始化的解决方案

  我们知道,一个类的加载主要分为4步:验证、准备、解析、初始化。为了保证类的初始化(4步中的第四步)能够成功进行,JVM在初始化阶段进行多线程同步,也就是同一个时候,只能有一个线程来对一个类进行初始化。当然,我们还知道,一个类只能被初始化一次。
  基于这个特性,可以实现另一种线程安全的延迟加载的方案。代码如下:

public class SingleTon {
    private  static class InstanceHolder{
        public static final SingleTon instance = new SingleTon();
    }

    private SingleTon(){
    }

    public static SingleTon getInstance(){
        return InstanceHolder.instance;
    }
}

  当两个线程同时调用getInstance方法来获取instance对象时,如果此时InstanceHolder类还没有加载的话,首先会先加载这个类。
  这里简单的介绍一下,一个类加载的时机:
  1.首次创建该类的对象。
  2.访问该类的静态属性或者静态方法。
  3.使用Class.forName来加载一个类。
  所以当ThreadA和ThreadB首次获取instance对象时,InstanceHolder还没有加载,此时两个线程需要加载InstanceHolder类。由于类的加载是只能被一个线程执行,其他线程只能被阻塞住,所以当一个类被一个线程加载完毕之后,instance肯定是被初始化完毕了的,所以所有的线程看到的都是同一个instance对象。
  根据《Java 并发编程的艺术》一书中的介绍,类的初始化分为5个阶段:

A.第1阶段:通过在Class对象上的同步(即获取Class对象的初始化锁),来控制类或者接口的初始化。这个获取锁的线程会一直等待,知道线程能够获取这个初始化锁。

  我们假设,Class对象还没有被初始化(初始化状态为state,此时被标记为state = noInitialization),并且此时有两个线程(ThreadA和ThreadB)来尝试同时初始化这个对象。执行流程如下表:

时间 ThreadA ThreadB
t1 A1:尝试获取Class对象的初始化锁。这里假设ThreadA获取到了初始化锁 B1:尝试获取Class对象的初始化锁,由于ThreadA获取到了初始化,所以ThreadB将一直在等待获取初始化锁
t2 A2:ThreadA看到InstanceHolder 还没有被初始化(因为读取到state=noInitialization,ThreadA则设置state=initializing
t3 A3:线程A释放初始化锁
B.第2阶段:ThreadA执行类的初始化,同时ThreadB在初始化锁对应的condition上等待。
时间 ThreadA ThreadB
t1 A1:执行类的静态初始化和初始化类中声明的静态字段 B1:获取到初始化锁
t2 B2:读取到state=initializing
t3 B3:释放初始化锁
t3 B4:在初始化的condition中等待
C.第3阶段:ThreadA设置state=initialized,然后唤醒在condition中等待的所有线程。
时间 ThreadA
t1 A1:获取初始化锁
t2 A2:设置state=initialized
t3 A3:唤醒在condition中等待的所有线程
t4 A4:释放初始化锁
t5 A5:ThreadA的初始化处理过程完成
D.第4阶段:ThreadB结束类的初始化处理
时间 ThreadB
t1 B1:获取初始化锁
t2 B2:读取到state=initialized
t3 B3:释放初始化锁
t4 B4:ThreadB的类初始化处理过程完成
E.第5阶段:ThreadC执行类的初始化的处理。

  本来执行两个线程在竞争,这里ThreadC表示的意思是当ThreadA和ThreadB执行getInstance方法完毕之后,ThreadC才来调用getInstance方法。

时间 ThreadC
t1 C1:获取初始化锁
t2 C2:读取到state=initialized
t3 C3:释放初始化锁
t4 C4:ThreadC的类初始化处理过程完成

  这就是基于类初始化的解决方案的基本思路。从这里,我们可以看出来尽管instance = new SingleTon()被重排序,但是由于InstnaceHolder的初始化只能被一个线程执行,所以这里的重排序不会影响最终结果。当一个线程初始化操作完毕之后,其他的程线程看到instance对象肯定是一个初始化完毕的对象。

你可能感兴趣的:(你的单例模式是真的线程安全吗?)