2
0

单例模式:六种 Java 实现、三种破环攻击与选型指南

单例(Singleton)是 GoF 23 个设计模式里结构最简单、被问得最多、也被滥用得最狠的一个。简单到很多人以为"加个 static 就完事了",但它真正难的地方在于:线程安全、延迟加载、以及防止别人绕过你的构造器。这篇文章用 Java 把这三件事讲透。

一、意图与适用场景

意图:保证一个类只有一个实例,并提供一个全局访问点

它适合这些场景:

  • 无状态或有状态的共享资源管理器:配置中心、连接池、线程池、缓存
  • 全局唯一的协调者:ID 生成器、序列号发号器、注册表
  • 创建代价高昂、且反复创建没有意义的对象(但注意:先确认它真的无状态或线程安全)

不适合的场景(这才是重点):

  • 只是想"图方便到处拿"——那是全局变量,不是设计模式。全局可变状态会带来隐藏依赖、并发竞争和测试困难。
  • 需要被继承、需要多实例、需要按参数构造的对象。
  • 现代工程里,如果已经有依赖注入容器(Spring、Guice),优先用容器管理的单例,而不是手写。容器的单例还能被 mock、被代理、被替换。

一个实用的判断标准:这个对象"在语义上"就只有一个吗?(比如"这台机器上的 JVM")如果只是"我不方便传参数",那就不是单例的理由。

二、单例必须同时满足的五个约束

很多人只满足第一条就以为写完了:

  1. 唯一实例:任何路径都拿不到第二个对象
  2. 全局访问点:通常是 getInstance()
  3. 线程安全:多线程并发下依然唯一
  4. 延迟加载(可选但常见需求):不用的时候不创建
  5. 抗破坏:反射、序列化、克隆、多类加载器都不能造出第二个

下面六种写法,本质上是这五条的不同取舍。

三、六种实现

1. 饿汉式(Eager Initialization)

public final class EagerSingleton {
    private static final EagerSingleton INSTANCE = new EagerSingleton();

    private EagerSingleton() {
        // 防御:防止反射调用
        if (INSTANCE != null) {
            throw new IllegalStateException("单例已被实例化,禁止重复构造");
        }
    }

    public static EagerSingleton getInstance() {
        return INSTANCE;
    }
}
  • 线程安全:由 JVM 类初始化机制保证(JLS 12.4.2),不需要任何同步,零开销
  • 延迟加载:类被主动使用时才初始化。如果这个类只有 getInstance() 一个入口,效果和懒加载差不多;但一旦它还有其他静态成员被访问,就会提前创建实例
  • 缺点:没有真正的"用到才建";构造函数抛异常会导致类初始化失败(后续访问抛 NoClassDefFoundError
  • 类声明 final 是为了防止被继承后被子类"再生成一个实例"

2. 懒汉式(线程不安全,只用来理解问题)

public final class LazyUnsafe {
    private static LazyUnsafe instance;

    private LazyUnsafe() {}

    public static LazyUnsafe getInstance() {
        if (instance == null) {          // ← 竞态窗口
            instance = new LazyUnsafe();
        }
        return instance;
    }
}

两个线程同时通过 instance == null 判断,就会各自 new 一次。这段代码在教学里出现,在生产里应该消失。

3. 同步方法 / 同步块 —— 能用,但慢

public final class LazySynchronized {
    private static LazySynchronized instance;

    private LazySynchronized() {}

    public static synchronized LazySynchronized getInstance() {
        if (instance == null) {
            instance = new LazySynchronized();
        }
        return instance;
    }
}

正确但每次调用都要抢锁——而单例的调用往往在热路径上,这是没必要的开销。

4. 双重检查锁(DCL)—— 关键是 volatile

public final class DoubleCheckedSingleton {
    // volatile 不能省!见下文
    private static volatile DoubleCheckedSingleton instance;

    private DoubleCheckedSingleton() {}

    public static DoubleCheckedSingleton getInstance() {
        if (instance == null) {                    // 第一次检查:命中后完全无锁
            synchronized (DoubleCheckedSingleton.class) {
                if (instance == null) {            // 第二次检查:防止重复创建
                    instance = new DoubleCheckedSingleton();
                }
            }
        }
        return instance;
    }
}

为什么必须写 volatile

new DoubleCheckedSingleton() 不是原子操作,它至少分三步:

  1. 分配内存
  2. 调用构造器初始化对象
  3. 把引用赋给 instance

第 2、3 步在某些 CPU/JIT 优化下可以重排序。如果没有 volatile,线程 B 可能在第 3 步完成、第 2 步尚未完成时读到 instance != null,于是拿到一个构造未完成的对象并直接使用——这种 bug 极难复现,也极难排查(这就是著名的 "DCL 失效" 问题,JSR-133 修复了 volatile 的语义之后,DCL 才变得正确)。

DCL 在 JDK 5 之前是不可靠的,这也是为什么老代码里它是反面教材。

5. 静态内部类(Holder)—— 兼顾延迟加载与零同步开销

public final class HolderSingleton {
    private HolderSingleton() {}

    private static class Holder {
        private static final HolderSingleton INSTANCE = new HolderSingleton();
    }

    public static HolderSingleton getInstance() {
        return Holder.INSTANCE;
    }
}
  • 线程安全:类初始化由 JVM 保证(JLS §12.4.2 的初始化过程会对 Class 对象加锁,从而建立 happens-before 关系,初始化结果对所有线程可见,且天然只执行一次)
  • 延迟加载:HolderSingleton 被加载时不会初始化 Holder,只有第一次调用 getInstance() 访问 Holder.INSTANCE 才会触发 → 真正做到"用时才建"
  • 无数次调用零同步开销,代码还短

日常手写单例,这是首推方案。

6. 枚举(Enum)—— 《Effective Java》力荐

public enum EnumSingleton {
    INSTANCE;

    private final Map<String, String> cache = new HashMap<>();

    public void put(String key, String value) {
        cache.put(key, value);
    }

    public String get(String key) {
        return cache.get(key);
    }
}

调用处:EnumSingleton.INSTANCE.put("a", "1");

为什么它是最安全的写法:

  • 线程安全:枚举常量在类初始化时创建,同样由 JVM 保证
  • 天然抗反射Constructor.newInstance() 对枚举类型直接抛 IllegalArgumentException: Cannot reflectively create enum objects
  • 天然抗序列化:Java 序列化规范对枚举特殊处理,反序列化返回的是同一个实例(其它写法必须手写 readResolve()
  • 代码最短,且不会因为漏写某一步而出现漏洞

关于"枚举不能延迟加载"这个流行说法,需要修正一下:枚举类同样遵循 JLS 的类初始化规则,首次主动使用才初始化——所以它其实是惰性的。真正要说的是:一旦初始化,所有枚举常量会被一次性创建,所以多个常量的枚举不适合当单例;只有一个 INSTANCE 时,它与饿汉式在时机上等价。

7. 补充:CAS 写法(了解即可)

public final class CasSingleton {
    private static final AtomicReference<CasSingleton> INSTANCE = new AtomicReference<>();

    private CasSingleton() {}

    public static CasSingleton getInstance() {
        for (;;) {
            CasSingleton current = INSTANCE.get();
            if (current != null) {
                return current;
            }
            current = new CasSingleton();
            if (INSTANCE.compareAndSet(null, current)) {
                return current;
            }
        }
    }
}

无锁,但并发首次创建时会有短暂的对象浪费(CAS 失败的线程创建出来的对象被丢弃),且存在与 DCL 相同的"引用先发布"隐患——所以生产代码里没有理由选它而不选 Holder 或枚举。

四、三种常见的"破环"攻击与防御

攻击 1:反射

Constructor<HolderSingleton> c = HolderSingleton.class.getDeclaredConstructor();
c.setAccessible(true);
HolderSingleton second = c.newInstance();   // 第二个实例诞生了

防御:

  • 首选枚举(免防御,反射直接失败)
  • 或者在私有构造器里加守卫:用一个静态标志位记录是否已构造,第二次调用直接抛异常
private static boolean constructed = false;

private HolderSingleton() {
    synchronized (HolderSingleton.class) {
        if (constructed) {
            throw new IllegalStateException("禁止重复构造");
        }
        constructed = true;
    }
}

攻击 2:序列化 / 反序列化

public final class SerializableSingleton implements Serializable {
    private static final long serialVersionUID = 1L;
    private static final SerializableSingleton INSTANCE = new SerializableSingleton();
    private SerializableSingleton() {}
    public static SerializableSingleton getInstance() { return INSTANCE; }

    // 关键:反序列化时把新对象拦掉,返回同一实例
    private Object readResolve() {
        return INSTANCE;
    }
}

不写 readResolve(),反序列化就会 new 一个新对象(因为它绕过了构造器,直接由字节流重建)。枚举没有这个问题。

攻击 3:克隆与多类加载器

  • 克隆:单例如果实现了 Cloneable,必须重写 clone() 返回 getInstance(),或者直接抛 CloneNotSupportedException。最简单的是不实现 Cloneable
  • 多类加载器:同一个类被不同的 ClassLoader 加载,会得到两个不同的 Class 对象,各自的静态字段互不相通 → JVM 里真的存在两个"单例"。典型场景:Tomcat 每个 webapp 独立的 classloader、热部署、OSGi。同类加载器问题没有"代码级"解法,只能靠部署架构规避(把单例放在容器/父加载器加载的类里)。

五、选型决策表

写法线程安全延迟加载抗反射抗序列化复杂度建议
饿汉式readResolve简单场景可用
懒汉(无同步)readResolve不要用
同步方法readResolve调用频率低时可用
DCL + volatilereadResolve需要极致性能时
静态内部类readResolve手写首选
枚举类初始化即建最低最安全,首选
CASreadResolve了解即可

一句话结论:能用枚举就用枚举;需要延迟加载或要继承/需要保持类与实例分离时,用静态内部类;其余写法主要用来理解原理和历史包袱。

六、别忘了它的问题:为什么有人说"单例是反模式"

  • 隐藏依赖Foo.getInstance() 把依赖藏在方法体里,看方法签名看不出它依赖了什么
  • 难以测试:全局状态在测试间残留,无法并行、无法注入假对象
  • 并发瓶颈:共享可变状态要么加锁、要么用并发容器/不可变对象
  • 生命周期僵硬:静态字段生命周期跟类加载器绑定,容器环境下不好管理

替代方案:依赖注入 + 容器管理的单例作用域(Spring 的 @Component 默认就是 singleton)。注意 Spring 的"单例"是每个 IoC 容器一个,不是 JVM 级唯一——多个容器(或多个 ClassLoader)下依然会存在多个实例,别和手写单例混着用。

如果确实需要一个纯工具类式的无状态辅助,直接写静态方法往往比单例更直白(Math.max() 就没有任何实例)。

七、参考资料

  • Joshua Bloch, Effective Java (3rd Edition), Item 3: Enforce the singleton property with a private constructor or an enum type
  • Java Language Specification §12.4.2 Detailed Initialization Procedure、§8.9 Enum Types
  • JSR-133 / Java Memory Model FAQ —— Double-Checked Locking(解释 DCL 失效与 volatile 的作用)
  • Java Object Serialization Specification §1.12 readResolve

评论