前言

单例模式应该算是 23 种设计模式中,最常见最容易考查的知识点了。常常会有面试官让手写单例模式,别到时候傻乎乎的说我不会。

之前,我有介绍过单例模式的几种常见写法。还不晓得的,传送门看这里:

设计模式之单例模式

本篇文章将开展一些不太容易想到的问题。带着你思考一下,传统的单例模式有哪些问题,并给出解决方案。让面试官眼中一亮,心道,小伙子有点货色啊!

以下,以 DCL 单例模式为例。

DCL 单例模式

DCL 就是 Double Check Lock 的缩写,即双重查看的同步锁。代码如下,

public class Singleton {    //留神,此变量须要用volatile润饰以避免指令重排序    private static volatile Singleton singleton = null;    private Singleton(){    }    public static Singleton getInstance(){        //进入办法内,先判断实例是否为空,以确定是否须要进入同步代码块        if(singleton == null){            synchronized (Singleton.class){                //进入同步代码块时再次判断实例是否为空                if(singleton == null){                    singleton = new Singleton();                }            }        }        return singleton;    }}

乍看,以上的写法没有什么问题,而且咱们的确也常常这样写。

然而,问题来了。

DCL 单例肯定能确保线程平安吗?

有的小伙伴就会说,你这不是废话么,大家不都这样写么,必定是线程平安的啊。

的确,在失常状况,我能够保障调用 getInstance 办法两次,拿到的是同一个对象。

然而,咱们晓得 Java 中有个很弱小的性能——反射。对的,没错,就是他。

通过反射,我就能够毁坏单例模式,从而调用它的构造函数,来创立不同的对象。

public class TestDCL {    public static void main(String[] args) throws Exception {        Singleton singleton1 = Singleton.getInstance();        System.out.println(singleton1.hashCode()); // 723074861        Class<Singleton> clazz = Singleton.class;        Constructor<Singleton> ctr = clazz.getDeclaredConstructor();        //通过反射拿到无参结构,设为可拜访        ctr.setAccessible(true);        Singleton singleton2 = ctr.newInstance();        System.out.println(singleton2.hashCode()); // 895328852    }}

咱们会发现,通过反射就能够间接调用无参构造函数创建对象。我管你结构器是不是公有的,反射之下没有隐衷。

打印出的 hashCode 不同,阐明了这是两个不同的对象。

那怎么避免反射毁坏单例呢?

很简略,既然你想通过无参结构来创建对象,那我就在构造函数里多判断一次。如果单例对象曾经创立好了,我就间接抛出异样,不让你创立就能够了。

批改构造函数如下,

再次运行测试代码,就会抛出异样。

无效的阻止了通过反射去创建对象。

那么,这样写单例就没问题了吗?

这时,伶俐的小伙伴必定就会说,既然问了,那就是有问题(可真是个小机灵鬼)。

然而,是有什么问题呢?

咱们晓得,对象还能够进行序列化反序列化。那如果我把单例对象序列化,再反序列化之后的对象,还是不是之前的单例对象呢?

实际出真知,咱们测试一下就晓得了。

// 给 Singleton 增加序列化的标记,表明能够序列化public class Singleton implements Serializable{     ... //省略不重要代码}//测试是否返回同一个对象public class TestDCL {    public static void main(String[] args) throws Exception {        Singleton singleton1 = Singleton.getInstance();        System.out.println(singleton1.hashCode()); // 723074861        //通过序列化对象,再反序列化失去新对象        String filePath = "D:\\singleton.txt";        saveToFile(singleton1,filePath);        Singleton singleton2 = getFromFile(filePath);        System.out.println(singleton2.hashCode()); // 1259475182    }    //将对象写入到文件    private static void saveToFile(Singleton singleton, String fileName){        try {            FileOutputStream fos = new FileOutputStream(fileName);            ObjectOutputStream oos = new ObjectOutputStream(fos);            oos.writeObject(singleton); //将对象写入oos            oos.close();        } catch (IOException e) {            e.printStackTrace();        }    }    //从文件中读取对象    private static Singleton getFromFile(String fileName){        try {            FileInputStream fis = new FileInputStream(fileName);            ObjectInputStream ois = new ObjectInputStream(fis);            return (Singleton) ois.readObject();        } catch (IOException e) {            e.printStackTrace();        } catch (ClassNotFoundException e) {            e.printStackTrace();        }        return null;    }}

能够发现,我把单例对象序列化之后,再反序列化之后失去的对象,和之前曾经不是同一个对象了。因而,就毁坏了单例。

那怎么解决这个问题呢?

我先说解决方案,一会儿解释为什么这样做能够。

很简略,在单例类中增加一个办法 readResolve 就能够了,办法体中让它返回咱们创立的单例对象。

而后再次运行测试类会发现,打印进去的 hashCode 码一样。

是不是很神奇。。。

readResolve 为什么能够解决序列化毁坏单例的问题?

咱们通过查看源码中一些要害的步骤,就能够解决心中的纳闷。

咱们思考一下,序列化和反序列化的过程中,哪个流程最有可能有操作空间。

首先,序列化时,就是把对象转为二进制存在 `ObjectOutputStream 流中。这里,貌似如同没有什么非凡的中央。

其次,那就只能看反序列化了。反序列化时,须要从 ObjectInputStream 对象中读取对象,失常读出来的对象是一个新的不同的对象,为什么这次就能读出一个雷同的对象呢,我猜这里会不会有什么猫腻?

应该是有可能的。所以,来到咱们写的办法 getFromFile中,找到这一行ois.readObject()。它就是从流中读取对象的办法。

点进去,查看 ObjectInputStream.readObject 办法,而后找到 readObject0()办法

再点进去,咱们发现有一个 switch 判断,找到 TC_OBJECT 分支。它是用来解决对象类型。

而后看到有一个 readOrdinaryObject办法,点进去。

而后找到这一行,isInstantiable() 办法,用来判断对象是否可实例化。

因为 cons 构造函数不为空,所以这个办法返回 true。因而结构进去一个 非空的 obj 对象 。

再往下走,调用,hasReadResolveMethod 办法去判断变量 readResolveMethod是否为非空。

咱们去看一下这个变量,在哪里有没有赋值。会发现有这样一段代码,

点进去这个办法 getInheritableMethod。发现它最初就是为了返回咱们增加的readResolve 办法。

同时咱们发现,这个办法的修饰符能够是 public , protected 或者 private(咱们以后用的就是private)。然而,不容许应用 static 和 abstract 润饰。

再次回到 readOrdinaryObject办法,持续往下走,会发现调用了 invokeReadResolve 办法。此办法,是通过反射调用 readResolve办法,失去了 rep 对象。

而后,判断 rep 是否和 obj 相等 。 obj 是方才咱们通过构造函数创立进去的新对象,而因为咱们重写了 readResolve 办法,间接返回了单例对象,因而 rep 就是原来的单例对象,和 obj 不相等。

于是,把 rep 赋值给 obj ,而后返回 obj。

所以,最终失去这个 obj 对象,就是咱们原来的单例对象。

至此,咱们就明确了是怎么一回事。

一句话总结就是:当从对象流 ObjectInputStream 中读取对象时,会查看对象的类否定义了 readResolve 办法。如果定义了,则调用它返回咱们想指定的对象(这里就指定了返回单例对象)。

总结

因而,残缺的 DCL 就能够这样写,

public class Singleton implements Serializable {    //留神,此变量须要用volatile润饰以避免指令重排序    private static volatile Singleton singleton = null;    private Singleton(){        if(singleton != null){            throw new RuntimeException("Can not do this");        }    }    public static Singleton getInstance(){        //进入办法内,先判断实例是否为空,以确定是否须要进入同步代码块        if(singleton == null){            synchronized (Singleton.class){                //进入同步代码块时再次判断实例是否为空                if(singleton == null){                    singleton = new Singleton();                }            }        }        return singleton;    }    // 定义readResolve办法,避免反序列化返回不同的对象    private Object readResolve(){        return singleton;    }}

另外,不晓得仔细的读者有没有发现,在看源码中 switch 分支有一个 case TC_ENUM 分支。这里,是对枚举类型进行的解决。

感兴趣的小伙伴能够去研读一下,最终的成果就是,咱们通过枚举去定义单例,就能够避免序列化毁坏单例。

微信搜「烟雨星空」,白嫖更多好文~