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 对象的初始化操作,一定发生在引用赋值之前。 |
2.5 知识体系图:单例模式核心逻辑
下面这张图,我把本章的核心知识点串了一下。你可以看到,从饿汉式和懒汉式出发,引出了线程安全问题,然后通过 DCL 和 volatile 来解决。这张图我画了好几次,才把关系理清楚。
好了,这一讲我们聊了饿汉式、懒汉式、线程安全问题、DCL 以及 volatile 的作用。我个人觉得,单例模式虽然简单,但能把这些细节讲清楚,才算真正理解了。下一讲我们会继续深入单例模式的其他实现方式,比如静态内部类、枚举实现等,到时候再聊。