单例模式是 Java 中最常见的创建型设计模式,核心是保证一个类在 JVM 里只有一个实例并提供全局访问点。但真正写出线程安全、防反射、防序列化的单例并不简单:懒加载与线程安全天然冲突,双重检查锁(DCL)少一个 volatile 就会出隐蔽 bug。本文从饿汉式、懒汉式讲起,拆解 DCL 为什么必须加 volatile,对比静态内部类与枚举两种推荐写法,并给出反序列化与反射攻击的防御方案。
一、为什么需要单例模式
很多对象天生就只该有一个:线程池、连接池、配置中心、注册表、日志器。如果每处都 new 一个,不仅浪费内存,还会让状态彼此割裂——两个线程从两个不同的连接池拿到连接,事务和限流就全乱了。单例把”唯一实例”这件事收口到一个地方,对外只暴露一个稳定的获取入口,调用方无需关心它何时创建。
但单例也是最容易被滥用的模式。能做成无状态工具类(纯静态方法)的就别用单例;能用依赖注入容器托管的也未必需要手写。先问一句”它真的必须唯一吗”,再决定是否落地单例。
二、饿汉式与懒汉式:最朴素的两种写法
饿汉式在类加载时就创建实例,天生线程安全,但缺点是一上来就占资源,哪怕永远用不到。懒汉式把创建推迟到第一次调用,但最简单的写法在并发下会创建出多个实例。
// 饿汉式:类加载即创建,线程安全但可能提前占用资源
public class EagerSingleton {
private static final EagerSingleton INSTANCE = new EagerSingleton();
private EagerSingleton() {}
public static EagerSingleton getInstance() {
return INSTANCE;
}
}
// 懒汉式(线程不安全版):并发下可能 new 出多个实例
public class LazySingleton {
private static LazySingleton instance;
private LazySingleton() {}
public static LazySingleton getInstance() {
if (instance == null) { // 线程 A、B 同时进入,各自 new 一个
instance = new LazySingleton();
}
return instance;
}
}
给 getInstance() 加 synchronized 能立刻解决并发问题,但每次获取都要抢锁,高并发下反而成了性能瓶颈。于是有了更精细的双重检查锁。
三、双重检查锁 DCL:为什么必须加 volatile
DCL 的思路是:只有实例还没创建时才加锁进入临界区,创建好之后直接返回,不再走锁。看起来两全其美,但经典实现里有一个致命坑——instance 必须声明为 volatile。
public class DclSingleton {
// 关键点:volatile 禁止指令重排,保证可见性
private static volatile DclSingleton instance;
private DclSingleton() {}
public static DclSingleton getInstance() {
if (instance == null) { // 第一次检查(无锁,快路径)
synchronized (DclSingleton.class) {
if (instance == null) { // 第二次检查(持锁,防重复创建)
instance = new DclSingleton();
}
}
}
return instance;
}
}
3.1 不加 volatile 的隐患:指令重排
instance = new DclSingleton() 在 JVM 里并不是原子的,它大致分成三步:① 分配内存;② 在内存上执行构造函数初始化对象;③ 把 instance 引用指向这块内存。编译器和 CPU 可能做指令重排,让 ③ 先于 ② 完成。此时另一个线程在第一次检查里看到 instance 不为 null,就直接返回了一个还没初始化完成的对象,用到成员变量时就会拿到默认值甚至抛空指针。
volatile 在这里干两件事:一是通过内存屏障禁止 ②③ 重排,二是保证一个线程写完、其他线程立刻可见。想深入 volatile 的 happen-before 语义,可以看这篇 Java 内存模型 JMM 与 volatile。
四、静态内部类:懒加载与线程安全的最佳折中
如果你不想纠结 volatile 和锁,静态内部类(Initialization-on-demand holder)是更省心的写法。JVM 保证类初始化是线程安全的,而内部类只有在被引用时才加载,天然实现了懒加载。
public class HolderSingleton {
private HolderSingleton() {}
private static class Holder {
private static final HolderSingleton INSTANCE = new HolderSingleton();
}
public static HolderSingleton getInstance() {
return Holder.INSTANCE; // 第一次调用才触发 Holder 类加载
}
}
这个写法的优点非常明显:无锁、延迟加载、线程安全全由类加载机制兜底,代码也最干净。它几乎是 DCL 之前最被推荐的方案。但它挡不住反射和序列化攻击——下面会讲。
五、枚举单例:Effective Java 的终极推荐
Joshua Bloch 在《Effective Java》里给出过一个至今最稳妥的结论:实现单例的最佳方式是写一个包含单个元素的枚举。它写法极简,而且由 JVM 保证实例唯一。
public enum EnumSingleton {
INSTANCE;
public void doSomething() {
// 业务方法直接挂在枚举实例上
}
}
// 使用
EnumSingleton.INSTANCE.doSomething();
5.1 枚举如何天然防反射与序列化
普通类的私有构造器能被反射强行调用,setAccessible(true) 一关就破了防线;而枚举的实例由 JVM 在类加载时创建,且《Java 语言规范》明确禁止通过反射创建枚举对象,newInstance 遇到枚举直接抛异常。序列化方面,枚举的序列化机制不走普通 readObject,而是按名字解析,天然杜绝反序列化产生新实例。也就是说,枚举单例在”防反射、防序列化”这两条上不需要你写任何额外代码。
六、破坏性攻击:反射与序列化如何击穿单例
下面这张表把四种主流写法在四个维度上摆开对比,一眼就能看出为什么枚举胜出:
| 写法 | 线程安全 | 懒加载 | 防反射 | 防序列化 |
|---|---|---|---|---|
| 饿汉式 | 是 | 否 | 否 | 否 |
| 懒汉式(synchronized) | 是 | 是 | 否 | 否 |
| DCL + volatile | 是 | 是 | 否 | 否 |
| 静态内部类 | 是 | 是 | 否 | 否 |
| 枚举单例 | 是 | 是 | 是 | 是 |
下面这段代码演示反射如何击穿一个普通单例——即便构造器是 private,反射也能强行调用:
Constructor<DclSingleton> c = DclSingleton.class.getDeclaredConstructor();
c.setAccessible(true); // 关闭访问检查
DclSingleton fake = c.newInstance(); // 直接 new 出一个新实例,单例被破
System.out.println(fake == DclSingleton.getInstance()); // false
如果你坚持用 DCL 或静态内部类,又想挡住反射,只能在构造器里加一道”第二次调用就抛异常”的守卫(用静态标记位),但这会让代码变得脆弱。序列化场景则要额外实现 readResolve() 返回同一个实例。相比之下,枚举把这些麻烦全部免掉了。
七、Spring 容器里的单例 ≠ Java 单例
很多人会混淆两个概念。Spring 的 @Scope("singleton") 指的是在 IoC 容器(一个 ApplicationContext)里只有一个 Bean,但如果你用 new 创建同一个类,或者用多个容器加载同一配置,照样会有多个实例。它靠的是容器缓存,不是类加载机制。手写单例解决的是”一个类在 JVM 里只有一份”,而 Spring 单例解决的是”被容器管理的 Bean 只有一份”,二者边界要分清。关于设计模式在业务代码里的更多落地,可参考 Java 设计模式实战:工厂与策略模式。
八、选型清单:什么时候该用单例
落到工程实践,给出一份落地清单:
- 对象确实必须全局唯一(连接池、配置、注册表、调度器)——才考虑单例;
- 能做成无状态工具类或交给 DI 容器的,不要用手写单例;
- 需要懒加载又嫌 DCL 啰嗦,用静态内部类;
- 要求绝对防反射、防序列化,无脑上枚举单例;
- 并发场景的共享状态本身就有坑,单例只是起点,真正的难点在线程池与异步编排的协作上。
单例模式看着简单,真正写对却要懂类加载、JMM 和序列化三套机制。日常开发里,枚举单例是性价比最高的默认选择;当你需要兼顾可读性与极致控制时,静态内部类与 DCL 依然是值得掌握的经典写法。




