单例模式:六种 Java 实现、三种破环攻击与选型指南
单例(Singleton)是 GoF 23 个设计模式里结构最简单、被问得最多、也被滥用得最狠的一个。简单到很多人以为"加个
static就完事了",但它真正难的地方在于:线程安全、延迟加载、以及防止别人绕过你的构造器。这篇文章用 Java 把这三件事讲透。
一、意图与适用场景
意图:保证一个类只有一个实例,并提供一个全局访问点。
它适合这些场景:
- 无状态或有状态的共享资源管理器:配置中心、连接池、线程池、缓存
- 全局唯一的协调者:ID 生成器、序列号发号器、注册表
- 创建代价高昂、且反复创建没有意义的对象(但注意:先确认它真的无状态或线程安全)
不适合的场景(这才是重点):
- 只是想"图方便到处拿"——那是全局变量,不是设计模式。全局可变状态会带来隐藏依赖、并发竞争和测试困难。
- 需要被继承、需要多实例、需要按参数构造的对象。
- 现代工程里,如果已经有依赖注入容器(Spring、Guice),优先用容器管理的单例,而不是手写。容器的单例还能被 mock、被代理、被替换。
一个实用的判断标准:这个对象"在语义上"就只有一个吗?(比如"这台机器上的 JVM")如果只是"我不方便传参数",那就不是单例的理由。
二、单例必须同时满足的五个约束
很多人只满足第一条就以为写完了:
- 唯一实例:任何路径都拿不到第二个对象
- 全局访问点:通常是
getInstance() - 线程安全:多线程并发下依然唯一
- 延迟加载(可选但常见需求):不用的时候不创建
- 抗破坏:反射、序列化、克隆、多类加载器都不能造出第二个
下面六种写法,本质上是这五条的不同取舍。
三、六种实现
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() 不是原子操作,它至少分三步:
- 分配内存
- 调用构造器初始化对象
- 把引用赋给
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 + volatile | ✅ | ✅ | ❌ | 需 readResolve | 中 | 需要极致性能时 |
| 静态内部类 | ✅ | ✅ | ❌ | 需 readResolve | 低 | 手写首选 |
| 枚举 | ✅ | 类初始化即建 | ✅ | ✅ | 最低 | 最安全,首选 |
| CAS | ✅ | ✅ | ❌ | 需 readResolve | 高 | 了解即可 |
一句话结论:能用枚举就用枚举;需要延迟加载或要继承/需要保持类与实例分离时,用静态内部类;其余写法主要用来理解原理和历史包袱。
六、别忘了它的问题:为什么有人说"单例是反模式"
- 隐藏依赖:
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