关于java:面试官看完我手写的单例直接惊呆了

39次阅读

共计 4411 个字符,预计需要花费 12 分钟才能阅读完成。

前言

单例模式应该算是 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 分支。这里,是对枚举类型进行的解决。

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

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

正文完
 0