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) {
// 第二次检查:进入同步块之后,再次确认实例是否为空
