单例模式深度解析:懒汉式与饿汉式的线程安全与JVM原理
发布时间:2026/10/20 7:52:27 锦皓数字建站

做后端开发这些年我调查过的线上问题里最简单也最气人的一类就是“资源被重复加载”。印象很深的一次某个定时任务服务里的配置对象被 new 了两份两套线程各用各的配置导致任务参数对不上线上出现了重复扣费。排查到最后根因特别朴素负责加载配置的类没有做单例约束谁调用就 new 一个。也正是从那一次起我把“单例模式”从“八股文知识点”的队列里拎出来单独认真过了一遍。单例模式Singleton Pattern确保一个类在整个应用生命周期里只有一个实例并提供一个全局访问点。它的实现思路里最有名的两条路线就是“懒汉模式”和“饿汉模式”这篇文章从原理到实战把这两兄弟彻底讲透。如果你已经知道单例是干什么的但没细想过线程安全背后的 JVM 机制或者你正在为一个类的写法纠结这篇文章刚好能给你一个相对完整的决策参考。下面我开始一步步拆。1. 两兄弟是怎么来的单例模式要解决的“多实例之乱”1.1 一个真实的重复创建事故先回到前面说的定时任务服务。表面上它的代码结构清晰A 模块从数据库读出用户名单B 模块根据配置计算分成比例C 模块执行扣费。问题在于“用户名单读取器”和“分成配置中心”各自都通过构造函数初始化了一份配置对象而构造函数里又有远程拉取配置的逻辑。测试环境数据量小看不出异常一到大促数据量上来同一套配置被初始化十几遍每次初始化都有不稳定的网络耗时和随机失败再加上多个线程同时在跑服务就出现了间歇性的配置错乱和重复扣费。修复方式其实不是上多复杂的分布式锁而是让这个类保持单例。目标有三个其一类对外只有一个全局访问点其二实例在进程内唯一其三外部无法绕过访问点随意 new。这就是单例模式的全部要义。但“保持唯一”说起来简单真正实现起来却因为“什么时候创建实例”这个问题演化出了两种截然不同的风格一类迫不及待地创建等不及别人来取另一类磨磨蹭蹭被调用时才动手。前者被大家叫成饿汉式后者叫懒汉式。1.2 “懒”与“饿”的命名逻辑懒汉式Lazy Initialization就好比冬天的你闹钟响了三次都不肯掀被子直到最后一秒不得不起床才一个翻身跳起来。映射到代码里就是实例字段一开始是 null只有第一次调用 getInstance() 时发现字段为空才执行 new。它把创建动作延迟到“非用不可”的时刻核心追求是“不浪费、按需加载”。饿汉式Eager Initialization则像一个看见自助餐就先把盘子叠满的人类一被加载就把实例 new 好放在那里不管后面有没有人取先准备着再说。映射到代码里就是static final 字段在类加载初始化阶段就完成赋值getInstance() 只是把现成对象交出去。它追求的是“最简单、最稳”代价是可能提前创建。这里我补一句很多新手觉得饿汉式更“先进”因为代码短懒汉式更“聪明”因为延迟加载。但真实工程里没有绝对优劣只有适不适合。这两个名字也暗示了它们最核心的分歧实例创建的时机以及为了这个时机要付出多少并发控制的代价。后面我会把两者的细节和坑逐个展开。2. 饿汉式类加载的那一刻实例已经端到嘴边2.1 饿汉式长什么样先看最经典的饿汉式代码public class ConfigCenter { // 唯一实例类加载初始化阶段完成创建 private static final ConfigCenter INSTANCE new ConfigCenter(); private ConfigCenter() { // 这里做一些较重的初始化动作比如加载远程配置、建立连接 System.out.println(ConfigCenter initialized.); } public static ConfigCenter getInstance() { return INSTANCE; } public String getConfig(String key) { return config- key; } }注意几件事构造器是 private外部无法 new 新的对象INSTANCE 是 static finalstatic 保证全局唯一final 保证引用不可重写getInstance() 方法本身没有任何同步也没有空判断。读者可能会问万一外部用反射调用私有构造器呢这个坑我放到第四章统一讲。在了解破坏者之前我们先把饿汉式的线程安全逻辑弄清楚。2.2 为什么说饿汉式的线程安全是“白送的”这是最重要的知识点。很多人以为饿汉式线程安全是因为“static final”字段本身是线程安全的其实真正的原因是 JVM 类加载过程的初始化阶段。根据《Java虚拟机规范》类从加载到初始化要经历加载、验证、准备、解析、初始化。在初始化阶段JVM 执行clinit()方法——这个方法会收集静态字段的赋值语句和静态代码块。关键点在于JVM 会在多线程环境下为clinit()方法加锁当一个线程正在初始化某个类时其他线程如果也触发了这个类的初始化会阻塞等待初始化完成后它们读取到的是完整发布的对象。也就是说你在饿汉式里一个字锁都不用写JVM 已经替你保证了“这段初始化代码只会被一个线程完整执行一次”。有些读者会把“JVM 加锁保证类初始化只执行一次”理解成“那 DCL 也不必加了”这是不对的。饿汉式的线程安全成立前提是实例创建确实发生在类初始化阶段而懒汉式里的 new 是写在 getInstance() 方法内部的普通代码不在clinit()里JVM 不会为它提供任何免费的同步保护。2.3 饿汉式的真实代价启动即创建没得商量饿汉式的代价也来自“类加载即初始化”。第一是启动变慢。如果类的构造函数里有建立数据库连接池、拉取远程配置、解析大配置文件等重逻辑那么这个类的初始化时间会被拖到启动路径上。哪怕你的服务其实根本不需要这个功能只要某个入口代码触发了类初始化实例就会被硬生生创建出来。第二是资源提前占用。一个连接池对象创建成功后连接通常是既有的如果随后业务根本没调用它这些连接就干等在那里白白消耗数据库连接数。第三是缺乏灵活性。实例只对应无参构造器你没法在运行时根据环境动态传入参数。虽然可以后期用 init 方法补充但单例模式本身不建议搞复杂初始化接口。我以前接手过一个项目里面有用饿汉式写的“规则引擎单例”构造函数会加载几十个规则文件启动速度直接慢了好几秒。后来排查发现这个规则引擎只在个别带有“营销活动”标签的接口才会用到完全没有必要跟着应用一起启动。把这段逻辑改成懒汉式之后启动时间立刻降了一个量级。这就是饿汉式的真实代价——它不是代码量的问题而是“时机”问题。3. 懒汉式第一次被调用才动手线程安全要自己扛3.1 最朴素的懒汉线程不安全到底不安全在哪最直观的懒汉式写法public class LazySingleton { private static LazySingleton instance; private LazySingleton() { System.out.println(LazySingleton initialized.); } public static LazySingleton getInstance() { if (instance null) { instance new LazySingleton(); } return instance; } }这个写法在单线程下完全没问题第一次调用创建后续调用直接返回。但一放到多线程环境就原形毕露。假设线程 A 和线程 B 几乎同时调用 getInstance()两个线程都看到了 instance 等于 null都进入了 if 块于是各自执行一次 new LazySingleton()。此时进程里就有两个不同的实例单例被打破。更糟糕的情况是对象发布不完整在没有同步的情况下线程 A 把 instance 的引用写出去线程 B 读取到这个非 null 的引用但它实际指向的对象可能还没完成构造字段还是默认值B 直接拿着这个“半成品”去使用轻则拿到错误数据重则直接抛空指针。所以“懒加载”天然和线程安全有冲突判断 null、创建实例、发布引用这三件事之间存在时间窗口必须靠同步机制把窗口关掉。3.2 synchronized 方法版安全了但每次访问都排队最简单的修复给 getInstance() 方法加 synchronized。public class LazySingleton { private static LazySingleton instance; private LazySingleton() {} public static synchronized LazySingleton getInstance() { if (instance null) { instance new LazySingleton(); } return instance; } }这个方案线程安全确实没问题因为是静态方法锁对象是当前 Class 对象两个线程同时进入时只有一个能执行另一个会阻塞等待instance 的写发生在锁内读也发生在锁内不存在可见性问题。可性能上就很不划算了。synchronized 方法版把所有调用者串行化即使是实例已经创建好的“纯读”场景每个线程也必须先拿到锁才能走下去。高并发下这等于把 getInstance() 的吞吐量砍掉大半。如果单例对象本身是轻量级的、只负责返回配置值那这个锁产生的开销可能比业务逻辑还大。我见过有的线上应用把这种写法用在热点路径上压测时接口的 TPS 上不去排查后发现每次请求都卡在 getInstance() 的锁上。所以这个方法版本只适合“低频访问 创建逻辑简单”的场景作为教学演示很好工程上基本不推荐。3.3 DCL 双重检查锁空判断、加锁、再空判断DCLDouble-Checked Locking双重检查锁是对上面方案的优化public class DCLSingleton { // volatile 很关键下面会讲 private static volatile DCLSingleton instance; private DCLSingleton() {} public static DCLSingleton getInstance() { if (instance null) { // 第一次检查无锁快速路径 synchronized (DCLSingleton.class) { if (instance null) { // 第二次检查拿到锁后再确认 instance new DCLSingleton(); } } } return instance; } }逻辑分三层第一次 if 判断放在同步块外面是为了避免无谓的锁竞争。实例已经创建好的情况下读 volatile 引用本来就是原子的直接返回即可。synchronized 块只包住“创建”这一段锁的粒度比 synchronized 方法版小很多。同步块里还要再做一次 instance null 的判断是为了处理两个线程同时通过第一次检查的情况线程 A 抢到锁并创建实例线程 B 在 A 释放锁后进入同步块如果不再判断一次B 会再创建一个新实例。第三点是初学者最容易漏的。只做一次判断然后直接 new会出现“两个实例交替出现”的诡异 bug而且因为是偶发测试环境很难复现。写 DCL第二次空判断必须成为肌肉记忆。这里我要额外强调 volatile 的作用。很多人知道要加但说不上来为什么。new DCLSingleton() 这行代码在字节码层面不是一个原子操作它大致拆成三步在堆上分配一块内存在内存上调用构造器完成对象初始化把内存地址赋值给 instance 引用。如果不用 volatile编译器和 CPU 可能对 2 和 3 做指令重排变成先赋值引用、后执行构造器。这时另一个线程在持有锁之前通过外层 if 判断发现 instance 不为 null于是直接返回它而实际上对象里的字段还是默认值使用端就会拿到一个“半初始化”的实例。volatile 的语义正好禁止了这种重排序保证只有对象完全构造完成后引用才会对外可见。多说一句历史DCL 在 JDK 5 之前的旧内存模型下即便写了 volatile也不能保证线程安全因为旧语义对 volatile 写入后的可见性定义有漏洞。从 JDK 5 开始引入 JSR-133 内存模型后DCL volatile 才是公认的安全组合。看到老系统里“没有 volatile 的 DCL”不要轻易说它没问题要结合它跑的 JDK 版本再下结论。3.4 静态内部类既想懒又想安全的最优解如果你既想要懒加载又不愿意背 DCL 那么多注意事项我会推荐静态内部类写法也有不少人叫它 Holder 模式public class HolderSingleton { private HolderSingleton() {} // 静态内部类外部类初始化时不会触发它的初始化 private static class InstanceHolder { private static final HolderSingleton INSTANCE new HolderSingleton(); } public static HolderSingleton getInstance() { return InstanceHolder.INSTANCE; } }这个方案巧妙地把“创建实例”挪到了一个静态内部类的clinit()里。外部类 HolderSingleton 被加载时并不会触发内部类 InstanceHolder 的初始化只有 getInstance() 第一次执行到 InstanceHolder.INSTANCE 的一瞬才会触发 InstanceHolder 的类初始化。而类初始化的过程前面说过是 JVM 加锁保证同步的。这就同时拿到了懒加载、线程安全、无显式锁、性能好这些优点。所以我的默认推荐里懒汉式的首选通常是静态内部类而不是 DCL。DCL 适合你已经用了老代码、不方便改成内部类结构的场景。两者从线程安全强度上讲都一样但从可读性和维护性上讲静态内部类更不容易被后面的维护者改坏。4. 从 JVM 到字节码两者的关键差异藏在类加载和内存模型里4.1 类初始化时机谁在“饿”谁在“懒”要真正理解两者差异得立足类加载时机。JVM 规范把类初始化的触发条件叫“主动使用”常见有几类创建类的实例new、访问类的静态字段不含编译期常量、调用类的静态方法、反射类操作、初始化子类时父类会先初始化以及把类作为 JVM 启动的主类。用这个规则回看饿汉式只要外部类触发了主动使用静态字段 INSTANCE 的赋值语句就会在clinit()中被执行实例立即创建。这就是它“饿”的原因——实例创建跟类的初始化深度绑定。而静态内部类方案里外部类初始化并不触发内部类初始化只有 getInstance() 第一次访问 InstanceHolder.INSTANCE 时JVM 才把这个内部类变成主动使用触发它的初始化。这就是它“懒”的原因。用表格来对照一下这组差异对比维度饿汉式懒汉式静态内部类实例创建时机外部类初始化时第一次调用 getInstance 时线程安全来源JVM 保证clinit()JVM 保证内部类clinit()是否需要显式锁不需要不需要类加载即创建是否代码理解成本低中这张表的结论很明确两者都不是靠业务代码里的锁来实现并发的而是把并发控制下沉到 JVM 的类加载机制里。这也是很多人学单例模式越学越迷糊的根源——他们总盯着 Java 关键字没抬头看 JVM 在做什么。4.2 指令重排与 volatileDCL 里那个 volatile 不是摆设继续深挖 DCL。为什么不能把 DCL 里的 instance 声明成普通 static 字段然后靠 synchronized 块来保证安全问题出在“锁外读”那一步。DCL 的第一次 if 判断发生在锁外读的是一个普通字段普通字段的读不受锁保护另一个线程在锁内写入的实例引用锁外线程未必立刻可见更糟的是可见时对象可能没完全构造。这种情况只靠 synchronized 是不够的必须让第一次判断读到的字段有严格的可见性和有序性语义也就是 volatile。有一个很容易混淆的点为什么静态内部类方案不需要 volatileDCL 却需要因为静态内部类里实例引用是static final字段且赋值发生在内部类的clinit()中。JVM 对clinit()做了完整的加锁和发布处理final 字段本身又有 final 语义所以不需要额外加 volatile。DCL 是“先读后锁、锁外再读”的模式这个模式只有在 volatile 加持下才安全。之前有个同事跟我讨论说既然 synchronized 块内 new 的过程已经原子化了那外面那层 if 直接读普通字段是不是也行我让他做个实验把 volatile 去掉跑一个 16 线程并发循环创建单例的测试限制条件是把构造器里加一个短暂 sleep。结果没过多久测试里就出现了两个不同地址的实例。这类偶发问题在低并发、快速执行的情况下很难出现一旦压测上强度立刻现形。所以我的结论一直很坚定DCL 里的 volatile 是安全底线不是锦上添花的优化。4.3 反射与序列化两种模式共同的软肋聊完正常的创建路径还得聊聊两个“破坏者”反射和序列化。反射可以绕过访问检查。只要拿到 Class 对象setAccessible(true) 后调用私有构造器就能创建出一个新的实例这直接导致单例被打破ConstructorDCLSingleton c DCLSingleton.class.getDeclaredConstructor(); c.setAccessible(true); DCLSingleton another c.newInstance();饿汉式和懒汉式都逃不掉这个问题。想要更强的防御一种思路是在构造器里加一个 flag非法实例构造时抛异常另一种是做枚举单例。Java 对枚举的反射创建有硬性限制枚举单例天然免疫反射攻击。但枚举类本质上是急加载可以把它理解成“防御力最强版的饿汉式”。序列化也会产生第二个实例Java 对象反序列化时会通过反射创建新对象不走构造器。解决方案是给单例类实现 readResolve() 方法private Object readResolve() { return INSTANCE; }这样反序列化得到的对象会和已有实例指向同一引用。如果单例对象不涉及跨进程传输可以不操心这一条一旦涉及 Redis 缓存、消息队列、RPC 传输这步就必须补上。很多线上“配置突然变了而且改不回来”的诡异问题排查到最后就是反序列化生成了新的单例实例大家都以为自己改的是同一个对象其实各自手里拿的不是同一个。5. 实战选型我一般按什么标准决定用饿汉还是懒汉5.1 什么场景我会毫不犹豫选饿汉先说结论再说理由。我一般在下面三种情况选饿汉单例对象轻量构造不涉及 IO、不耗时。比如存放基础配置的 Map、工具方法的聚合类。应用一启动就必定会用这个类。既然早晚都要用还不如让它在类加载时完成初始化把构造逻辑的报错往前挪启动阶段就能发现问题。代码要交给新人维护。饿汉式只有 static final 一行几乎不可能被“优化”坏哪怕同事不理解类加载原理也不会误删什么东西。还有一种情况也值得选饿汉构造里依赖外部资源但初始化失败就希望应用启动直接失败。比如数据库连接池连接配错宁可启动报错也不要等运行时才炸。饿汉式把初始化前移后报错时间更早排查更舒服。毕竟启动时失败和凌晨三点线上运行中失败对开发者的心理冲击是完全不一样的。5.2 什么场景我会换成懒汉静态内部类为首选反过来下面三种情况我会用懒汉实例构建成本高而且有可能是“用不到的”。像之前提到的规则引擎加载几十个规则文件属于典型的重初始化。就算业务上大概率会用到延迟创建也能让启动阶段轻快不少。模块是插件化的加载了但不一定触发核心功能。用懒汉可以做到“模块加载 只是注册定义真正调用 才初始化实例”。对启动时间有硬性指标的在线应用。哪怕单例初始化只有一两百毫秒积少成多也会拖慢发布速度用懒汉把非关键单例移到首次调用时能把启动链路缩短一大截。如果确定要懒汉我会优先写静态内部类方案。只有在一个类已经有锁、老代码结构不好动的情况下我才会考虑 DCL并且会格外小心地保留 volatile。上面也说了DCL 的坑不是难懂而是容易被不懂的人“优化”掉一旦去掉 volatile线上就是偶发事故定时炸弹。5.3 一点个人经验和检查清单项目做多了以后我总结了一个很小的决策清单每次写单例都过一遍构造器有没有做到 private能反射绕过是另一回事但起码语法层面要拦住。如果不需要懒加载直接用饿汉式需要懒加载优先静态内部类。如果非要用 DCL永远不要删 volatile也永远不要只做一次 null 判断。类里有没有可能被反序列化有就补 readResolve()。实例字段声明成 final 了吗饿汉式和静态内部类里都能用 final能加就加多一层不可变约束。是不是真的需要单例很多场景用依赖注入容器托管的“单作用域对象”就够不一定非要手写单例模式。最后关于命名提醒一句虽然大家习惯说“懒汉模式”“饿汉模式”但在代码评审里最好写清楚“懒加载单例”“急加载单例”配上注释说明创建时机和线程安全来源。这样别人维护你的代码时能少猜很多。我个人用下来最顺手的组合是无状态、轻量级的工具类用饿汉式一行 static final 解决带有 IO、连接池、加载昂贵资源或者会被反射/序列化折腾的类用静态内部类懒汉式必要时补上 readResolve() 和枚举兜底。如果你以前只是背过两者的区别建议照着上面的代码自己跑一遍多线程测试看一下去掉 volatile 的 DCL 在高并发下的表现——亲手踩过一次这个知识点才算真正长在身上。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。