单例模式_02.破坏及防御

150 阅读6分钟

前言

上一篇介绍了单例模式的几种实现,基本可以放在实际开发中使用,但其实有些边界没有被考虑,从而无法保证单例模式的鲁棒性。

介绍几种破坏的方法和对应防御

所谓的边界,其实就是那些可以突破单例模式限制,创建多个实例的操作。其实我们已经在第一篇的懒加载实现中介绍过几个。此篇将着重讨论几种破坏单例模式的方式以及防御方式。

反射

在第一篇中我们了解到,单例模式下类的构造方法是需要私有化的,避免外部通过 new 创建实例。但我们忽略了Java有反射这种很开挂的特性。 我们以饿汉式为例,准备一个测试方法如下:

    @Test
    public void test_singleton_when_reflect() throws Exception {
        Singleton singleton01 = Singleton.getInstance();
        Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor(null);
        constructor.setAccessible(true);
        Singleton singleton02 = constructor.newInstance(null);
        Assert.assertSame("非单例",singleton01,singleton02);
    }

运行后得到的测试结果为:

java.lang.AssertionError: 非单例 expected same:<singleton.Singleton@6ae40994> was not:<singleton.Singleton@1a93a7ca>
Expected :singleton.Singleton@6ae40994
Actual   :singleton.Singleton@1a93a7ca

就像是三体中人类举全球力量打造的2000多战舰被三体的一个水滴轻松打败,反射真的是Java中开挂的存在。 为了防止这个挂,我们可以对Singleton的构造方法加以强化。

public class Singleton {
    private static Singleton instance = new Singleton();
    private Singleton() {
        if(instance != null) {
            throw new RuntimeException("此单例对象已存在,禁止非法调用构造器!");
        }
    }
    public static Singleton getInstance() {
        return instance;
    }
}

再次执行测试方法,就会得到这样的结果:

java.lang.reflect.InvocationTargetException
    ...
    at singleton.SingletonTest.test_reflect(SingletonTest.java:16)
    ...
Caused by: java.lang.RuntimeException: 此单例对象已存在,禁止非法调用构造器!
    at singleton.Singleton.<init>(Singleton.java:8)
    ... 30 more

当通过反射去强行调用构造器时就会在构造中报错,无法完成实例化。

反序列化

多数情况下,我们需要把对象的状态信息通过网络进行传输,或者需要将对象的状态信息持久化,以便将来使用时将其进行反序列化。关于序列化可以参考《序列化理解起来很简单》。 这其实也是对单例的保持有破坏的风险。我们通过一个测试方法观察下:

    @Test
    public void test_singleton_when_serialize() throws Exception {
        Singleton singleton01 = Singleton.getInstance();
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream("file"));
        objectOutputStream.writeObject(singleton01);
        File file = new File("file");
        ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream(file));
        Singleton singleton02 = (Singleton) objectInputStream.readObject();
        Assert.assertSame("非单例",singleton01,singleton02);
    }

执行得到结果:

java.lang.AssertionError: 非单例 expected same:<singleton.Singleton@2b05039f> was not:<singleton.Singleton@21588809>
Expected :singleton.Singleton@2b05039f
Actual   :singleton.Singleton@21588809

此时,代码中将流反序列化为对象的操作,已经破坏了单例模式。所以在涉及到序列化的场景时,要格外注意对单例的破坏。 为了保持单例,需要在Singleton中定义readResolve:

public class Singleton implements Serializable {
    ...
    private Object readResolve() {
        throw new RuntimeException("单例模式,禁止破坏");
    }
}

再次执行单元测试,得到下面的结果:

java.lang.RuntimeException: 单例模式,禁止破坏
   at singleton.Singleton.readResolve(Singleton.java:16)
    ...
   at singleton.SingletonTest.test_singleton_when_serialize(SingletonTest.java:28)
   ...

前文说到"定义readResolve",在开始接触单例的资料前确实很迷惑。 平时我们接触到的这些不知道从哪儿突然冒出来的吾称为"约定的方法",都是进行重写。而在这个场景下,Singleton唯一实现的Serializable接口是没有方法的。 那为什么说是定义这个方法呢?我们可以单元测试中反序列化操作,即ObjectInputStream的对象执行readObject()方法作为切入点追踪下源码。 当追踪到readOrdinaryObject(boolean unshared)方法时,如图所示:

img_1.png 方法体中出现了两个调用:desc.hasReadResolveMethod()desc.invokeReadResolve(obj) 理解为对目标流进行检查是否有readResolve()这个方法,如果有就调用。 所以在这里说是”定义“其实是比较准确的,一些资料中写”重写readResolve“,个人认为欠妥(支撑这个观点的一个论据是在readResolve方法上显式地加@Override,编译器是会报错的)。

clone

还有一种方式可以破坏单例模式,就是通过对象拷贝。 需要实现一个Cloneable接口,如果要调用拷贝,需要显式地在Singleton中重写Object类的clone()方法:

public class Singleton implements Serializable,Cloneable {
    ...
    @Override
    protected Singleton clone() {
        return (Singleton)super.clone();
    }
}

这个对于想实现单例的开发者就很吊诡。 既要单例,又要做到对象拷贝。难怪大部分资料称之为主动破坏单例模式。 同样,我们通过一个单元测试观察下:

    @Test
    public void test_singleton_when_clone() throws Exception {
        Singleton singleton01 = Singleton.getInstance();
        Singleton singleton02 = singleton01.clone();
        Assert.assertSame("非单例",singleton01,singleton02);
    }

执行得到结果:

java.lang.AssertionError: 非单例 expected same:<singleton.Singleton@6ae40994> was not:<singleton.Singleton@1a93a7ca>
Expected :singleton.Singleton@6ae40994
Actual   :singleton.Singleton@1a93a7ca

那如果想防止这种破坏呢,还是可以在clone()方法中做拦截:

public class Singleton implements Serializable,Cloneable {
    ...
    @Override
    protected Singleton clone() throws CloneNotSupportedException {
        throw new RuntimeException("单例模式,禁止破坏");
    }
}

再次执行单元测试,就得到:

java.lang.RuntimeException: 单例模式,禁止破坏
   at singleton.Singleton.clone(Singleton.java:21)
   at singleton.SingletonTest.test_singleton_when_clone(SingletonTest.java:35)
   ...

但是,谁会这么无聊,主动去开启破坏然后又拦截掉呢?这不是有病吗?

总结

这篇笔记描述了几种破坏单例的方式以及应对,首先需要注意的是上一篇中考量单例模式的第一点就是将构造器私有化,脱离了这个前提,单例形同虚设,也无需谈论什么破坏。 我们可以通过强化构造器,对用反射非法实例化的方式进行拦截(本篇主要以饿汉式为例,懒汉式需要通过一个计数器,在构造器中对计数器做Double-Check,计数超过1就抛出异常拦截)。 很多时候需要进行对象序列化,在后续反序列化时,也会对单例进行破坏,可以定义一个readResolve()方法,在方法体中拦截破坏。 另外一种则是我们主动去实现Cloneable接口,重写Object的clone()方法,去生成另一个实例,称为开发者的"主动破坏"。 到此,单例模式的介绍应该结束了。其实还有一个小细节,是我从开始一直在考虑的,即我们在拦截单例破坏时,需要抛出异常呢,还是返回我们原本的单例呢? 当然两种都是可以实现的,有些资料中甚至在拦截反序列化时返回实例,在拦截clone()时抛出异常,这就很不能忍。 个人观点,当然也已体现在本篇笔记的代码中,还是要保持一致,统一返回单例或者统一抛出异常,在此基础上,我更倾向于抛出异常。因为无论开发者是想通过反射、反序列化还是clone,都期望能得到新的实例。 而返回单例在运行时并没有明显的错误,可能只有在业务层面看得到对象所包含的信息的差距,令开发者误解为已经成功破坏了单例。通过报错,可以在运行时提醒开发者快速了解到对于这个类存在使用不当,一定程度上是符合fail-fast原则的。 emm。。他们是破坏者啊,为什么要替他们考虑。。。