单例模式-DCL双重锁检查实现及原理刨析

  以我的经验为例(如有不对欢迎指正),在生产过程中,经常会遇到下面两种情况:
  1.封装的某个类不包含具有具体业务含义的类成员变量,是对业务动作的封装,如MVC中的各层(HTTPRequest对象以Threadlocal方式传递进来的)。

  2.某个类具有全局意义,一旦实例化为对象则对象可被全局使用。如某个类封装了全球的地理位置信息及获取某位置信息的方法(不考虑地球爆炸,板块移动),信息不会变动且可被全局使用。

  3.许多时候整个系统只需要拥有一个的全局对象,这样有利于我们协调系统整体的行为。如用来封装全局配置信息的类。

  上述三种情况下如果每次使用都创建一个新的对象,且使用频率较高或类对象体积较大时,对象频繁的创建和GC会造成极大的资源浪费,同时不利于对系统整体行为的协调。此时便需要考虑使用单例模式来达到对象复用的目的。

  在看单例模式的实现前我们先来看一下使用单例模式需要注意的四大原则:

  1.构造私有。(阻止类被通过常规方法实例化)

  2.以静态方法或者枚举返回实例。(保证实例的唯一性)

  3.确保实例只有一个,尤其是多线程环境。(确保在创建实例时的线程安全)

  4.确保反序列化时不会重新构建对象。(在有序列化反序列化的场景下防止单例被莫名破坏,造成未考虑到的后果)

  目前单例模式的实现方式有很多种,我们仅讨论接受度最为广泛的DCL方式静态内部类方式(本篇讨论DCL)

  DCL(Double Check Lock)双重锁检查

  我们直接来看代码:

  

   我们直接看核心方法getCar(),可以看到我们在new Car()之前用了两次if判断,一个在同步块外、一个在同步块内。我们来模拟一下创建新实例的过程:

  1. 第一次判断单例对象的引用是否指向null,如果不为null则直接返回car,为null则进入下面的同步块实例化新的对象。

  2. 获取Car.class对象的对象锁,在多线程的情况下同一时间只有一个线程会获得锁并进入同步块,执行同步块内的代码。

  3. 进入同步块后,在此判断单例对象的引用是否指向null。这里再次判断的意义是:虽然我们在进入同步块前进行了一次判断,但在我们等待获取锁的过程中,其它某个已经获得锁的线程可能已经执行了同步块内的内容,为car创建了实例。所以在进入同步块后我们需要进行第二次判断,如果本次判断car的指向依然为null,那么此时我们确定,只有当前线程在可以改变car指向的同步块内,且car此时的指向为null。那么此时我们可以放心大胆的执行实例化的动作。

  4. 当前线程实例化对象完成并退出同步块,此时其它阻塞在等待锁位置的线程(已经进行了第一次if判断判断结果为true)获得锁并进入同步块,进行第二次if检查,发现car已经指向了某个实例,不在进行实例化动作。

  由于Car类的构造方法被我们重写为私有方法,我们只能使用上述Car.getCar()方法来获得实例(不能在其它类中随便new),这样便保证了实例的唯一性。做完这些,我们保证了多线程环境下获取实例的唯一性,但还有一点没有做完:保证反序列化不会破坏实例的唯一性。这点经常被忽略但非常重要,我们在使用单例模式来设计一个类时,从源头认为该类的实例全局只存在一个。但如果在序列化反序列化的场景下破坏了实例的唯一性,会导致难以排查的错误发生。虽然大部分情况下,我们不会让单例的类实现Serializable、Externalizable接口,但在继承关系复杂的情况下我们的单例类难免不是继承自实现了Serializable、Externalizable接口的祖先。

  我们跟随反序列化时调用的readObject()的源码看一下调用栈:

private Object readOrdinaryObject(boolean unshared) throws 
 IOException {
        //此处省略部分代码
        Object obj;
        try {
            obj = desc.isInstantiable() ? desc.newInstance() : null;
        } catch (Exception ex) {
            throw (IOException) new InvalidClassException(
                desc.forClass().getName(),
                "unable to create instance").initCause(ex);
        }
        //此处省略部分代码
        if (obj != null &&handles.lookupException(passHandle) == null &&desc.hasReadResolveMethod()){
            Object rep = desc.invokeReadResolve(obj);
            if (unshared && rep.getClass().isArray()) {
                rep = cloneArray(rep);
            }
            if (rep != obj) {
                handles.setObject(passHandle, obj = rep);
            }
        }
        return obj;
    }

  我们可以看到第六行:obj = desc.isInstantiable() ? desc.newInstance() : null;

  如果desc是可序列化的,那么通过反射调用无参构造方法来生成一个新的对象,这里提供了除getCar()方法外第二个为对象生成新实例的入口,破坏了我们实例的唯一性。

  我们接着往下看,if (obj != null &&handles.lookupException(passHandle) == null &&desc.hasReadResolveMethod())--->如果被实例化对象包含readResolve()方法;

  Object rep = desc.invokeReadResolve(obj);

  handles.setObject(passHandle, obj = rep);-------->调用被实例化对象的readResolve()生成新的对象并返回。

  那么我们重写一下readResolve()方法,使其返回我们的唯一实例,这样就防止了反序列化对单例的破坏。

  

  注意这里我们没有用@Override来修饰该方法,因为我们并不知道该类的继承链上是否实现过Serializable、Externalizable接口,或者暂时没有实现但不确定以后是否会修改为实现序列化接口。实现过便重写,未实现过就当拿这个方法祭天了。

  到这里感觉已经做了不少工作,但是还是漏了非常重要的一点:由JVM的指令重排序导致的著名DCL失效问题(JVM的乱序执行功能)。

  问题具体是这样的,JVM为了优化运行效率会自以为是的对我们的指令进行重排序或者忽略它认为无效的指令(如空循环),对于对象的创建是分为以下三步的:

  1.在堆内存开辟内存空间。
  2.在堆内存中实例化Car里面的各个参数。
  3.把对象指向堆内存空间。

  其中第二步与第三步可能会被乱序执行,此时如果一个线程在同步块内实例化对象,执行的第三步(没有执行第二步),而另一个线程刚好在判断引用的指向是否为null。由于已经执行了三,所以即使对象未被实例化但依然会被返回并被使用,从而出现异常。另一方面,多核多线程情况下,由于CPU多级缓存的存在,变量的修改对于各个线程的可见性不能得到保证。比如A线程已经实例化了对象,但引用的指向只是再执行A线程的CPU多级缓存中,并未同步到主存。那么对于跑在其它CPU的线程来说,car的指向依然为null!

  官方也发现了这个问题并在1.5版本修复了该问题,只要将变量声明为volatile,对于volatile变量的读写前后都会加入内存屏障来防止读写操作与前后的其它指令重排序。

同时volatile还保证了多核多线程环境下,变量对各个CPU的缓存一致性即各个线程间变量的可见性。

  volatile的变量是说这变量可能会被意想不到地改变,这样,编译器就不会去假设这个变量的值了。为了实现 volatile 内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。但对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM 采取了保守的策略。

  1. 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。

  2. 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。

  3. 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。

  4. 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。

  volatile 写是在前面和后面分别插入内存屏障,而 volatile 读操作是在后面插入两个内存屏障。

内存屏障说明
StoreStore 屏障 禁止上面的普通写和下面的 volatile 写重排序。
StoreLoad 屏障 防止上面的 volatile 写与下面可能有的 volatile 读/写重排序。
LoadLoad 屏障 禁止下面所有的普通读操作和上面的 volatile 读重排序。
LoadStore 屏障 禁止下面所有的普通写操作和上面的 volatile 读重排序。

  当对非 volatile 变量进行读写的时候,每个线程先从内存拷贝变量到CPU缓存中。如果计算机有多个CPU,每个线程可能在不同的CPU上被处理,这意味着每个线程可以拷贝到不同的 CPU cache 中。而声明变量是 volatile 的,JVM 保证了每次读变量都从内存中读,跳过 CPU cache 这一步

  以上两点保证了多核多线程环境下变量对各线程的可见性以及对变量读写指令的有序性,这样一个完整的DCL单例类便写完了,完整代码如下:

package learning;

import java.io.Serializable;

public class Car {
    //构造函数私有,禁止通过常规方式实例化
    private Car(){}
    //单例对象的引用
    static  volatile Car car=null;
    //DCL获取单例对象
    static Car getCar(){
        if(car==null){
            synchronized(Car.class){
                if(car==null){
                    car=new Car();
                }
            }
        }
        return car;
    }

    private Object readResolve() {
        return getCar();
    }
}

  我们写个测试样例跑跑看,用三条线程重复调用getCar()方法获取实例,每条获取20000次。如果获得的不是唯一实例则抛出异常。下面是线程实体类:

   

  主函数:

  

   运行结果,三条线程都没有抛出异常,说明该方式在多线程的情况下是保证了单例的正确性的:

  

  

  

  总结一下,在使用DCL时应注意的点:

  1. volatile修饰实例引用,保证在多核多线程的情况下引用的值对各个线程的可见性,以及禁止为引用赋值时指令的重排序。

  2. 在进入同步块前后都要进行判空。进入前判空时逻辑需要,进入后判空时防止等待锁的过程中其它线程改变了引用的值导致第一次判空无效。

  3. 注意防止反序列化对单例的破坏,通过重写readResolve()方法实现。

  4. 定义私有构造函数,封锁其它类直接创建实例的入口。

 

END$$

posted @ 2019-11-19 19:41  牛有肉  阅读(1547)  评论(0编辑  收藏  举报