12、单例模式进阶:序列化破坏单例、反射破坏单例、如何防止单例被破坏
单例模式,说白了就是保证一个类在整个系统里只有一个实例。很多同学觉得这玩意儿简单,写个双重检查锁就完事了。嗯,我当年也是这么想的,直到在生产环境里踩了坑。
今天咱们聊聊单例模式的两个“隐形杀手”——序列化和反射。这两个东西,稍不注意就能把你的单例撕得粉碎。
12.1 序列化如何破坏单例?
先看一个最常见的场景。你写了一个单例类,实现了 Serializable 接口,然后通过反序列化获取对象。你猜怎么着?每次反序列化都会创建一个新实例。
为什么会这样?因为 Java 的反序列化机制,底层是通过 ObjectInputStream.readObject() 来创建对象的。它不会调用你的构造方法,而是直接在内存里“复制”出一个对象来。你的单例控制,在它面前形同虚设。
我在项目中遇到过这个问题。当时我们做了一个配置管理中心,用单例模式加载全局配置。后来为了支持分布式缓存,把配置对象序列化到了 Redis 里。结果每次从 Redis 取回来,配置对象都变成了新实例,导致缓存更新逻辑全乱了。
核心问题:反序列化会绕过构造方法,直接创建新对象。
解决方案:readResolve() 方法
Java 其实给我们留了一个后门——readResolve() 方法。当反序列化发生时,JVM 会检查这个类有没有定义 readResolve(),如果有,就调用它返回的对象,而不是新创建的那个。
public class Singleton implements Serializable {
private static final Singleton INSTANCE = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return INSTANCE;
}
// 防止反序列化破坏单例
private Object readResolve() {
return INSTANCE;
}
}
注意看,readResolve() 方法直接返回了已有的单例对象。这样反序列化时,虽然底层还是创建了新对象,但最终返回的是我们指定的那个。新创建的对象会被 GC 回收掉。
个人建议:只要你的单例类实现了 Serializable,就一定要加上 readResolve() 方法。别偷懒,我吃过亏。
12.2 反射如何破坏单例?
反射破坏单例,比序列化更“暴力”。它可以直接调用你的私有构造方法,想创建多少个实例就创建多少个。
你想想看,我们写单例模式时,构造方法都是 private 的。但反射可以通过 setAccessible(true) 强行把私有方法变成可访问的。然后 newInstance() 一调用,新实例就出来了。
// 反射破坏单例的示例
Class<?> clazz = Singleton.class;
Constructor<?> constructor = clazz.getDeclaredConstructor();
constructor.setAccessible(true); // 强行访问私有构造方法
Singleton s1 = Singleton.getInstance();
Singleton s2 = (Singleton) constructor.newInstance(); // 创建新实例
System.out.println(s1 == s2); // false,单例被破坏了
我曾经在一个框架里看到过这种问题。框架用了反射来做依赖注入,结果把单例类的构造方法给调了,导致整个系统的状态管理全乱套了。
注意:反射破坏单例是“无声”的,不会报错,但你的单例已经名存实亡了。
解决方案:在构造方法中加防反射检查
最直接的办法,就是在构造方法里加一个标志位。第一次调用构造方法时正常创建,第二次调用就直接抛异常。
public class Singleton {
private static boolean flag = false;
private static final Singleton INSTANCE = new Singleton();
private Singleton() {
// 防反射检查
if (flag) {
throw new RuntimeException("不要用反射破坏单例!");
}
flag = true;
}
public static Singleton getInstance() {
return INSTANCE;
}
}
这个思路其实很简单——用 flag 记录构造方法是否被调用过。第一次调用时 flag 为 false,正常执行并设为 true。第二次再调用,不管是通过反射还是其他方式,都会抛出异常。
避坑指南:我曾经用 AtomicBoolean 替代 boolean 来做这个标志位,因为在高并发场景下,boolean 的可见性可能有问题。虽然构造方法一般不会并发调用,但养成好习惯总没错。
12.3 终极方案:枚举单例
说了这么多,有没有一种方式,既能防序列化,又能防反射?有,就是枚举单例。
Java 枚举天生就是单例的。JVM 保证了枚举实例只会被创建一次,而且枚举的序列化和反射都有特殊处理——反序列化时不会创建新实例,反射也无法调用枚举的构造方法。
public enum SingletonEnum {
INSTANCE;
public void doSomething() {
System.out.println("我是枚举单例,谁也破坏不了我");
}
}
// 使用方式
SingletonEnum.INSTANCE.doSomething();
我个人非常推荐这种方式。虽然看起来不太像传统的单例模式,但它确实是最安全的。Joshua Bloch 在《Effective Java》里也强烈推荐枚举单例。
总结一下三种方案的对比:
| 方案 | 防序列化破坏 | 防反射破坏 | 推荐指数 |
|---|---|---|---|
| 普通单例 + readResolve() | ✅ 需要手动实现 | ❌ 无法防御 | ⭐⭐ |
| 普通单例 + 标志位检查 | ❌ 无法防御 | ✅ 可以防御 | ⭐⭐ |
| 枚举单例 | ✅ 天然防御 | ✅ 天然防御 | ⭐⭐⭐⭐⭐ |
12.4 知识体系总览
下面这张图,把单例模式被破坏的两种方式以及对应的防御策略梳理清楚了。你可以把它当作一个快速参考。
说白了,单例模式的核心就是“控制实例数量”。序列化和反射这两个破坏方式,本质上都是绕过了你的控制逻辑。理解了这一点,防御思路就清晰了——要么堵住绕过的路径(readResolve、标志位),要么选择一条绕不过的路(枚举)。
我的经验:在实际项目中,如果单例类不需要继承其他类,我首选枚举单例。如果需要继承,那就用双重检查锁 + readResolve + 标志位,三重保险。虽然代码多了点,但心里踏实。