Appearance
单例模式的安全性
「你会写单例吗」之后,面试官的下一刀往往是「那你的单例怎么被破坏」——反射和序列化各是一把刀。本篇不教怎么写单例(写法大全见 Java设计模式全览),专门讲怎么把一个写好的单例打成假单例,以及每种攻击对应的防守姿势。反射机制背景见 Java反射。
1 靶子:一个饿汉式单例
java
public class Singleton {
private static final Singleton INSTANCE = new Singleton();
private String name;
public String getName() {
return this.name;
}
private Singleton() {
this.name = "Neo";
}
public static Singleton getInstance() {
return INSTANCE;
}
}构造器已 private,客户端无法 new,只能 getInstance()——看起来万无一失。下面两次攻击都绕开了这层封装。
2 攻击一:反射
原理:反射拿到私有构造器,改掉访问权限,直接再造一个实例。
java
Singleton instance1 = Singleton.getInstance();
Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
constructor.setAccessible(true); // 击穿 private
Singleton instance2 = constructor.newInstance();
System.out.println(instance1 == instance2); // false —— 单例已破setAccessible(true) 本就是留给框架打破封装的后门,private 拦得住编译器,拦不住反射。
3 防守一:枚举
Java 提供的官方答案是把单例写成枚举:
java
public enum Singleton {
INSTANCE; // 只有一项
private String name;
Singleton() {
this.name = "Neo";
}
public String getName() {
return name;
}
}此时再想反射拿无参构造器:
java
Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
// 抛 NoSuchMethodException:编译器为枚举合成的是 (String name, int ordinal) 构造器即便按合成的签名去取构造器,Constructor#newInstance 对枚举类也会直接抛 IllegalArgumentException: Cannot reflectively create enum objects——这是 JVM 层面的硬保护:枚举实例由 JVM 在类初始化时创建,反射这条路对枚举是封死的。这也是《Effective Java》把枚举列为单例首选的原因。
4 攻击二:序列化 / 反序列化
序列化把对象转成字节流,反序列化再转回来——回来的那个可不是原来那个:
java
Singleton instance1 = Singleton.getInstance();
ByteArrayOutputStream bout = new ByteArrayOutputStream();
new ObjectOutputStream(bout).writeObject(instance1); // 需先 implements Serializable
ObjectInputStream in = new ObjectInputStream(
new ByteArrayInputStream(bout.toByteArray()));
Singleton instance3 = (Singleton) in.readObject();
System.out.println(instance1 == instance3); // false —— 单例又破了关键在 readObject:反序列化走的是"从流里重建对象"的通道,不经过构造器,所以 private 构造器形同虚设,JVM 造出了一个全新的 Singleton。
5 防守二:readResolve()
JVM 留了官方钩子:反序列化结束时若发现类定义了 readResolve() 方法,就用其返回值替换从流中重建出来的对象。
java
public class Singleton implements Serializable {
private static final Singleton INSTANCE = new Singleton();
private Singleton() {
}
public static Singleton getInstance() {
return INSTANCE;
}
// 看这里:反序列化时被 JVM 自动调用,返回值替换流中的对象
public Object readResolve() throws ObjectStreamException {
return INSTANCE;
}
}再跑一次上面的攻击代码,instance1 == instance3 变回 true。方法签名按序列化规范是:
ANY-ACCESS-MODIFIER Object readResolve() throws ObjectStreamException;访问修饰符任意,但必须叫这个名字、无参、返回 Object。
6 攻防矩阵
| 攻击方式 | 绕过点 | 普通类防守 | 枚举 |
|---|---|---|---|
| 反射 new | setAccessible(true) + 私有构造器 | 构造器里加标志位防重入(笨拙且有竞态) | 天生免疫(JVM 封死反射造枚举) |
| 序列化重建 | readObject 不走构造器 | 实现 readResolve() 返回单实例 | 天生免疫(枚举序列化按 name 处理) |
两点补白:
- 枚举对序列化攻击同样免疫——枚举的序列化只写常量名,反序列化按
Enum.valueOf找回既有常量,天然不会造新对象,不需要写 readResolve(); - 除了本文两种,实践中还有克隆(不重写 clone 就抛 CloneNotSupportedException,风险低)和某些绕过构造器的序列化框架(如通过 Unsafe/反射库绕过 readResolve 的特例),面试点到即可。
面试追问链
- 单例有哪几种经典写法?→ 饿汉、双重检查锁(volatile 必不可少)、静态嵌套类 Holder、枚举,详见 Java设计模式全览;
- 怎么用反射破坏单例?→ getDeclaredConstructor + setAccessible(true) + newInstance,绕过 private 构造器;
- 怎么防反射?→ 用枚举;JVM 禁止反射创建枚举实例,Constructor#newInstance 直接抛 IllegalArgumentException;
- 怎么用序列化破坏单例?→ readObject 从流重建对象不经过构造器,得到新实例;
- readResolve() 为什么有效?→ 反序列化完成后 JVM 用它的返回值替换流中对象;签名任意修饰符、返回 Object、抛 ObjectStreamException;
- 枚举单例为什么天然安全?→ 实例由 JVM 在类初始化时创建,反射被封死;序列化只按常量名走 valueOf,两个攻击面同时免疫;
- DCL 里 volatile 防什么?→ 防止 new 的「分配-初始化-赋引用」重排序被另一线程读到半成品引用。
面试官视角小结
单例题的梯度非常工整:写出 DCL 是及格,说出 volatile 防重排序是标准动作;追问「能不能破坏」时,能主动给出反射与序列化两路攻击、并分别落到「枚举」与「readResolve()」两个官方解,就是本篇的满分线。最高级的候选人口径是:「只要类加载器体系还在,任何普通类的单例都可以被刻意破坏,枚举是 JVM 担保的唯一形态」——一句话把问题从 API 层拔到语言规范层。反过来,只背写法、被追问破坏手段时一脸茫然,说明对反射与序列化的底层通道(setAccessible、readObject 不走构造器)没有真实理解。反射基础可回看 Java反射。
来源:javadoop《单例模式的安全性》(https://javadoop.com/post/singleton-not-single)——本文为研读消化后的重写笔记,原文版权归 javadoop,引用请遵原文。