Skip to content

单例模式是所有设计模式里最简单、也最常被问到的一个。它背后的知识点——饿汉/懒汉、双重检查锁、volatile 的作用、枚举防反射——平时零散都接触过,但没系统地串成一条线。这篇按演进顺序捋一遍:每一版解决了什么、又遗留了什么。

一、解决什么问题

一句话:保证一个类在整个进程里只有一个实例,并提供一个全局访问点

什么时候需要"全局唯一"?典型场景:

  • 配置对象:整个应用读同一份配置,不能各读各的
  • 连接池 / 线程池:数据库连接池、线程池全局只有一份
  • Spring 的 Bean:Spring 容器里的 Bean 默认就是单例(scope=singleton
  • 日志器、缓存:全局共享一份

反面例子:如果一个类允许多个实例,但逻辑上应该唯一(比如计数器),多实例就会导致状态不一致。

二、演进:从饿汉式到枚举

单例的实现一路演进,核心矛盾始终是三个:何时创建、线程安全、能否被破坏

v1 饿汉式:简单直接

java
public class Singleton {
    private static final Singleton INSTANCE = new Singleton();
    private Singleton() {}                        // 私有构造,禁止外部 new
    public static Singleton getInstance() { return INSTANCE; }
}

类加载时就创建实例。线程安全(类加载机制保证静态字段只初始化一次),但浪费内存——哪怕你从来不用这个单例,类一加载它就建好了。

v2 懒汉式:用时才建,但不安全

java
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:安全了,但每次都要锁

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

线程安全了,但 synchronized 加在方法上,每次获取都要拿锁——即使实例早就创建好了,后续所有调用还是串行排队,性能差。

v4 双重检查锁(DCL):性能与安全的平衡

java
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;
    }
}

DCL 双重检查锁执行流程:外层判断不为 null 直接返回,为 null 才加锁,锁内再判断一次

两个 if 各管一件事:

  • 外层 if:实例已存在时完全不加锁,直接返回——解决了 v3 每次都要锁的性能问题
  • 内层 if:多个线程同时进了锁,第一个创建后,后面的进来发现 instance 已经不是 null,就不会重复创建

volatile 为什么不能少new Singleton() 在 JVM 里其实是三步——①分配内存 ②初始化对象 ③把引用指向内存地址。②和③可能被指令重排:先把引用指过去、再初始化。这时另一个线程在外层 if 判断 instance != null,拿到的可能是还没初始化完的半成品对象volatile 禁止这种重排,保证"引用可见时对象已初始化完整"。

volatile 防指令重排:正常顺序三步 vs 重排后引用先指向、初始化延后

v5 静态内部类:懒加载 + 线程安全,还不用锁

java
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 枚举:最安全,防反射和序列化

java
public enum Singleton {
    INSTANCE;
    public void doSomething() { ... }
}

枚举是 Java 里实现单例最安全的方式,因为 JVM 从底层保证了:

  • 防反射:反射 newInstance() 创建枚举实例会直接抛 IllegalArgumentException(JVM 特判)
  • 防序列化:枚举天然序列化安全,反序列化不会创建新实例
  • 线程安全:枚举实例初始化由 JVM 保证

所以《Effective Java》推荐"用枚举实现单例"。缺点是写法上不如 class 灵活(不能继承等),但作为单例容器足够了。

三、怎么破坏单例(面试高频)

前面 v1~v5 的 class 实现,都有两个漏洞:

① 反射:私有构造拦不住反射:

java
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 的核心思想(文章整理中)。