单例模式深度解析:从饿汉式到双重检查锁(DCL)的底层原理

作者:小饼干 | 2026年5月14日

单例模式作为 Java 设计模式中最基础也最常被问查的模式,核心目标很简单:确保一个类只有一个实例,并提供一个全局访问点。

但在多线程并发的环境下,想要写出一个既高效又安全的单例,其实有很多底层细节值得推敲。今天我们来聊聊单例的两种主流实现及其背后的内存模型。


一、 饿汉式(Eager Initialization)

饿汉式非常直观,它在类加载阶段就完成了实例的初始化。

1. 代码实现

public class SingletonEager {
    // 类加载时就完成了初始化
    private static final SingletonEager instance = new SingletonEager();

    private SingletonEager() {}

    public static SingletonEager getInstance() {
        return instance;
    }
}

2. 特点分析

  • 优点:写法非常简单。由于实例在类加载时创建,利用了 JVM 的类加载机制来保证天然的线程安全,不需要额外的同步消耗。
  • 缺点:不支持延迟加载。不管你程序中用不用这个实例,它都会提前创建好占用内存。如果这个对象很大或者初始化很重,可能会造成一定的内存浪费。

二、 懒汉式与双重检查锁(DCL)

为了解决内存占用问题,我们通常会选择“懒汉式”——即在第一次使用时才去创建实例。为了兼顾性能和线程安全,双重检查锁(Double-Check Locking, DCL) 成了标准写法。

1. 代码实现

public class Singleton {
    // 必须配合 volatile 关键字使用
    private volatile static Singleton uniqueInstance;

    private Singleton() {}

    public static Singleton getUniqueInstance() {
        // 第一重判断:如果对象已创建,直接返回,避免不必要的加锁开销
        if (uniqueInstance == null) {
            // 类对象加锁
            synchronized (Singleton.class) {
                // 第二重判断:在捕获锁之后再次检查,防止阻塞在锁上的多个线程重复创建实例
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}

2. 特点分析

  • 优点:实现了延迟加载(Lazy Loading),仅在第一次调用时占用内存。通过 synchronized 块缩小了锁的范围,性能优于直接在方法上加锁。
  • 痛点:代码逻辑相对复杂,且必须通过 volatile 来规避 指令重排 带来的潜在风险。

三、 深挖底层:为什么一定要加 volatile?

很多同学理解 DCL 时会忽略 uniqueInstance 前声明的 volatile。事实上,没有它,DCL 并不安全。

当我们执行 uniqueInstance = new Singleton(); 这行代码时,在汇编层面其实分成了 三步 执行:

  1. 分配空间:为 uniqueInstance 分配内存空间。
  2. 初始化:执行构造函数,初始化对象内容。
  3. 赋值引用:将 uniqueInstance 指向分配好的内存地址。

1. 指令重排的陷阱

由于 JVM 具有指令重排(Instruction Reordering)的特性,上述步骤的执行顺序可能从 1->2->3 变成 1->3->2

在单线程下这没有问题,但在多线程环境下:

  • 线程 T1 执行了步骤 1 和 3(此时对象已指向内存空间,但还未初始化)。
  • 线程 T2 调用 getUniqueInstance(),执行第一重判断 if (uniqueInstance == null)
  • 此时 uniqueInstance 不为空,T2 直接返回了这个尚未初始化完成的对象。如果 T2 接着访问该对象的方法,就会报错。

2. Volatile 的救赎:内存屏障

加了 volatile 后,JMM(Java 内存模型)会插入特定的内存屏障来禁止这种重排序:

  • StoreStore 屏障:在 volatile 写(赋值引用)之前插入。保证步骤 1 和 2 的写入操作物理上先于步骤 3 完成。也就是说,引用的赋值绝对不会跑到初始化之前。
  • StoreLoad 屏障:在 volatile 写之后插入。保证步骤 3 的写入结果对其他线程立即可见。

有了这层屏障,当线程 T2 读取 uniqueInstance != null 时,我们可以 100% 保证该对象已经是一个完全初始化的可用对象了。


四、 总结

  • 如果你追求简单省事,且对象占用资源不高,饿汉式是首选。
  • 如果你追求极致性能和延迟加载,请务必使用 DCL + volatile

这就是单例模式中的“细节决定成败”。作为 Java 开发者,不仅要会写代码,更要理解代码背后 JVM 和内存模型的运行逻辑。

希望这篇分享对你有帮助,我们下次再见!


版权声明:本文归“小饼干”所有,欢迎专业讨论。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐