单例模式是所有设计模式里最简单、也最常被问到的一个。它背后的知识点——饿汉/懒汉、双重检查锁、静态内部类、枚举防反射——平时零散都接触过,但没系统地串成一条线。这篇按演进顺序捋一遍:每一版解决了什么、又遗留了什么。
一、解决什么问题
一句话:保证一个类在整个进程里只有一个实例,并提供一个全局访问点。
什么时候需要"全局唯一"?典型场景:
- 配置对象:整个应用读同一份配置,不能各读各的
- 连接池 / 线程池:数据库连接池、线程池全局只有一份
- Spring 的 Bean:Spring 容器里的 Bean 默认就是单例(
scope=singleton) - 日志器、缓存:全局共享一份
反面例子:如果一个类允许多个实例,但逻辑上应该唯一(比如计数器),多实例就会导致状态不一致。
二、演进:从饿汉式到枚举
单例的实现一路演进,核心矛盾始终是三个:何时创建、线程安全、能否被破坏。
v1 饿汉式:简单直接
public class Singleton {
private static final Singleton INSTANCE = new Singleton();
private Singleton() {} // 私有构造,禁止外部 new
public static Singleton getInstance() { return INSTANCE; }
}类加载时就创建实例。线程安全(类加载机制保证静态字段只初始化一次),但浪费内存——哪怕你从来不用这个单例,类一加载它就建好了。
v2 懒汉式:用时才建,但不安全
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 用到才创建
instance = new Singleton();
}
return instance;
}
}解决了"浪费内存",但线程不安全:两个线程同时通过 instance == null 判断,会各自 new 一个,单例就破了。
v3 加 synchronized:安全了,但每次都要锁
public static synchronized Singleton getInstance() {
if (instance == null) { instance = new Singleton(); }
return instance;
}线程安全了,但 synchronized 加在方法上,每次获取都要拿锁——即使实例早就创建好了,后续所有调用还是串行排队,性能差。
v4 双重检查锁(DCL):性能与安全的平衡
public class Singleton {
private static volatile Singleton instance; // volatile 不能少
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查:不为 null 直接返回,不进锁
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查:防止并发重复创建
instance = new Singleton();
}
}
}
return instance;
}
}两个 if 各管一件事:
- 外层
if:实例已存在时完全不加锁,直接返回——解决了 v3 每次都要锁的性能问题 - 内层
if:多个线程同时进了锁,第一个创建后,后面的进来发现instance已经不是 null,就不会重复创建
为什么 DCL 多了个 volatile:外层 if 是无锁读,缺了 synchronized 的保证;volatile 防止"引用先指向、初始化延后"的指令重排,保证拿到的一定是完整实例。想搞懂 volatile 的原理,看《volatile 关键字详解》。
v5 静态内部类:懒加载 + 线程安全,还不用锁
public class Singleton {
private Singleton() {}
private static class Holder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() { return Holder.INSTANCE; }
}巧妙利用了类加载机制:Holder 这个静态内部类,只有在第一次被访问(getInstance() 里引用 Holder.INSTANCE)时才加载,加载时 JVM 保证 INSTANCE 只初始化一次。所以它同时拿到懒加载(不用不建)+ 线程安全(类加载保证)两个好处,而且没有锁开销,代码还简洁。日常最推荐这种。
v6 枚举:最安全,防反射和序列化
// 枚举单例:可以有字段、私有构造器、方法
public enum ConfigManager {
INSTANCE; // 唯一的实例
private final Map<String, String> config = new HashMap<>();
ConfigManager() { // 私有构造器:加载配置(JVM 保证只执行一次)
config.put("db.url", "jdbc:mysql://localhost:3306/db");
config.put("db.username", "admin");
}
public String get(String key) {
return config.get(key);
}
}
// 使用:ConfigManager.INSTANCE.get("db.url")实际用在哪:枚举单例适合放"全局唯一、无状态"的东西——配置管理器(整个应用读同一份配置)、缓存管理器(单例缓存)、全局注册表等。它本质是把"单例"这件事交给 JVM 兜底,代码最简洁也最安全。
枚举是 Java 里实现单例最安全的方式,因为 JVM 从底层保证了:
- 防反射:反射
newInstance()创建枚举实例会直接抛IllegalArgumentException(JVM 特判) - 防序列化:枚举天然序列化安全,反序列化不会创建新实例
- 线程安全:枚举实例初始化由 JVM 保证
所以《Effective Java》从理论上推荐"用枚举实现单例"——它是所有实现方式里最安全的。不过实际项目里枚举单例用得不多(写法不如 class 直观、团队习惯),大家日常还是用 class 实现,静态内部类最常用;枚举单例知道"有这个终极形态、以及它为什么最安全"就够了。
三、怎么破坏单例(面试高频)
前面 v1~v5 的 class 实现,都有两个漏洞:
① 反射:私有构造拦不住反射:
Constructor<Singleton> c = Singleton.class.getDeclaredConstructor();
c.setAccessible(true);
Singleton s = c.newInstance(); // 绕过 private 构造,又造了一个实例(反射的原理和 setAccessible 的作用,见《Java 反射详解》)
② 序列化:反序列化会走 readResolve 之外的道路,默认会 new 一个新对象。
应对:
- 反射:在私有构造里加判断,若实例已存在则抛异常
- 序列化:加
readResolve()方法返回已有实例 - 或者干脆用 v6 枚举,两个漏洞从根上堵死
这也是为什么说"枚举是单例的终极形态"。
四、Spring 注解单例:容器缓存,不是类自己保证
现在项目里基本都是 @Service、@Component 注解,它们默认单例。但注解本身不实现单例——@Service 只是个标记,真正实现单例的是 Spring 的 IoC 容器:
- 启动扫描到
@Service标记的类 - 用反射创建实例
- 存进容器的单例缓存池(
singletonObjects,一个ConcurrentHashMap) - 之后每次注入 /
getBean()都从缓存池拿同一个实例,不再 new
// Spring 容器内部大致逻辑(简化)
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(); // 单例缓存池
public Object getBean(String name, Class<?> clazz) {
Object bean = singletonObjects.get(name); // ① 先从缓存拿
if (bean == null) { // ② 没有才反射创建
bean = clazz.getDeclaredConstructor().newInstance();
singletonObjects.put(name, bean); // ③ 存进缓存
}
return bean; // ④ 之后都返回同一个
}和手写单例的本质区别:
| 手写单例(饿汉/懒汉/DCL) | Spring 注解单例 | |
|---|---|---|
| 谁保证单例 | 类自己(私有构造器 + 静态变量) | 容器(缓存池只存一个) |
| 类本身 | 私有构造器,天生阻止 new | 普通类,new 多少个都行 |
| 实现 | 构造器 private + 静态实例 | 反射创建 + Map 缓存 |
手写单例是"类自己锁死只能有一个";Spring 单例是"类很普通、随便 new,只是容器只创建一次并缓存,保证大家拿到同一个"。所以 Spring 的 @Service 类,你其实可以手动 new 出第二个——只是没人这么干,因为注入进来的永远是容器缓存里那个。
小结
- 单例 = 进程内唯一实例 + 全局访问点,典型场景:配置、连接池、Spring Bean
- 演进线:饿汉(简单但浪费)→ 懒汉(省但不安全)→ synchronized(安全但慢)→ DCL(volatile 防重排) → 静态内部类(懒加载+安全+无锁,推荐) → 枚举(防反射/序列化,终极)
- DCL 的
volatile是为了禁止"引用先于初始化"的指令重排,不是可有可无 - class 实现都能被反射/序列化破坏,枚举从 JVM 层面堵死
下一步可以看同为创建型的《工厂模式》——它和单例经常配合使用(工厂返回单例),也是 Spring 里 @Bean 的核心思想。
