恒美微站 Logo 恒美微站
  • 首页
  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心
  • 联系我们

单例模式饿汉式与懒汉式:线程安全、双重检查锁到实战避坑全解析

  • 首页
  • 资讯中心
  • /
  • 单例模式饿汉式与懒汉式:线程安全、双重检查锁到实战避坑全解析

相关资讯

红黑树与哈希表深度对比:从C++ map/unordered_map底层原理到工程实践 2026/10/10 22:36:32
从“聊完就忘“到动态画像:Supermemory如何处理信息冲突与过期记忆 2026/10/10 22:31:32
Django URLconf路由机制详解:匹配、命名空间与反向解析 2026/10/10 22:31:32

最新资讯

3DGS 场景第二次打开出现破洞:HarmonyOS 7 分块缓存校验与原子替换怎么做
从设备智能到空间理解,慢云科技的空间AI产品如何重新定义智慧人居
2026家用指纹锁品牌推荐:德施曼爆款产品深度解析
在 WebAssembly 中编译运行 GGML 算子:Wasm SIMD128 向量化指令实操指南
K8S-Kubeadm使用配置文件初始化集群实操
this.

今日推荐

UE动画修改实战:从资产编辑到重定向与蒙太奇驱动
统计随机数生成器攻击下的KLJN安全密钥交换协议Matlab仿真
政务API安全治理:资产测绘、低代码编排与行标对标实践

本周热门

UE动画修改实战:从资产编辑到重定向与蒙太奇驱动
统计随机数生成器攻击下的KLJN安全密钥交换协议Matlab仿真
政务API安全治理:资产测绘、低代码编排与行标对标实践

本月精选

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)

单例模式饿汉式与懒汉式:线程安全、双重检查锁到实战避坑全解析

发布时间:2026/10/10 22:36:32
单例模式饿汉式与懒汉式:线程安全、双重检查锁到实战避坑全解析 设计模式里的单例几乎是每个开发者入门设计模式的第一课也是面试中出镜率最高的考点。但很多人在实际写代码时对饿汉式和懒汉式的选择仍然停留在“背答案”的阶段只会说“饿汉式线程安全、懒汉式效率高”至于为什么不安全、为什么双重检查锁要加volatile、什么时候用什么实现讲不清楚。这篇文章就把单例模式的这两种经典实现彻底掰开揉碎从原理到代码、从坑点到实战选择一次说透。无论你是刚学设计模式的新手还是准备面试的求职者或者在工作中想用单例却被各种问题困扰的开发者这篇都值得你仔细读一遍。1. 单例模式核心思想与适用场景1.1 单例模式到底解决什么问题单例模式的核心思想非常朴素保证一个类在整个系统运行期间只有一个实例并提供一个全局访问点。这里有两层含义第一是“唯一性”第二是“可控性”。为什么要保证唯一因为有些资源在系统里不应该存在多份。比如一个数据库连接池如果每个模块各建各的数据库链接数很快就会被耗尽比如一个全局配置管理器如果加载了多份配置某个模块改了参数其他模块不知道再比如日志记录器多个实例会导致日志文件被多个对象同时写入逻辑混乱。这些场景本质上是共享的、有状态的资源多实例不仅浪费还会引发数据不一致。而“全局访问点”则是为了让所有调用方都能拿到同一个实例。你不需要把这个实例作为参数层层传递只需要调用Singleton.getInstance()就能在任何地方获取。这非常符合“全局唯一”的语义。但我要多说一句单例模式虽然看起来简单却是被滥用最多的设计模式之一。很多初学者喜欢把所有“工具类”都做成单例其实没有必要。如果一个类没有内部状态、只是方法的集合用静态方法就够了不需要单例。单例模式的真正价值在于“这个实例必须唯一并且需要维护状态”。1.2 什么场景适合用单例根据我的经验适合单例的场景大致有这几类全局资源管理线程池、数据库连接池、缓存管理器、消息队列客户端。这些资源创建和销毁成本高且需要全局共享一份。配置中心应用启动时加载配置文件之后所有模块读同一份配置不允许出现各读各的情况。日志/监控日志写入需要串行化监控上报需要统一入口。多个实例会导致输出混乱或重复上报。访问共享硬件或服务打印机管理、硬件接口调用、网络连接等都需要单例来控制并发访问。有状态的服务对象比如一个计数器、一个会话管理器状态需要全局保持一致。还有一个常见的误区是把单例当作“全局变量”的替代品。虽然有相似之处但单例是“可控的全局变量”因为你可以控制它的初始化时机、线程安全、生命周期。不过也正是因为它的“全局性”在大型项目里如果不加约束容易引入隐藏耦合——模块之间通过单例隐式通信代码会变得难以测试和维护。这一点后面我会详细说。2. 饿汉式实现与原理2.1 饿汉式上来就实例化简单粗暴饿汉式英文叫Eager Initialization意思是“急性子”类加载的瞬间就把实例创建好不管你现在用不用。实现方式是在类的静态成员变量中直接初始化实例。Java实现public class Singleton { // 静态成员变量类加载时立即初始化 private static final Singleton instance new Singleton(); // 私有构造方法禁止外部new private Singleton() {} // 静态方法返回唯一实例 public static Singleton getInstance() { return instance; } }C实现C里同样可以这样写class Singleton { private: static Singleton instance; // 静态成员变量声明 Singleton() {} // 私有构造 Singleton(const Singleton) delete; // 禁止拷贝 Singleton operator(const Singleton) delete; // 禁止赋值 public: static Singleton getInstance() { return instance; } }; // 在源文件中定义并初始化C11之前 // Singleton Singleton::instance;上面的写法在C11之前需要把静态成员的定义放在类外比较麻烦。C11之后更推荐使用局部静态变量的方式这个后面讲懒汉式时再提。核心解析饿汉式之所以线程安全是因为实例化发生在类加载阶段。Java的类加载机制保证了clinit()方法静态初始化块在多线程环境下只会被一个线程执行JVM会隐式地加锁同步其他线程必须等待。所以天然杜绝了并发创建多个实例的问题。C静态成员变量的初始化在C11之前确实存在线程安全问题因为标准没有明确规定初始化顺序和线程安全。但C11之后静态局部变量的初始化被标准保证为线程安全的——如果多个线程同时首次调用只有一个线程会执行初始化其他线程会阻塞等待。这也是为什么现代C推荐用局部静态变量实现单例。2.2 饿汉式的优缺点与注意事项优点实现简单没有线程同步开销因为没有并发风险。获取实例速度最快调用getInstance()时实例必然已存在。缺点不管用不用类加载时就会创建实例。如果单例对象创建成本很高比如初始化数据库连接池、加载大配置文件而系统启动时根本用不到它就会白浪费资源、拖慢启动速度。如果实例依赖其他配置或环境变量而这些东西在类加载时还没准备好就会导致初始化失败。注意点构造方法一定要私有否则外部可以直接new单例就失去了意义。如果类可以被继承那么要防止子类构造方法绕过私有限制通常直接声明finalJava或禁止继承。Java中如果单例类实现了Serializable接口反序列化时会破坏单例唯一性需要重写readResolve()方法。后面专门讲。我用生活化类比说明饿汉式就像你早上一睁眼就把厨房的饭全部做好哪怕你中午才饿。好处是你随时想吃都能吃到坏处是你早上可能不想吃还得花力气维护一桌菜。如果这桌菜做起来特别贵就更不划算了。所以饿汉式适合那种“类加载成本低、即使不用也无所谓”的对象比如一个简单的工具类单例。如果你的单例对象初始化需要依赖网络、数据库或者耗时较长饿汉式就要谨慎使用。3. 懒汉式实现与线程安全3.1 基础懒汉式用到才创建但有并发问题懒汉式Lazy Initialization对应的是“懒汉”不主动干活等真正有人找我要实例时才创建。它的初衷是解决饿汉式“过早初始化”的问题。最基本的懒汉式非线程安全public class Singleton { private static Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance null) { instance new Singleton(); // 延迟创建 } return instance; } }这段代码逻辑很简单第一次调用getInstance()时发现instance为null就创建之后不为null直接返回。看起来完美解决了“不用不创建”的问题。但它存在严重的线程安全问题如果两个线程同时第一次调用getInstance()线程A执行完if (instance null)判断还没来得及执行new Singleton()线程B也进入了这个判断两个线程都走到instance new Singleton()最终创建了两个不同的实例。这违反了单例的唯一性。你可能会想“概率这么低应该没事吧”在单线程测试中永远测不出来但一旦到了高并发环境这种可能性就变成必然。而且即使某些线程偶发拿到同一个实例也足够让系统数据混乱。3.2 同步方法简单但性能差既然有并发问题最简单的修复方式就是给getInstance()方法加上synchronizedpublic class Singleton { private static Singleton instance; private Singleton() {} public static synchronized Singleton getInstance() { if (instance null) { instance new Singleton(); } return instance; } }线程安全了但性能成了问题。每次调用getInstance()都要获取锁即使实例已经创建好了后面所有线程还是得排队等锁。在Java中如果这个单例被频繁调用同步开销会非常明显。而在C中std::mutex加锁解锁的成本也不低。这种写法在早期教科书里常见但实际生产环境基本不会用。因为它牺牲了大部分性能来换取极少数情况下才需要的安全性——拿大炮打蚊子代价太高。3.3 双重检查锁Double-Checked Locking双重检查锁的核心思路在进入同步代码块前先检查实例是否为空如果不为空就直接返回避免获取锁如果为空则进入同步代码块在锁内再次检查。public class Singleton { // volatile 关键字至关重要后面详细解释 private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance null) { // 第一次检查避免不必要的加锁 synchronized (Singleton.class) { // 加锁 if (instance null) { // 第二次检查防止并发创建多个实例 instance new Singleton(); } } } return instance; } }第一次if判断在没有并发时直接跳过锁性能接近饿汉式第二次if判断保证了只有第一个进入同步块的线程能创建实例。因为第二次检查在锁内所以不会出现多个线程同时创建的情况。为什么必须加 volatile这是面试的重灾区。如果不加volatile双重检查锁在并发下仍然可能出问题。原因在于instance new Singleton()并不是一个原子操作它分为三个步骤分配内存空间初始化对象调用构造方法将对象的引用赋值给instance如果JVM和CPU为了优化而进行了指令重排序可能会变成“先赋值引用、后初始化对象”。此时另一个线程来了在第一次检查时发现instance不为null于是直接返回这个引用但对象可能还没初始化完成该线程拿着半个对象去用结果就是异常。volatile关键字在Java 5之后加入了一个内存屏障机制禁止指令重排序并且保证多线程之间的可见性当一个线程写volatile变量时会把修改立即刷新到主内存当一个线程读volatile变量时会从主内存中读取最新值。这样就能确保返回给其他线程的一定是构造完成的对象。C中的双重检查锁C实现双重检查锁需要借助原子变量和互斥锁#include atomic #include mutex class Singleton { private: static std::atomicSingleton* instance{nullptr}; static std::mutex mtx; public: static Singleton* getInstance() { Singleton* tmp instance.load(std::memory_order_acquire); if (tmp nullptr) { std::lock_guardstd::mutex lock(mtx); tmp instance.load(std::memory_order_relaxed); if (tmp nullptr) { tmp new Singleton(); instance.store(tmp, std::memory_order_release); } } return tmp; } };C的双重检查锁需要同时处理内存顺序memory order和互斥锁写起来比Java复杂而且容易出错。所以在现代C里我更推荐直接用局部静态变量来实现class Singleton { public: static Singleton getInstance() { static Singleton instance; // C11起局部静态变量初始化线程安全 return instance; } };相信很多C程序员第一次看到这种写法会怀疑它是否线程安全。是的C11标准明确保证了函数内局部静态变量的初始化是线程安全的——如果多个控制流同时进入声明其中任意一个线程初始化该变量时其他线程都会阻塞等待初始化完成。这种写法既懒加载函数第一次调用时才初始化又线程安全代码还最简洁确实是懒汉式的进阶绝佳方案。3.4 懒汉式对应到“24种设计模式”中的归类这里顺便澄清一个概念通常我们说“23种设计模式”而标题写的是“24种”这可能是把某些变体或者Java特有实现也算进去了。实际上不必纠结具体数目重要的是理解单例模式在分类中的位置。单例模式属于创建型模式关注的是“对象创建的控制”。它不像工厂模式、抽象工厂模式那样产生多种对象而是限制只能产生一个对象。懒汉式的特点总结成一句话为了延迟初始化把简单问题复杂化了。这句话没有贬义而是提醒你在实现懒汉式的过程中你不得不处理线程安全、指令重排、序列化等一系列问题。如果你的单例初始化成本不高用饿汉式可能更省心。如果你的单例初始化确实很重用静态内部类或C局部静态变量才是更优雅的方案。3.5 更优雅的Java实现静态内部类与枚举静态内部类Initialization-on-demand holder静态内部类利用JVM的类加载机制实现既懒加载又线程安全public class Singleton { private Singleton() {} // 静态内部类不调用getInstance时不会加载 private static class InstanceHolder { private static final Singleton INSTANCE new Singleton(); } public static Singleton getInstance() { return InstanceHolder.INSTANCE; } }原理外部类加载时静态内部类InstanceHolder不会被加载只有当调用getInstance()时JVM才加载InstanceHolder随后初始化INSTANCE。由于类加载过程是线程安全的所以整个单例的创建过程也自然线程安全。这本质上是“懒加载的饿汉式”既避免了饿汉式的提前初始化浪费又避免了双重检查锁的复杂性和volatile依赖。枚举实现《Effective Java》作者Joshua Bloch强烈推荐用枚举实现单例public enum Singleton { INSTANCE; public void doSomething() { // 业务方法 } }枚举单例的优势非常明显天然线程安全、天然防止反射和序列化攻击。因为枚举类型在JVM中只会实例化一次反射也不能通过构造器创建枚举实例序列化时枚举也由JVM特殊处理不会产生多个实例。缺点是代码看起来不太像传统单例而且如果你需要继承其他类枚举会受限。但在绝大多数单例场景下枚举都是最佳选择。4. 常见问题与排查技巧实录4.1 实例化之后多例问题防不胜防很多人在写单例时只摆了private构造方法和getInstance()以为万无一失。实际上单例唯一性有三座大山需要搬掉反射、序列化、克隆。反射破坏Class? clazz Class.forName(Singleton); Constructor? constructor clazz.getDeclaredConstructor(); constructor.setAccessible(true); // 绕过private限制 Singleton newInstance (Singleton) constructor.newInstance();这样就强制创建了第二个实例。防御手段在私有构造方法里做一个判断如果实例已创建过就抛出异常。比如private Singleton() { if (instance ! null) { throw new IllegalStateException(单例已被创建不允许反射实例化); } }但这种防御对于懒汉式和饿汉式需要结合实现细节来调整因为实例可能在构造方法执行后才赋值给static变量需要设计好标志位。枚举不存在这个问题因为JVM禁止反射实例化枚举。序列化破坏如果单例实现了Serializable调用readObject()时会通过反序列化创建一个新的实例而不会走构造方法。防御方法是重写readResolve()private Object readResolve() { return instance; // 直接返回已有实例 }同样枚举天然免疫这个问题。克隆破坏如果单例实现了Cloneableclone()方法也会创建一个新实例。防御办法是重写clone()并抛出异常或者直接返回this。4.2 双重检查锁在安卓、高并发环境下的特殊坑我在Android开发中见过不少单例bug主要是fragment或Activity中持有单例引用导致的内存泄漏。其实单例本身没有问题问题是单例的生命周期与应用不一致导致它持有了Activity的引用使Activity无法被回收。举个例子你写了一个DataManager单例把Activity的Context传入进去public class DataManager { private static DataManager instance; private Context context; private DataManager(Context context) { this.context context; } public static DataManager getInstance(Context context) { if (instance null) { instance new DataManager(context); } return instance; } }如果这个context是Activity的那么即使Activity被finish了DataManager单例仍然持有它Activity就无法被回收造成内存泄漏。正确做法是使用ApplicationContext或者提供init(Context)方法在应用启动时传入ApplicationContext然后再通过不带参的getInstance()获取。另外在高并发环境下即使使用了双重检查锁如果你没有正确使用volatileJava或原子指令C就可能在多核CPU上出现可见性问题。表现为理论上实例已经创建完了但其他线程看到的还是null于是又创建了一次。这个bug很难复现因为依赖于CPU调度和内存模型。4.3 单元测试怎么处理单例单例在单元测试中是个比较头疼的问题因为它隐藏了依赖且状态全局共享。如果你在测试时发现一个测试方法修改了单例里的数据另一个测试方法读到脏数据那就要小心了。常用的处理方式为单例提供reset()或destroy()方法专门用于测试环境下重新初始化。使用依赖注入替代直接调用getInstance()这样测试时可以传入mock对象。将单例作为工厂方法内部的唯一实现对外暴露接口测试时替换实现。总之单例模式虽然有“全局访问”的便利但也要为可测试性付出代价。这属于设计上“两难”没有银弹只能在工程上做好平衡。5. 实际项目中的选型心得与避坑指南5.1 到底选饿汉式还是懒汉式我给出我在工作中的实践建议按场景分场景推荐实现原因系统启动时必须加载、且初始化简单饿汉式代码简单、无并发开销、竞态少启动时不必加载、但初始化成本高懒汉式Java用静态内部类/枚举C用局部静态变量延迟到首次使用时初始化避免启动浪费高并发频繁访问、需要极致的性能枚举Java或局部静态变量C既安全又无需显式加锁需要兼容老代码、必须显式加锁的旧项目双重检查锁 volatile可改进现有代码但注意线程安全细节这里强调一个原则不要为了“设计模式”而设计模式。如果单例只会在单线程环境中使用比如一些脚本工具直接使用最简单的懒汉式不加锁都没问题。但绝大多数生产环境是多线程的所以要么选择天然安全的实现要么必须显式处理线程安全。我自己的项目习惯是Java里99%的单例都用枚举或静态内部类除非需要继承才考虑其他方案。C里一律用局部静态变量。这两种方式几乎不需要思考线程安全代码也短不容易出错。5.2 我在实际项目中踩过的坑第一个坑用懒汉式单例管理数据库连接池结果并发一大就疯狂建连接。当时代码用的是“同步方法”那个版本看似线程安全。但问题在于获取数据库连接的操作本身很慢所有线程都堵在getInstance()的锁上导致系统吞吐量极低。排查时发现单例方法变成了全局串行点。后来我把数据库连接池改成用饿汉式连接池在应用启动时就创建同时把连接池内部的获取连接操作交给连接池自己管理同步单例处不再加锁性能瞬间提上来。这事告诉我单例的锁只应该保护实例创建不应该保护业务逻辑。第二个坑单例里保存用户登录信息导致用户串数据。有一个项目把当前登录用户信息放在单例的成员变量里本意是“全局都能访问”。结果多个用户同时在线时由于单例是唯一的A用户的信息被B用户覆盖出现了严重的串号事故。这个案例非常典型直接说明了一个真理单例适合放共享的资源不适合放与请求/会话相关的数据。用户信息应该放在ThreadLocal里或者作为方法参数传递绝对不能用单例保存。第三个坑自研框架中单例与Spring容器冲突。在Java后端领域Spring本身就对Bean默认使用单例范围singleton很多人还在Spring管理的Bean内部额外写单例模式造成双重单例。这不会立即报错但会导致状态管理的混乱。比如你有一个UserCache类Spring容器里实例化了A这个单例你又通过UserCache.getInstance()拿到了B这个单例两个缓存对象数据不一致。正确做法是在Spring环境中交给Spring管理Bean的生命周期不要再自己写单例非Spring环境的模块比如工具包、基础组件可以保留单例模式。5.3 单例模式的未来演进思考其实设计模式不是一成不变的铁律。随着语言特性发展很多模式的实现方式在不断演进。比如Java的枚举、C11的局部静态变量都在降低单例模式的实现难度。而现代软件架构中单例也被容器Spring、依赖注入、服务定位等机制部分替代。但底层思想是一样的控制对象的唯一性和访问入口。我在实际使用中发现写出一个靠谱的单例仅仅是个开始。更重要的是要有意识地区分“真正需要全局唯一的资源”和“仅仅为了方便访问的全局数据”。前者用单例后者用依赖注入或上下文传递。把这个边界想清楚比记住24种模式的类图有用得多。最后再分享一个小技巧如果你的单例需要在不同环境下有不同的表现比如测试环境用mock、生产环境用真实对象可以在getInstance()里通过一个可配置的标志位创建不同实例但要让这个标志位的改变发生在应用启动阶段并且不要在运行期动态修改。否则并发下状态突变你调试到怀疑人生也找不到原因。

关于恒美微站

恒美微站专注于为个体商户、工作室提供极简自助建站服务,让每个人都能轻松拥有专业网站。

快速链接

  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心

服务项目

  • 可视化建站
  • 拖拽编辑
  • 主题定制
  • SEO 优化
  • 网站托管

联系方式

  • 📍 地址:北京市朝阳区建国路 88 号
  • 📞 电话:400-888-8888
  • ✉️ 邮箱:info@hmyw.cn
  • 🕐 时间:周一至周日 9:00-18:00

© 2024 恒美微站 hmyw.cn 版权所有 | 京 ICP 备 12345678 号