2. 单例模式(上):饿汉式与懒汉式、线程安全问题、双重检查锁定(DCL)、volatile关键字的作用

各位好,我们继续聊设计模式。今天这一讲,咱们把单例模式的上半部分掰开揉碎讲清楚。单例模式,说白了就是保证一个类只有一个实例,并且提供一个全局访问点。听起来简单,但里面的坑,尤其是多线程环境下的坑,我当年可是踩了不少。

2.1 饿汉式与懒汉式:两种最朴素的实现

先看两种最基础的写法。饿汉式,顾名思义,很“饿”,类加载的时候就把实例创建好了。懒汉式呢,很“懒”,等到第一次有人调用获取实例的方法时,才去创建。

饿汉式

public class SingletonEager {
    private static final SingletonEager INSTANCE = new SingletonEager();

    private SingletonEager() {}

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

这种写法,我个人习惯在项目里用来管理一些轻量级的、一定会用到的资源,比如日志框架的实例。优点很明显:简单、线程安全(JVM 在类加载阶段就完成了初始化,天然避免了多线程竞争)。缺点呢?如果这个类创建很重,或者你压根儿没用到它,那资源就白白浪费了。

懒汉式(非线程安全版)

public class SingletonLazy {
    private static SingletonLazy instance;

    private SingletonLazy() {}

    public static SingletonLazy getInstance() {
        if (instance == null) {
            instance = new SingletonLazy();
        }
        return instance;
    }
}

这种写法,我在早期的一个小工具项目里用过。当时觉得挺美,直到有一次压测,发现实例被创建了两次,程序行为变得诡异。为什么会这样?因为 if (instance == null) 这个判断和 new SingletonLazy() 这两个操作不是原子的。线程 A 判断为 null,还没创建呢,线程 B 也判断为 null,然后两个线程各自 new 了一个对象。嗯,这里要注意,单例模式在多线程下,必须考虑线程安全。

2.2 线程安全问题:从 synchronized 到性能瓶颈

解决懒汉式的线程安全问题,最直接的办法就是加 synchronized

同步方法版(性能差)

public class SingletonSyncMethod {
    private static SingletonSyncMethod instance;

    private SingletonSyncMethod() {}

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

这个写法,线程安全是保证了,但性能堪忧。你想想看,每次调用 getInstance() 都要经过同步锁,而实际上只有第一次创建时需要同步。一旦实例创建好了,后续的调用根本不需要加锁。我在一个高并发的配置中心服务里试过这种写法,压测结果直接掉了 30% 的吞吐量。所以,这种写法只适合并发极低的场景,或者你压根儿不在乎那点性能损失。

2.3 双重检查锁定(DCL):经典但容易写错

为了既保证线程安全,又避免每次调用都加锁,业界提出了双重检查锁定(Double-Checked Locking,简称 DCL)。

public class SingletonDCL {
    private static volatile SingletonDCL instance;

    private SingletonDCL() {}

    public static SingletonDCL getInstance() {
        if (instance == null) {                     // 第一次检查
            synchronized (SingletonDCL.class) {
                if (instance == null) {             // 第二次检查
                    instance = new SingletonDCL();
                }
            }
        }
        return instance;
    }
}

这个模式,我第一次看到时觉得挺巧妙。第一次检查避免了不必要的加锁,第二次检查保证了在锁内只创建一次。但这里有个关键点:instance 变量必须用 volatile 修饰。为什么?

核心原因: 防止指令重排序。 new SingletonDCL() 这个操作在 JVM 层面不是原子的,它大致分为三步:
1. 分配内存空间
2. 初始化对象(调用构造函数)
3. 将引用指向分配的内存地址
如果不加 volatile,步骤 2 和 3 可能被重排序。也就是说,线程 A 可能先执行了步骤 3(此时 instance 已经不为 null,但对象还没初始化完),然后线程 B 进来,第一次检查发现 instance != null,直接返回了一个未初始化完成的对象。这会导致程序出现难以排查的诡异 bug。

我曾经在一个分布式锁的客户端里遇到过这个问题。当时线上偶尔出现空指针异常,查了两天才定位到是 DCL 没加 volatile。从那以后,我写 DCL 的第一件事就是检查 volatile 有没有漏掉。

2.4 volatile 关键字的作用:不止是可见性

很多人知道 volatile 能保证可见性(一个线程修改了变量,其他线程能立即看到),但容易忽略它另一个重要特性:禁止指令重排序。

特性 说明 在 DCL 中的作用
可见性 对 volatile 变量的写操作,会立即刷新到主内存;读操作会从主内存重新读取。 保证线程 B 能看到线程 A 对 instance 的赋值。
禁止指令重排序 在 volatile 变量读写操作的前后,会插入内存屏障,阻止编译器/CPU 对指令进行重排序。 保证 new 对象的初始化操作,一定发生在引用赋值之前。
我的小建议: 如果你在写 DCL 时,不确定要不要加 volatile,那就加上。加了不会错,不加可能出大问题。而且从 Java 5 开始,volatile 的语义被加强,DCL 配合 volatile 是标准写法。

2.5 知识体系图:单例模式核心逻辑

下面这张图,我把本章的核心知识点串了一下。你可以看到,从饿汉式和懒汉式出发,引出了线程安全问题,然后通过 DCL 和 volatile 来解决。这张图我画了好几次,才把关系理清楚。

单例模式核心逻辑(饿汉式·懒汉式·DCL) 饿汉式 类加载时创建实例 懒汉式 首次调用时创建实例 天然安全 多线程下不安全 线程安全问题 多个线程可能创建多个实例 synchronized 同步 简单但性能差(每次加锁) 双重检查锁定(DCL) 兼顾性能与安全 依赖 volatile 禁止重排序
避坑指南: 我曾经在重构一个老项目时,看到有人用 DCL 但没加 volatile,还跑了好几年没出问题。这其实是因为运气好,或者 JVM 实现没有触发重排序。但你不能依赖运气。记住:DCL 必须配合 volatile 使用,这是 Java 内存模型规定的,不是可有可无的优化。

好了,这一讲我们聊了饿汉式、懒汉式、线程安全问题、DCL 以及 volatile 的作用。我个人觉得,单例模式虽然简单,但能把这些细节讲清楚,才算真正理解了。下一讲我们会继续深入单例模式的其他实现方式,比如静态内部类、枚举实现等,到时候再聊。

公众号: 蓝海资料掘金营,微信 deep3321