Skip to content

单例模式的安全性 ​

「你会写单例吗」之后,面试官的下一刀往往是「那你的单例怎么被破坏」——反射和序列化各是一把刀。本篇不教怎么写单例(写法大全见 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 攻防矩阵 ​

攻击方式绕过点普通类防守枚举
反射 newsetAccessible(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,引用请遵原文。