拓冰建站拓冰建站
首页 / 资讯中心 / 正文

单例模式全解析:饿汉与懒汉的线程安全工程实践

先讲个我早年踩过的坑。当时给一个内部工具写日志模块图省事在代码里直接 new 了一个 Logger 就往里写。跑了一会儿发现日志文件内容互相覆盖有的线程写的内容直接消失了。查了半天最后发现项目里有三个地方分别 new 了 Logger 实例每个实例持有独立的文件写入点大家各写各的文件指针互相踩踏。那次排查让我意识到这种全局只能有一份的资源靠自觉约束根本不现实。而设计模式里专门解决这个问题的方案就是今天要聊的主角——单例模式。单例模式应该是所有设计模式里最常被提起、也最容易被人低估的一个。看似简单——一个类只能创建一个实例提供一个全局访问点但真的把唯一性从代码层面锁死再往外延伸到线程安全、类加载、反射、序列化、依赖注入里面的水其实很深。这篇博文我会从饿汉和懒汉两条主线展开把 Java、C、Python 三种语言的典型实现完整过一遍再聊一聊我实际项目中踩过的坑以及单例模式在现代架构里包括多 Agent 系统的演变。不管你是准备面试、写期末作业还是想真正理解创建型模式的底层逻辑这篇都值得花十几分钟读完。1. 单例模式要解决的核心问题1.1 一个初学阶段的真实翻车现场先回到日志模块那个场景。一个程序里之所以会出现多个 Logger 实例往往不是有意设计的而是因为不同模块各写各的、互相不知道对方存在。每个 Logger 都打开同一个日志文件各自的缓冲区互相覆盖最终结果就是日志错乱、丢失、甚至文件被截断。这类问题最典型的特征是只在特定并发时序下才出现平时看起来一切正常上线一段时间后突然爆发。更常见的情况是资源浪费。比如数据库连接池、线程池、缓存客户端这些对象本质上就是软件层面的基础设施建一次就行。如果每次调用都 new 一个连接池会瞬间创建大量 TCP 连接数据库直接被压垮线程池创建大量空闲线程内存白白消耗。我见过一个项目里每个请求进来都新建一个 Redis 连接压测数据一上来Redis 端连接数直接飙到五位数服务端直接被判定为疑似攻击。这些问题的共同点是什么是系统里需要且只需要一个实例这个约束没有被代码强制约束。靠程序员的自觉一定会漏靠全局变量跨模块容易乱只有从创建入口上把唯一性锁死才是最可靠的方案。这就是单例模式存在的根本原因。1.2 单例模式的核心特征与适用边界根据最标准的定义单例模式需要满足三个条件第一类自身负责保存它的唯一实例第二构造函数对外不可见外部不能随意 new第三提供一个全局访问点让所有调用方拿到同一个对象。用大白话翻译一下这个类把自己的生产许可证收回来不允许外界直接开工只能通过它提供的统一窗口来领对象而且不管是谁来领领走的都是同一个对象。这和现实里的单点办公很像——全公司只有一个财务窗口不管你是哪个部门来报销去的都是同一个窗口不存在窗口 A 和窗口 B 各记各账的情况。但单例模式并不是万能的。它的设计目标只针对进程内唯一实例这个场景。如果你的系统是分布式部署十个进程都在跑同一个服务每个进程里各有一个唯一实例那其实一共有十个实例跨进程的唯一性单例模式管不了需要引入分布式锁或者外部存储。另外单例模式会把对象生命周期拉长到进程级别相当于把一个隐性的全局状态挂在了整个系统里如果这个对象本身是有状态的、会被并发修改那反而会引入新的线程安全问题。所以判断要不要用单例先问自己两个问题这个对象在进程内是不是真的只需要一份这份对象是不是无状态或者可以接受共享2. 饿汉式单例类加载即初始化的直接打法2.1 Java 饿汉式类加载机制天然兜底饿汉这个词很形象意思是这个类一出生就急着把实例创建好等于饿着肚子也要先把饭端上桌。在 Java 里饿汉式的标准写法大概是这样的public class EagerSingleton { // 类加载时完成实例化JVM 保证类加载过程是线程安全的 private static final EagerSingleton INSTANCE new EagerSingleton(); private EagerSingleton() { // 私有构造器禁止外部 new } public static EagerSingleton getInstance() { return INSTANCE; } }这段代码之所以线程安全关键不在写法本身而在 JVM 的类加载机制。一个类在首次被主动使用时会触发初始化而这个初始化过程由 JVM 保证同一时间只会有一个线程在执行。也就是说多个线程同时第一次调用getInstance()时JVM 内部的类加载锁已经帮你把唯一实例创建这个动作串行化了。等类加载完成INSTANCE已经是一个完整的对象后续所有线程拿到的都是它。所以饿汉式在 Java 里不需要加任何锁天生线程安全。这种写法在工程里非常常见很多开源框架的全局配置管理器用的就是类似思路。网上有些文章会说饿汉式浪费资源我不完全赞同。浪费与否要看对象的构造成本。如果只是一个轻量级对象类加载时顺手创建成本几乎可以忽略如果对象构造特别昂贵比如要初始化连接池、读取配置文件、建立网络连接那确实应该谨慎这时候懒汉式更合适。2.2 为什么 C 的饿汉式没有 Java 那么省心C 里并没有类加载这个概念所以饿汉式的实现思路要落到静态对象上。最直白的写法是这样的class EagerSingleton { public: static EagerSingleton getInstance() { return instance_; } EagerSingleton(const EagerSingleton) delete; EagerSingleton operator(const EagerSingleton) delete; private: EagerSingleton() {} static EagerSingleton instance_; }; EagerSingleton EagerSingleton::instance_;这段代码在单线程程序里没问题但它埋了一个坑静态对象instance_的定义在文件里如果另一个编译单元在它初始化之前就调用getInstance()程序就会访问到一个未完成构造的对象。这个问题在 C 里有个专门的名字叫 Static Initialization Order Fiasco静态初始化顺序惨案。两个不同文件里的静态对象谁先初始化取决于链接顺序不完全可控。C 社区后来普遍推荐的做法是把它改成函数内的静态局部变量也就是常说的 Meyers SingletonC11 之后静态局部变量初始化时编译器会自动保证线程安全class Singleton { public: static Singleton getInstance() { static Singleton instance; return instance; } Singleton(const Singleton) delete; Singleton operator(const Singleton) delete; private: Singleton() {} };注意这个其实属于懒汉式只不过语言层面把线程安全做了兜底。我在实际写 C 代码时基本只认这一种写法。原因很简单它既避免了全局静态对象的初始化顺序问题又不用手动加锁代码量最少语义最清晰。如果非要在 C 里写严格意义上的饿汉建议还是老老实实处理初始化顺序或者用std::once_flag包一层否则真的容易在大型项目里翻车。2.3 Python 的模块级单例换个角度看饿汉Python 的情况比较特别。Python 里没有 private 构造器__new__和__init__都可以被外部直接调用所以在 Python 里写单例思路得换一换。最简单、最 Pythonic 的做法其实是用模块级变量模块在首次 import 时执行一次之后再次 import 直接走缓存所以模块天然就是一个单例容器。# database.py class DatabaseConnection: def __init__(self): # 初始化连接 pass # 模块导入时创建唯一实例 db_connection DatabaseConnection()用的时候这样写from database import db_connection这个方案的本质和饿汉式非常接近模块被导入时就把实例创建好了之后的引用全都指向同一个对象。好处是没有任何线程安全问题——Python 的 import 机制保证了同一模块在同一进程内只会执行一次。坏处和 Java 饿汉式一样如果你的对象构造成本高而代码路径里某个模块可能根本不会用到它那导入时白白创建就是一种浪费。不过需要提醒一点from database import db_connection这种写法是安全的因为它拿的是模块里那个对象的引用但如果你在别的模块里写from database import db_connection as conn之后再conn xxx那只是在当前命名空间里重新绑定名字不会影响其他引用的对象也不会创建新实例。如果心里不清爽直接import database然后database.db_connection最稳。2.4 饿汉式 vs 懒汉式先给一张对比表在往下聊懒汉式之前我先把两种方案的核心差异摆出来方便后面按场景做选择对比项饿汉式懒汉式实例创建时机类加载/模块导入时首次调用 getInstance 时线程安全实现依赖语言初始化机制天然安全需要手动加锁或用特殊技巧响应速度首次调用快但启动阶段慢首次调用有创建开销资源占用即使不用也占用用不到就不占代码复杂度低相对高容易写出 Bug适用场景轻量对象、启动时可接受重资源、按需创建这张表下面每一行都会展开讲。3. 懒汉式单例延迟加载与线程安全的长期博弈3.1 最朴素的懒汉式与它的线程安全 Bug懒汉式的核心理念是按需创建——第一次有人真的来取对象时才着手创建。最朴素的 Java 实现长这样public class LazySingleton { private static LazySingleton instance; private LazySingleton() {} public static LazySingleton getInstance() { if (instance null) { instance new LazySingleton(); } return instance; } }这段代码在单线程环境里完全没问题但一旦进入多线程就是一个经典的 Bug。两个线程同时执行getInstance()都判断instance null成立然后先后执行new LazySingleton()结果系统里存在两个实例单例的唯一性直接被破坏。更麻烦的是这个 Bug 不是每次都会触发它高度依赖线程调度时序可能跑了几百次才出现一次排查成本非常高。很多初学者会问Java 里instance null不是一步操作吗问题出在判断和赋值之间不是原子的。CPU 时间片可能在判断之后、赋值之前被切换走另一个线程趁虚而入。多线程安全不是看单条语句而是看整个检查-赋值序列是否需要同步保护。3.2 从加锁到双重检查锁DCL的演进最直接的修复方案是给getInstance()加synchronizedpublic static synchronized LazySingleton getInstance() { if (instance null) { instance new LazySingleton(); } return instance; }够安全了但性能崩了。synchronized是方法级的意味着无论实例创建没创建每次调用都要经过同步入口。在实例早已创建好的高并发场景下大量线程为了拿一个已经存在的对象在锁上排队白白消耗 CPU。实测下来高并发下的同步方法实现吞吐量可能只有优化版本的几分之一。于是有了双重检查锁Double-Checked LockingDCLpublic class DclSingleton { 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; } }思路是先把大概率命中的情况用无锁的 volatile 读挡在门外只有第一次确实没创建时才进入同步块进入同步块后再做一次判空防止等待锁的线程重复创建。这个写法在 Java 5 之后是安全的但有一个关键前提instance必须声明为volatile。3.3 DCL 中 volatile 到底在防什么指令重排的坑如果不加volatileDCL 看起来逻辑上没有任何漏洞但它实际上有一个隐藏的雷指令重排。创建一个对象在 JVM 层面大致要做三件事分配内存、调用构造器初始化对象、把引用赋值给变量。理论上这三步的顺序是先 i 后 n 再 a但 JVM 的即时编译器为了优化性能允许在不改变单线程语义的前提下重排指令。也就是说可能出现这样的顺序分配内存、把引用赋值给变量、最后执行构造器初始化。假设线程 A 先执行了内存分配和引用赋值还没执行构造器初始化此时线程 B 进入getInstance()第一次检查发现instance ! null直接返回了这个半成品对象。线程 B 拿着一个还没有被构造的对象去做业务操作轻则拿到默认值重则直接触发空指针异常。加volatile之后JVM 会禁止对这个变量的读写操作重排保证引用赋值一定发生在构造器初始化之后从根上消除了这个隐患。这个坑我在早年写 Java 时真的踩过。当时觉得 DCL 不就是两次判空加锁吗抱着侥幸心理没加 volatile线上偶发拿到不完整对象的诡异异常排查了两天才定位到指令重排。从那以后我写 DCL 的第一反应就是先加 volatile形成肌肉记忆。3.4 C11 Magic Static 与 Java 静态内部类借力语言特性绕了一圈手动加锁你会发现 Java 和 C 其实都提供了更优雅的语言级解法。C 11 标准明确规定函数内静态局部变量的初始化是线程安全的编译器会插入必要的同步逻辑且初始化只执行一次。这就是我之前提到的 Meyers Singleton 的正确打开方式class Singleton { public: static Singleton getInstance() { static Singleton instance; return instance; } Singleton(const Singleton) delete; Singleton operator(const Singleton) delete; private: Singleton() {} };这段代码简洁到几乎不需要解释。它是懒加载的首次调用才创建又是线程安全的由编译器保证而且没有 DCL 那套繁琐的 volatile 逻辑。如果项目使用 C11 或更高标准没有理由不用这种写法。Java 这边对应的优雅方案是静态内部类也叫 Initialization-on-demand Holder Idiompublic class HolderSingleton { private HolderSingleton() {} private static class Holder { private static final HolderSingleton INSTANCE new HolderSingleton(); } public static HolderSingleton getInstance() { return Holder.INSTANCE; } }原理并不复杂JVM 只有在主动使用Holder类时才会触发它的初始化而getInstance()第一次被调用时才会访问Holder.INSTANCE从而触发Holder的类加载。类加载由 JVM 保证线程安全所以天然满足懒加载加线程安全。相比 DCL这种写法不需要 volatile代码也更干净。很多资深 Java 开发者会把它列为懒汉式单例的首选方案。3.5 Python 懒汉式的线程安全实现元类加锁回到 Python。如果不想用模块级单例的饿汉式也可以实现真正的懒加载单例比较常见的套路是重写__new__并加锁import threading class LazySingleton: _instance None _lock threading.Lock() def __new__(cls): if cls._instance is None: with cls._lock: if cls._instance is None: cls._instance super().__new__(cls) return cls._instance这里用到了 Python 里非常类似 DCL 的双重检查逻辑。先不加锁判空命中不了再拿锁进入锁内再次判空最后调用super().__new__(cls)真正创建对象。注意 Python 的super().__new__(cls)是不带参数的因为__init__后面还会被 Python 自动调用所以实例化逻辑要写到__init__里而不是写到__new__里。稍微提醒一句别以为 Python 有 GIL 就可以忽略线程安全问题。GIL 只保证单个字节码指令的原子性不保证if cls._instance is None和cls._instance super().__new__(cls)之间不会发生线程切换。实测中线程调度完全可能在判断后、赋值前让出 GIL另一个线程就会拿同一个对象去重复创建。所以该加锁时还是要加锁GIL 不是万能挡箭牌。还有一种更进阶的写法是用元类把单例逻辑集中到元类里让多个类复用class SingletonMeta(type): _instances {} _lock threading.Lock() def __call__(cls, *args, **kwargs): if cls not in cls._instances: with cls._lock: if cls not in cls._instances: cls._instances[cls] super().__call__(*args, **kwargs) return cls._instances[cls] class MyService(metaclassSingletonMeta): def __init__(self): pass元类方案的优点是通用一个元类管所有类缺点是增加了理解成本团队里如果都是初级开发者可能会看不懂。我一般建议项目里只有一个类需要单例直接用__new__加锁如果有多个类都有同样需求再考虑抽成元类。4. 单例的破防点、边界问题与现代架构中的取舍4.1 反射与序列化两个防不胜防的破坏手段很多人以为把构造器写成 private 就万事大吉了。实际上Java 里两大机制可以轻松击穿这道防线。第一个是反射// 反射拿到私有构造器 ConstructorEagerSingleton constructor EagerSingleton.class.getDeclaredConstructor(); constructor.setAccessible(true); // 强行创建新实例 EagerSingleton anotherInstance constructor.newInstance();构造器瞬间形同虚设。另一个破坏手段是序列化。如果一个单例类实现了Serializable那么通过反序列化获取对象时语言底层会绕过构造器、直接创建一个新实例导致系统里出现了第二份单例对象。防御方式也简单。反射这块比较彻底的做法是用枚举单例public enum EnumSingleton { INSTANCE; public void doSomething() {} }枚举类型在 JVM 层面的实例化机制天然避免了反射攻击和序列化破坏而且写法非常简洁。不过枚举在实际使用时有限制——它不是普通类无法继承别的类还会显式带着枚举的语义。如果是业务类继承需求往往就把它排除了。对于序列化更通用的防御手段是在类里增加readResolve方法protected Object readResolve() { return INSTANCE; }反序列化过程中如果类存在readResolve系统会调用它并把返回值作为最终的反序列化结果从而替换掉那个通过特殊机制创建出来的临时实例。注意readResolve的返回值必须和原实例一致否则一样会破功。此外如果单例类持有 Io 资源或者连接对象务必把这些字段标记为 transient避免反序列化时字段被重新赋值导致状态丢失。4.2 类加载器与进程边界所谓全局唯一并不全局单例模式的全局唯一有一个边界很多人在单体应用里没有感知一到 Web 容器或者分布式环境就掉坑。先讲类加载器。JVM 中的唯一性是以类加载器 类名为维度的。同一个 class 文件被两个不同的类加载器加载在 JVM 看来是两个不同的类。正常 Java 程序里应用只有一个系统类加载器所以单例唯一。但在 Tomcat 这类 Web 容器里每个 Web 应用默认使用独立的 WebAppClassLoader跨应用之间类是不相通的。假如你写了一个全局缓存单例放在共享库里两个 Web 应用各自加载了一份即使它们跑在同一个 JVM 里数据也不会互通每份实例各自为政。再讲进程边界。哪怕只有一个类加载器只要程序跑在多个 JVM 进程里比如多实例部署、微服务再比如 Python 的多进程 worker每个进程内都会有一份单例。单例模式的语义只在进程内成立跨进程的唯一性要靠外部队列、数据库锁、分布式协调服务来保证。理解了边界之后设计上要主动区分全局状态放在哪一层。进程内的共享状态用单例没问题需要跨进程的状态单例只是一个本地持有者真正的一致性仍然依赖外部基础设施。我自己做项目时有一条朴素的判断标准如果我要写一个全局缓存单例先问自己这个缓存的数据是当前进程私有的还是所有进程必须共享如果是后者单例只负责减少重复连接的开销数据一致性的锅还是得甩给 Redis 这种中间件。4.3 从单例到多 Agent 主从模式集中控制的思路延伸聊到现代架构一个有意思的现象是单例模式的统一控制点思想正在以新的形式出现在多 Agent 系统里。现在主流的 Agent 框架里主从模式是一个很常见的架构一个主 Agent 负责调度全局任务多个 SubAgent 负责执行子任务。按照最新的工程实践这些 SubAgent 实际上被看作另类的 Tool 进行调用——主 Agent 面对它们时并不把它当成一个平等的大脑而是当作一个可调用的工具单元输入输出都走统一的接口约定。这种设计的本质是要保证整个系统里只有一个真正在做决策的控制器避免多个 Agent 各说各话、状态错乱。这和单例模式的核心思想完全是一脉相承的入口要有且只有一个状态要集中管理。在多 Agent 系统里主 Agent 通常需要作为全局唯一的调度入口存在它的上下文管理器、任务队列、工具注册表往往就是按单例思想设计的。如果你在写这类系统别把 Agent 里的全局配置写成到处 new 的临时对象用一个全局唯一的 Coordinator 持有核心状态能省下很多不可控的并发问题。当然这里要强调多 Agent 的主从模式并不是单例模式的简单套用它比单例复杂得多——涉及消息传递、任务路由、失败恢复等。但如果你想快速理解它为什么这么设计单例模式的集中控制思想在多智能体架构中的延伸这个视角是足够贴切的。4.4 依赖注入容器现代框架里还需要手写单例吗最后聊一个很现实的工程问题。Spring 默认将 Bean 的生命周期设为 singleton也就是说Spring 容器本身就帮你做了一个类在容器内只有一个实例这件事。这种情况下还需要手动写单例吗我的建议是在 Spring 管理的范围里完全不需要也不要写。Spring 的 singleton 作用域已经保证了每个 Bean 在容器内只有一个实例你再手写一个 private 构造器反而会妨碍依赖注入和单元测试。正确做法是让 Spring 容器管理 Bean需要的时候在方法参数或属性上注入测试时也能轻松替换成 mock 对象。但要注意两个边界。第一Spring 的单例是一个容器内一个实例不是JVM 内一个实例。多个 Spring 容器比如 Parent Context 和 Child Context会各自持有实例。第二脱离 Spring 管理的代码比如工具类、纯 Java 组件如果你还需要保证唯一性那单例模式依然有效。我在项目里的习惯是框架层的东西交给容器管核心工具类或第三方连接管理类按需自己实现单例。别混着来更别在 Spring Bean 里手写一套静态单例否则测试时全局状态没法重置坑到队友就不好了。5. 选了单例之后还得注意的几个实操要点写单例这么多年除了上面讲到的线程安全、反射、序列化这些硬核问题还有几个偏经验的细节值得放在一起说。第一个是注意全局状态的可测试性。单例对象一旦创建生命周期几乎和进程一样长如果在里面塞了可变状态测试 A 改了值测试 B 拿到的就是脏数据。我见过不少项目因为单例里放了一个当前用户上下文导致单测互相污染。解决办法是能设计成无状态的尽量无状态必须持有状态时对外提供一个 reset 方法专门给测试环境用。第二个是该选饿汉还是懒汉不要僵硬地背结论。我一般这么定Java 和 C 里轻量对象直接饿汉类加载或者编译期创建简单省心重量级对象用懒汉并且优先使用 Java 静态内部类或 C 静态局部变量Python 里优先模块级单例遇到需要延迟加载再上__new__加锁或元类。很多网上的文章把饿汉批得一文不值我实际用下来在大多数后端服务里启动时多创建一两个轻量对象根本不构成性能瓶颈。为了省那点启动时间把代码搞成复杂的 DCL反而可能引入 volatile 漏写之类的 Bug得不偿失。第三个是别滥用单例。单例是多点共享的集中控制方案但它本质上也是一个全局状态。用滥了代码就会变成一团乱麻每个模块都靠一个全局对象传数据依赖关系完全隐性化。数据流本来就是从 A 流到 B 的结果你为了省参数传递往单例里塞了个中间变量后续维护的人看着想骂人。判断原则很清楚如果这个对象需要被多个无关联的模块共享且共享的是统一入口或去重后的资源用单例如果你只是不想写参数传递停手那是设计味道不对。6. 我的一点个人体会回头看我自己的成长路径单例模式是第一次让我感受到设计模式不是背出来的的那个模式。大学里学的时候考试能默写 Java 代码但完全没理解为什么不加 volatile 会出事、为什么枚举能免疫反射。直到自己写并发程序、维护线上系统、在多个 Agent 组成的系统里反复调状态协调之后才真正体会到所谓设计模式其实是前人把复杂的并发、生命周期、边界问题浓缩成了一个能落地的骨架。最后分享一个小经验面试或期末作业里如果遇到单例模式的题不要只会背代码。能把下面的问题讲清楚才算是真的懂饿汉和懒汉的差异是什么为什么懒汉式要考虑线程安全DCL 里 volatile 在防什么序列化和反射怎么破坏单例在依赖注入容器里还需要手写单例吗把这些问题一个个想明白远比流利地默写五种写法有价值。希望这篇从饿汉到懒汉的全方位解析能帮你在代码里少踩几个坑面试时多几分底气。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门