单例模式是所有设计模式里最简单、也最常被问到的一个。它背后的知识点——饿汉/懒汉、双重检查锁、volatile 的作用、枚举防反射——平时零散都接触过,但没系统地串成一条线。这篇按演进顺序捋一遍:每一版解决了什么、又遗留了什么。
一、解决什么问题
一句话:保证一个类在整个进程里只有一个实例,并提供一个全局访问点。
什么时候需要"全局唯一"?典型场景:
- 配置对象:整个应用读同一份配置,不能各读各的
- 连接池 / 线程池:数据库连接池、线程池全局只有一份
- 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,就不会重复创建
volatile 为什么不能少:new Singleton() 在 JVM 里其实是三步——①分配内存 ②初始化对象 ③把引用指向内存地址。②和③可能被指令重排:先把引用指过去、再初始化。这时另一个线程在外层 if 判断 instance != null,拿到的可能是还没初始化完的半成品对象。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 Singleton {
INSTANCE;
public void doSomething() { ... }
}枚举是 Java 里实现单例最安全的方式,因为 JVM 从底层保证了:
- 防反射:反射
newInstance()创建枚举实例会直接抛IllegalArgumentException(JVM 特判) - 防序列化:枚举天然序列化安全,反序列化不会创建新实例
- 线程安全:枚举实例初始化由 JVM 保证
所以《Effective Java》推荐"用枚举实现单例"。缺点是写法上不如 class 灵活(不能继承等),但作为单例容器足够了。
三、怎么破坏单例(面试高频)
前面 v1~v5 的 class 实现,都有两个漏洞:
① 反射:私有构造拦不住反射:
Constructor<Singleton> c = Singleton.class.getDeclaredConstructor();
c.setAccessible(true);
Singleton s = c.newInstance(); // 绕过 private 构造,又造了一个实例② 序列化:反序列化会走 readResolve 之外的道路,默认会 new 一个新对象。
应对:
- 反射:在私有构造里加判断,若实例已存在则抛异常
- 序列化:加
readResolve()方法返回已有实例 - 或者干脆用 v6 枚举,两个漏洞从根上堵死
这也是为什么说"枚举是单例的终极形态"。
小结
- 单例 = 进程内唯一实例 + 全局访问点,典型场景:配置、连接池、Spring Bean
- 演进线:饿汉(简单但浪费)→ 懒汉(省但不安全)→ synchronized(安全但慢)→ DCL(volatile 防重排) → 静态内部类(懒加载+安全+无锁,推荐) → 枚举(防反射/序列化,终极)
- DCL 的
volatile是为了禁止"引用先于初始化"的指令重排,不是可有可无 - class 实现都能被反射/序列化破坏,枚举从 JVM 层面堵死
下一步可以看同为创建型的《工厂模式》——它和单例经常配合使用(工厂返回单例),也是 Spring 里 @Bean 的核心思想(文章整理中)。
