序列化为什么会破坏单例?readResolve 有什么作用?
简化版
普通单例实现 Serializable 后,反序列化过程可以还原出一个新的对象,因此可能与静态实例不相同。定义 readResolve() 可以在对象返回给调用者前用既有单例替换反序列化结果;枚举则使用专门的序列化规则,天然返回原有枚举常量。
详细版
普通对象反序列化并不通过公开的 getInstance() 获取实例,所以私有构造方法不能维护唯一性。常见修复如下:
public final class Settings implements Serializable {
private static final long serialVersionUID = 1L;
private static final Settings INSTANCE = new Settings();
private Settings() {}
public static Settings getInstance() { return INSTANCE; }
private Object readResolve() {
return INSTANCE;
}
}
ObjectInputStream 读取对象后会查找合适的 readResolve(),并把它返回的对象作为解析结果。serialVersionUID 负责版本兼容判断,不负责维护单例。
如果单例根本不需要跨进程保存,就不要实现 Serializable。必须序列化时,还要避免在被替换的临时对象中承载不可忽略的外部副作用或敏感状态。
完整版教学
一、反序列化走的是另一条创建路径
单例的常规控制路径是:私有构造方法加公开获取方法。Java 原生反序列化根据流中的类描述和字段数据重建对象,并不会调用 getInstance(),普通可序列化类的私有构造方法也不足以阻止这一过程。
因此可能出现:
Settings a = Settings.getInstance();
Settings b = deserialize(bytes);
System.out.println(a == b); // 没有 readResolve 时可能为 false
二、readResolve 的替换时机
readResolve() 不是阻止临时对象被读取,而是在 ObjectInputStream 准备把结果返回给调用者时,用另一个对象替换它。对单例而言,替换对象就是静态保存的实例。
方法通常声明为无参数并返回 Object,可以抛出 ObjectStreamException。如果存在复杂继承层次,还要谨慎检查方法可见性和子类行为;最简单的做法是让单例类保持 final。
三、serialVersionUID 解决的不是唯一性
serialVersionUID 用于判断序列化流与当前类版本是否兼容。即使它固定且匹配,反序列化仍可能产生独立对象;即使没有显式声明,自动生成的值也不会替你返回单例。
这是面试中常见的概念混淆。
四、枚举的专门规则
枚举常量序列化时主要记录名称,反序列化时通过枚举类型和名称取得已有常量。枚举自定义的 readResolve() 不参与这一专门流程,也没有必要依靠它维护唯一性。
实际系统若使用 JSON、Kryo 等其他序列化框架,是否调用 Java 原生 readResolve() 取决于框架实现,不能直接套用 ObjectInputStream 的规则。
五、反序列化临时对象与 readResolve 替换
Java 原生反序列化不是调用 getInstance() 的普通构造流程。对普通 Serializable 单例,流会先恢复对象状态;若定义合适的 readResolve(),对象输入流在把结果交给调用方前用现有 INSTANCE 替换临时结果。
Settings a = Settings.getInstance()
serialize(a) -> class descriptor + fields
ObjectInputStream 开始恢复普通对象
产生待解析对象 temp(不走 getInstance)
调用 temp.readResolve()
readResolve 返回 Settings.INSTANCE
调用方得到 b,a == b
这条时序必须同时检查“谁负责创建”和“其他线程或调用方从哪里取得实例”。只看到最终引用相同还不够;若构造副作用执行了两次、发布了半初始化状态,或者作用域边界之外又创建了一份,就已经违反了本题讨论的保证。
六、保证范围与失效边界
| 评审维度 | 本题结论 |
|---|---|
| 唯一性/正确性保证 | readResolve 能让 ObjectInputStream 的最终解析引用回到既有实例;枚举使用更专门的名称恢复规则。 |
| 作用域边界 | 只适用于遵守 Java 原生序列化替换协议的路径。 |
| 仍未解决的问题 | JSON、Kryo 等框架未必调用 readResolve;临时对象恢复阶段的副作用也不能被替换引用抹去。 |
| 更合适的替代方案 | 不需要持久化就别实现 Serializable;固定实例可用枚举;其他框架需按其创建钩子配置。 |
设计模式的保证必须带作用域。把“某个类初始化边界只创建一次”说成“整个系统永远只有一个”,或把“安全发布引用”说成“对象所有方法都线程安全”,都会把不同层次的问题混在一起。
七、工程落地时的验证方法
- 为构造过程增加可观察计数,使用 32 个并发任务同时获取实例,构造计数应符合本题保证。
- 比较引用身份时使用
==,不要让重写后的equals()掩盖实际存在的多个对象。 - 若涉及类加载器,记录
instance.getClass().getClassLoader(),同时比较类对象和加载器身份。 - 若涉及序列化,至少执行一次“序列化 → 反序列化”,并核对返回引用及外部副作用。
- 若涉及 Spring,分别检查 Bean 名称、
ApplicationContext身份和作用域,而不只检查 Java 类名。 - 对共享可变字段另做并发测试;实例创建正确并不能替代业务状态的原子性测试。
- 对初始化失败、重复调用和资源关闭分别设计用例,确认生命周期语义可解释。
记忆钩子:readResolve 不是禁止“读出临时对象”,而是控制“最终把哪个引用交出去”。
八、常见误区与追问
- 误区:私有构造器能阻止 Java 反序列化。 普通 Serializable 对象恢复不走公开获取方法。
- 误区:serialVersionUID 能维护单例身份。 它只参与版本兼容判断。
- 误区:readResolve 对所有序列化框架自动生效。 是否支持取决于框架的对象创建和钩子协议。
- 追问:readResolve 应返回什么? 返回类中已经维护的唯一 INSTANCE,而不是新建对象。
- 追问:为什么通常让单例类 final? 避免子类改变序列化替换和实例创建语义。
- 追问:枚举为何不需要 readResolve? 枚举按常量名称定位已存在实例,走专门序列化规则。
九、加强记忆
私有构造方法拦不住原生反序列化这条对象重建路径,readResolve() 的作用是把读取结果替换回既有实例,而 serialVersionUID 只处理版本兼容。换用其他序列化框架时要重新核对其对象创建规则。