7、单例模式进阶:单例的序列化与反序列化问题、单例的破坏与防御

单例模式,说白了就是保证一个类只有一个实例。但很多同学写了好几年代码,还是会在这个看似简单的地方翻车。今天我们就来聊聊两个最容易踩的坑:序列化破坏和反射破坏。

7.1 序列化与反序列化:单例的隐形杀手

先问一个问题:你辛辛苦苦写了一个单例,然后把它序列化存到文件里,再反序列化读回来——你猜猜看,读回来的是同一个对象吗?

答案是:不是。除非你做了特殊处理。

我记得有一次在项目中做缓存模块,用了饿汉式单例来管理全局配置。一切都很完美,直到有一天线上出现了诡异的配置不一致问题。排查了半天,发现是某个同事在测试代码里把单例序列化到了Redis,再反序列化出来——结果拿到了两个不同的实例。

核心问题:Java的序列化机制在反序列化时,会通过反射调用无参构造器创建一个新对象。即使你的构造器是私有的,序列化框架也能绕过它。

来看一个典型的例子:

public class Singleton implements Serializable {
    private static final Singleton INSTANCE = new Singleton();
    
    private Singleton() {}
    
    public static Singleton getInstance() {
        return INSTANCE;
    }
}

// 反序列化测试
Singleton s1 = Singleton.getInstance();
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton.obj"));
oos.writeObject(s1);
oos.close();

ObjectInputStream ois = new ObjectInputStream(new FileInputStream("singleton.obj"));
Singleton s2 = (Singleton) ois.readObject();
ois.close();

System.out.println(s1 == s2); // 输出 false!

为什么会这样?因为反序列化时,JVM根本不走你的getInstance方法。它直接通过反射创建了一个全新的对象。

7.2 解决方案:readResolve方法

怎么防御?其实很简单。Java提供了一个钩子方法——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方法的返回值会替换掉反序列化创建的那个对象。也就是说,反序列化过程虽然创建了一个新对象,但最终返回的是你指定的那个已有实例。

个人习惯:我一般会在所有需要序列化的单例类上都加上readResolve方法。哪怕当前没用到序列化,也先写上。因为你永远不知道未来谁会把它塞进Redis或者MQ里。

7.3 反射破坏:更暴力的攻击方式

序列化的问题还算温和,反射才是真正的「大杀器」。你想想看,反射可以无视访问权限,直接调用私有构造器。

// 反射攻击单例
Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
constructor.setAccessible(true); // 绕过私有权限
Singleton s2 = constructor.newInstance();

System.out.println(s1 == s2); // 又是 false

我曾经在一个金融项目中看到过这种问题。当时团队用了Spring管理单例Bean,但有个同事在单元测试里直接用反射创建了实例,导致测试环境和生产环境的行为完全不一致。排查了整整两天才找到原因。

7.4 防御反射:双重校验锁 + 标志位

怎么防反射?最经典的做法是在构造器里加一个标志位:

public class Singleton {
    private static volatile Singleton INSTANCE;
    private static boolean flag = false; // 标志位
    
    private Singleton() {
        // 防止反射攻击
        if (flag) {
            throw new RuntimeException("不要用反射破坏单例!");
        }
        flag = true;
    }
    
    public static Singleton getInstance() {
        if (INSTANCE == null) {
            synchronized (Singleton.class) {
                if (INSTANCE == null) {
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}

这个思路其实很简单:第一次调用构造器时,把flag设为true。第二次再调用,直接抛异常。反射虽然能调用私有构造器,但挡不住构造器内部的逻辑判断。

注意:标志位本身也可能被反射修改!如果攻击者通过反射修改了flag字段的值,这个防御就失效了。更安全的做法是用枚举单例——枚举在JVM层面保证了单例,反射和序列化都无法破坏。

7.5 终极方案:枚举单例

我个人最推荐的方式,就是使用枚举实现单例。Joshua Bloch在《Effective Java》里也强烈推荐过:

public enum SingletonEnum {
    INSTANCE;
    
    public void doSomething() {
        // 业务逻辑
    }
}

// 使用方式
SingletonEnum.INSTANCE.doSomething();

枚举单例的好处是:

  • JVM保证实例唯一性,反射无法创建枚举实例
  • 序列化机制对枚举有特殊处理,反序列化不会创建新对象
  • 线程安全,由JVM保证

你想想看,一个方案同时解决了序列化破坏和反射破坏两个问题,是不是很省心?

7.6 知识体系总览

下面这张图总结了单例模式在序列化和反射场景下的破坏方式与防御策略:

单例模式:破坏与防御 单例实例 序列化/反序列化 readObject() 创建新对象 反射攻击 setAccessible() 调用私有构造器 防御策略 readResolve() 反序列化时返回已有实例 构造器标志位 第二次调用抛异常 枚举单例 JVM层面保证唯一性

7.7 避坑指南

最后,分享几个我踩过的坑:

  • 不要依赖final关键字:final只能保证引用不被修改,但挡不住反射和序列化创建新实例。
  • 注意反序列化时的版本号:如果类的serialVersionUID变了,反序列化会失败。我建议显式声明一个固定的serialVersionUID。
  • Spring管理的单例也要小心:Spring容器默认是单例,但如果你的Bean实现了Serializable,并且被序列化到外部存储,同样会遇到上述问题。
我的建议:新项目直接用枚举单例。老项目如果已经用了传统单例,至少加上readResolve方法和构造器标志位。别等到线上出了bug再后悔——我曾经就因为这个教训,周末加班修了一个通宵。

嗯,单例的序列化和反射问题就聊到这里。记住一句话:单例的敌人不是代码逻辑,而是Java的反射和序列化机制。理解了这一点,你就能写出真正坚不可摧的单例。


公众号:蓝海资料掘金营,微信deep3321