1. 从一次线上事故说起:你真的会用单例吗
大概两年前,我们一个订单服务的定时任务在凌晨高峰期偶发内存溢出,排查到最后,问题出在一个工具类的实例化上。那个类被设计成单例,用的是最普通的懒汉式写法,synchronized 加在方法上,结果在高并发下频繁争锁,任务队列越积越多,直接把内存打爆了。当时团队里有个同事说:为什么不试试双重检查锁?
说实话,双重检查锁(Double-Checked Locking,DCL)这个词,在 Java 面试八股文里出现频率极高,但真正能把它讲清楚、写对的人不多。我见过不少简历上写着“熟悉并发编程”的候选人,手写 DCL 时要么漏掉 volatile,要么把 synchronized 锁错了对象,要么压根说不清为什么要判断两次。
这篇文章不打算讲教科书上的大道理,而是从实际项目出发,把懒汉式单例的演进过程、双重检查锁的每一步都拆开揉碎,讲清楚每个 if、每个关键字、每个变量修饰符背后的原因。适合正在准备 Java 面试的开发者,也适合那些虽然天天写代码,但没想过“为什么这么写”的工程师。
整个拆解过程我会按照这么一条线走:先搞清楚单例模式和懒汉式的基本概念,再看线程安全是如何一步步引入的,然后重点剖析双重检查锁的完整实现,最后聊一聊 DCL 的局限性和替代方案。每一步都会配上踩坑经历和面试官可能会追问的问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先搞清楚:单例模式和懒汉式到底在解决什么问题
2.1 单例模式的本质:全局只有一个实例
单例模式可能是设计模式里最“简单”的一个,但也是最容易被写错的一个。它的核心约束只有两条:一个类只能有一个实例;这个实例必须能被全局访问到。听起来是不是很像静态变量?没错,从某种角度看,单例模式就是在“全局变量”和“面向对象”之间找平衡。
举个例子,一个项目里的配置中心客户端、数据库连接池、线程池管理器,这些对象如果被反复创建,会带来严重的资源浪费。配置中心客户端每次新 new 一个,就要重新建立网络连接;线程池被 new 好几次,线程就会被重复创建,最后系统直接崩掉。这时候单例模式就派上用场了——不管你从哪里调用,拿到的永远是同一个实例。
Java 里实现单例的方式有七八种,什么饿汉式、懒汉式、静态内部类、枚举、容器管理……如果按“实例创建时机”来分,就两大类:类加载时直接创建(饿汉式),和第一次使用时才创建(懒汉式)。双重检查锁属于懒汉式的一种经典优化写法。
2.2 懒汉式与饿汉式的核心差异
可能有人会觉得:直接饿汉式不就行了?类加载的时候就 new 好,天然线程安全,代码还简单。这确实是一种方案,但它有一个前提——你能接受类加载的“副作用”。
假设一个工具类里既有静态方法又有单例实例,饿汉式意味着只要这个类被引用,实例就会被创建。如果这个实例比较“重”,初始化时要加载配置文件、建立连接池,那你的应用启动时间会明显变长。更麻烦的是,有些实例的创建依赖于运行时才能确定的条件(比如某个环境变量、某个外部配置),饿汉式就没办法处理这种情况。
懒汉式的好处就是按需创建、延迟加载,核心逻辑用一句话说就是:先判断实例是否为 null,是就创建,不是就直接返回。但这句话放到多线程环境下就出问题了——两个线程同时判断 instance == null 都成立,然后各自 new 了一个实例出来,单例就变成“双例”甚至“多例”了。
解决线程安全最简单的办法是给创建方法加 synchronized,但这又引入了并发性能问题:每次获取实例都要经历一次锁的获取和释放,在争用激烈的情况下开销很高。双重检查锁的思路就是在这两者之间找一个平衡点——既延迟加载,又尽量少的锁开销。
3. 手写一份双重检查锁:每一行代码都有它的理由
3.1 标准实现代码逐行拆解
先把最标准的双重检查锁懒汉式单例代码贴出来,后面再逐行解释:
java复制public class Singleton {
// volatile 关键字是双重检查锁能够正确工作的关键
private static volatile Singleton instance;
// 构造方法私有化,外部无法直接 new
private Singleton() {
}
public static Singleton getInstance() {
// 第一次检查:如果实例已经存在,直接返回,避免进入同步代码块
if (instance == null) {
// 只有 instance 为 null 时才需要进入同步块
synchronized (Singleton.class) {
// 第二次检查:进入同步块之后,再次确认实例是否为空
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
这是一个标准的双重检查锁实现,代码量很小,但里面每一个细节都是前辈们踩了无数坑之后总结出来的。下面我会逐个点解释,尤其是那个 volatile,没有它整个写法就是错的。
3.2 为什么检查两次:每一层检查解决不同的问题
很多初学者看到两个 if (instance == null) 都会疑惑:这不是多余的吗?外面检查一次,里面再检查一次,到底在防谁?
第一个 if 是性能优化。想象一个场景:在高并发下,100 个线程同时调用 getInstance(),如果没有第一个 if,所有线程都会无条件进入 synchronized 块排队。但问题是,99% 的情况下单例早就创建好了,这些线程进同步块完全是浪费时间。先做一次空判断,能过滤掉绝大多数请求,只有实例还没被创建的那一小段时间内,线程才会走到锁逻辑。
第二个 if 是线程安全的关键。假设线程 A 和线程 B 同时通过了第一个 if,线程 A 抢到了锁,创建了实例,然后释放锁;线程 B 接着抢到锁,如果此时不做第二次判断,它会在不知道实例已被创建的情况下再 new 一个出来。第二次 if 就是防止这种“冤案”发生。
一句话总结:第一次检查拦截已经创建好的情况,第二次检查拦截竞争锁期间被别人创建好的情况。两次检查各司其职,少了谁都不行。
3.3 volatile 的真正作用:禁止指令重排序
这一节是面试中的重点,也是实际开发中最容易被忽略的。在早期的 Java 内存模型下,双重检查锁其实是一个不安全的写法,根本原因就在于 new Singleton() 这行代码在 JVM 执行时并非原子操作。
JVM 创建对象大致分三步:分配内存空间;调用构造方法初始化对象;把引用指向这块内存。这里的问题出在第二步和第三步——JVM 在编译期和运行期都可能对指令进行重排序,也就是说,第三步可能先于第二步执行。
画个场景你就明白了:线程 A 执行到 instance = new Singleton(),JVM 先分配了内存,然后把引用赋值给了 instance(此时实例还没初始化完,但 instance 已经不为 null)。这时线程 B 也来调用 getInstance(),第一个 if 发现 instance 不为 null,直接返回——拿到的却是一个没有初始化完成的“半成品”对象,程序运行到一半可能就抛 NullPointerException 了。
volatile 在这里做了两件事:一是保证实例对多线程的可见性,线程 A 修改后的值能立刻被线程 B 看到;二是禁止 JVM 对 volatile 变量赋值操作前后的指令进行重排序,保证“初始化完成”和“引用赋值”之间有个先后顺序。说白了,就是确保任何线程都不会拿到一个没初始化完的对象。
加了 volatile 之后,DCL 才是一个完整的、正确的实现。这也是为什么面试官看你写 DCL 时会特别盯住这个关键字,漏掉它,一切都是空谈。
4. 从无锁到锁:懒汉式线程安全的三种演进路线
4.1 简单粗暴:方法级同步的懒汉式
双检锁不是凭空冒出来的,它是从最朴素的懒汉式一步步优化过来的。最早解决线程安全的写法是这样:
java复制public class Singleton {
private static Singleton instance;
private Singleton() {
}
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
写起来很简单,直接用 synchronized 修饰整个方法。但在高并发的场景下,所有线程访问 getInstance() 都要经过锁的竞争和释放,即使实例已经被创建,依然要排队获取锁。我在前面的线上事故里就是栽在这个上面——方法频繁被调用,锁竞争激烈,系统吞吐量急剧下降。虽然 JVM 对无竞争锁有优化(偏向锁、轻量级锁),可一旦真的竞争起来,开销还是很大。
4.2 引入双重检查锁:性能与安全性的平衡
基于“同步代码块可以比同步方法更精细”的思路,有人提出了大胆的优化:只在实例还没创建的时候才同步,创建完以后直接非同步访问。这就是双重检查锁的雏形。
但前面也提到了,早期 Java 内存模型(JMM)下,这种方案由于指令重排序的存在是不安全的。直到 JDK 1.5 引入了新的内存语义,volatile 才能提供“禁止重排序”的保证,DCL 才算真正成为一个可靠的实现。
从代码层面看,DCL 相比方法级同步有一个本质的变化:锁的粒度从“所有访问”缩小到了“实例为空的那一瞬”。一旦实例创建完成,后续所有的访问都是无障碍的,性能损耗几乎为零。
4.3 为什么不直接锁类对象或使用其他锁机制
有同学会问:synchronized (Singleton.class) 锁的是类对象,能不能换成 new Object()?当然不能。锁对象必须是所有线程共享的,如果你在方法内部 new Object(),每个线程拿到的是不同的锁对象,锁就完全失去了意义。锁 Singleton.class 因为类对象在 JVM 中只会存在一份,所以能保证不同线程之间互斥。
还有人在想能不能用 Lock 或 ReentrantLock 替代 synchronized?可以,但在这种简单场景里没必要。synchronized 由 JVM 内置支持,经过多年的校优化,在无竞争情况下开销极低;ReentrantLock 虽然提供了更灵活的锁控制(超时、可中断、公平锁),但在这里属于杀鸡用牛刀。后面静态内部类和枚举的方案甚至可以直接避开锁的问题,那就更没必要纠结了。
5. 双重检查锁的完整代码实现与参数选择
5.1 完整可运行的案例(含测试代码)
在真实项目中,我往往会在这个基础单例上做一点功能扩展:支持序列化、避免反射破坏。下面是一段我在实际项目中使用的较完整版本:
java复制import java.io.Serializable;
public class Singleton implements Serializable {
private static final long serialVersionUID = 1L;
private static volatile Singleton instance;
private Singleton() {
// 防反射:如果实例已存在,再调用构造方法就抛异常
if (instance != null) {
throw new IllegalStateException("already exists");
}
// 模拟初始化配置
System.out.println("Singleton constructor invoked");
}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
// 防止序列化破坏单例
private Object readResolve() {
return instance;
}
}
写一段简单的多线程测试来验证效果:
java复制public class SingletonTest {
public static void main(String[] args) {
// 用 10 个线程同时尝试获取实例,观察是否唯一
for (int i = 0; i < 10; i++) {
new Thread(() -> System.out.println(Singleton.getInstance().hashCode())).start();
}
}
}
正常输出的 10 个 hashCode 应该是完全相同的,如果中间出现不同的 hashCode,说明单例被破坏,代码有问题。实际运行中可以加一个 CountDownLatch 来控制线程同时出发,这里为了简洁没有加,但多跑几次后仍能看到并发场景下实例是唯一的。
5.2 关键参数说明:serialVersionUID、volatile 的作用范围
这个看起来稍微复杂一点的版本里加入了 Serializable 接口和 serialVersionUID。为什么要做这些?如果你没有考虑序列化,直接把单例对象写入文件,再反序列化出来,会得到一个新实例——单例被破坏了。实现 Serializable 接口并提供 readResolve() 方法,可以指定反序列化时返回已有的单例对象。
serialVersionUID 的作用是版本控制。如果类在序列化之后发生了结构性修改(例如增加字段),JVM 会检查 serialVersionUID 是否一致,不一致会抛出异常。显式声明一个固定的 UID 可以避免这类问题,这也是我在生产环境中习惯直接写死它的原因。
volatile 变量只能修饰字段,不能修饰局部变量。在 DCL 中它会作用于 instance 这个静态字段的读写操作上。特别注意一点:volatile 仅保证读写操作的有序性和可见性,并不保证原子性。instance = new Singleton() 这一步因为有 volatile 的保护,从结果上看是原子的,但如果是 count++ 这种操作,加 volatile 依然会存在并发安全问题。
5.3 双重检查锁必须搭配的一个守卫:防止反射攻击
单例模式的私有构造方法,只能防住普通的调用方使用 new,但防不住反射。如果别人通过 setAccessible(true) 强行调用私有构造方法,单例一样会被破坏。
我上面的代码在构造方法里做了一个简单防御:如果实例已存在,再调用构造方法就抛异常。这个防御有个小问题——如果两个线程通过反射同时去调用构造方法,而 instance 还是 null,任然可能创建出两个实例。要彻底防住反射,更稳妥的做法是加一个静态布尔标志位,或者直接用枚举实现单例(反射对枚举无效)。实际项目中看你在什么层级做防御,通常面试中能答出“构造方法里判空抛异常”就足够了。
6. 面试官的连环追问:DCL 用得好不好,看这里就知道了
6.1 为什么不用静态内部类或枚举
在面试中,如果你能主动说出 DCL 的替代方案,会明显增加面试官的好感。常见的替换方案有两种——静态内部类和枚举。
静态内部类的写法很优雅:
java复制public class Singleton {
private Singleton() {
}
private static class Holder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE;
}
}
原理是 JVM 在加载 Singleton 类时不会立即加载 Holder,只有在调用 getInstance() 时才会触发 Holder 的加载和初始化。类加载过程天然线程安全,不会有多线程并发创建多个实例的问题。这种写法比 DCL 更简洁,连 volatile 都不用声明。
那是不是静态内部类就彻底取代 DCL 了?不完全是这样。在实际开发中,我给别人的建议是:能用枚举就用枚举,但如果你是去理解并发优化的思路、准备面试,DCL 依然是要掌握的重点。枚举方案写法最简洁:
java复制public enum Singleton {
INSTANCE;
// 业务方法
public void doSomething() {
}
}
枚举从根本上杜绝了反射、序列化攻击,因为 JVM 对枚举做了特殊保护。但它的缺点是:枚举在类加载时就会初始化,相当于饿汉式的变种,做不到真正的延迟加载。
6.2 懒汉式单例在高并发场景下的性能考量
DCL 的性能优势主要体现在实例创建之后。一旦实例被创建,每一次 getInstance() 都只是一个简单的 null 判断和返回,没有任何锁竞争,性能几乎等同于读取一个普通变量。
实例创建的那一瞬间,多个线程可能同时进入同步块,但锁只会让一个线程成功创建,其他线程在锁外排队,等锁释放后再次检查发现实例已存在,直接返回。这个过程虽然有一点线程阻塞,但只发生在系统启动或第一次调用时,对整体性能影响可以忽略。
从这个角度看,DCL 比方法级同步好很多,比饿汉式又灵活的很多,所以它是一个非常典型的“既想延迟加载,又不想太伤性能”的折中方案。
6.3 双重检查锁在 JDK 不同版本下的表现
早期 JDK 1.4 及之前的版本,volatile 的内存语义并不像现在这么强,无法阻止提前暴露未初始化对象,所以当时的 DCL 实现被认为是不可靠的。到了 JDK 1.5 之后,JSR-133 内存模型对 volatile 做了重新定义,强化了有序性和可见性保证,DCL 才被官方认为是一种有效的写法。
现在大家普遍使用 JDK 8、JDK 11、JDK 17,内存模型已经非常成熟,DCL 在这些版本下不会有问题,但理解这段历史还是有用的——它能帮你理解为什么很多老书上都写着“不要用双重检查锁”,而你现在学习的资料却又说它是标准写法。时代变了,JMM 也变了。
7. 常见问题与排查技巧实录
7.1 典型案例:DCL 返回了不同对象
现象:多线程环境下,Singleton.getInstance() 偶尔返回不同的 hashCode。
排查思路:先检查 instance 是否写了 volatile,这是最可能的原因。没有 volatile,就可能出现一个线程看到另一个线程“半初始化”的对象,还可能看到过期的 null 值。其次是检查是否有多 ClassLoader 导致 Singleton.class 被加载了多份。在应用服务器(Tomcat 等)中,同一个类可能被多个 ClassLoader 加载,各自持有自己的 instance,那单例实际上就变成了“类加载器级别”的单例。最后要确认是否被序列化和反射破坏。
7.2 避坑指南:构造方法里做太多初始化
DCL 只是保证了实例的唯一性,并不负责初始化速度。有些人喜欢在构造方法里写一大堆耗时操作,比如加载配置、连接数据库、初始化连接池,这会导致一个严重的问题——第一次调用 getInstance() 时会卡很久。
线上遇到过一次类似的事:某个老系统启动后第一个请求特别慢,查了半天发现不是 SQL 的问题,而是单例在构造函数里扫了整个配置文件目录。DCL 本身没问题,问题出在“懒加载”时做重活。
解决办法有两类:一是把耗时初始化拆出来,用线程安全的懒加载方式单独做;二是干脆别用懒汉式,改用饿汉式或“启动时预热”,把耗时操作放到系统初始化阶段去完成。这也是为什么我在设计一些重量级工具类时,会优先考虑在应用启动阶段就创建好,而不是等到用户第一次调用的那一刻。
7.3 一个容易被忽略的细节:锁对象的选择
synchronized (Singleton.class) 锁的是类对象,同一类加载器下全局唯一,跨线程安全。但如果你写成:
java复制synchronized (instance) { ... }
那就出问题了——如果 instance 是 null,那等于锁了一个 null,直接抛 NullPointerException;如果 instance 不为 null,那你锁的是已经被修改的引用,无法保证其他线程访问时的可见性。至于锁一个局部变量,那就更没意义了,每个线程都有自己的副本,形同虚设。
记住一点:锁对象必须是所有线程都能看到、且不会发生改变的“公共锚点”。类对象就是最理想的选择。
7.4 面试追问合集:你能扛住几个?
面试官在聊到单例和 DCL 时,通常会沿着几条线追下去:
- 懒汉式和饿汉式的本质区别是什么?各有什么优缺点?
- 双重检查锁为什么需要两次检查?去掉第一次或第二次会怎样?
volatile在这里到底解决了什么问题?能替换成final吗?- 如果构造方法抛出异常,DCL 会怎么表现?
- 静态内部类为什么线程安全?枚举为什么能防反射和序列化?
- 单例是否永远不会被破坏?有哪些破坏手段,怎么防御?
- 如果单例的实例很大,你还会用懒汉式吗?你会选哪种方案?
每一条都能展开讲十几分钟。本质上考的不是“你会不会背代码”,而是你能不能理解 JMM、理解类加载机制、理解并发控制,然后把它们真正落地到生产代码里。
8. 最后聊点实际的:我做项目时的选型倾向
如果你要我现在做一个新项目,我大概率不会直接用 DCL,而是优先选枚举或者静态内部类。为什么?不是因为 DCL 不好,而是因为它给的“灵活性”在绝大部分项目中用不上。延迟加载、按需初始化这些需求,用静态内部类就能实现,代码还更简单。真要防反射防序列化,枚举直接帮你锁死了。
但如果是老项目改造,或是既要延迟加载、又要尽可能不改变原有类的结构,DCL 是一个很实际的方案。它不需要引入额外的接口或枚举约束,改动最小,也能和现有代码无缝衔接。
在面试场景里,DCL 更像是一块试金石,测试你对 Java 并发的理解深度。写对 volatile,说明你知道指令重排序;能解释两次判断,说明你理解锁和可见性;能主动对比静态内部类和枚举,说明你有全局意识。
我自己的经验是:把 DCL 当成研究 JMM 的一把钥匙,而不是背下来的一个模板。等你理解了它背后那些原理,再去写任何一种单例模式,都会觉得心里非常有底,而不是靠着记忆硬抄代码。那时候面试官问到最后,你甚至可以主动指出 DCL 在极端场景下的适用边界,这比背一百道八股文都有用。
