Skip to content

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

一、解决什么问题

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

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

  • 配置对象:整个应用读同一份配置,不能各读各的
  • 连接池 / 线程池:数据库连接池、线程池全局只有一份
  • 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,就不会重复创建

为什么 DCL 多了个 volatile:外层 if 是无锁读,缺了 synchronized 的保证;volatile 防止"引用先指向、初始化延后"的指令重排,保证拿到的一定是完整实例。想搞懂 volatile 的原理,看《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 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 实现,都有两个漏洞:

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

java
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 容器:

  1. 启动扫描到 @Service 标记的类
  2. 用反射创建实例
  3. 存进容器的单例缓存池(singletonObjects,一个 ConcurrentHashMap
  4. 之后每次注入 / getBean() 都从缓存池拿同一个实例,不再 new
java
// 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 的核心思想。