单例模式全解析:五种写法、线程安全与实战避坑指南

发布时间:2026/10/21 10:00:33
单例模式全解析:五种写法、线程安全与实战避坑指南 早上刚到工位运维就在群里喊数据库连接池被打满了。看了眼监控高峰期才几百QPS连接池配的50个怎么看都够用。排查了半天最后发现是某个同事在工具类里每次调用都 new 了一个数据库连接池对象连接没复用全被耗光了。这事的根源就是对象该有多个还是只能有一个没想清楚。这也是单例模式Singleton Pattern最典型的应用场景——保证一个类在整个程序生命周期里只有一个实例并提供一个全局访问点。我第一次学单例模式时觉得这不就是个把构造函数私有化、加个静态方法返回自己的小把戏吗后来接手的项目多了才发现单例模式是设计模式里最容易写错、也最容易埋雷的一个。饿汉式、懒汉式、双重检查锁定、静态内部类、枚举——五种写法各有各的坑很多老手都栽过。这篇文章不打算把单例模式讲成课本上的定义堆砌而是从一个实际项目维护者的视角把每种写法的设计动机、适用场景、常见误用连同我在真实项目里踩过的坑一并说清楚。代码示例用Java写因为单例模式在Java里最经典但涉及的设计思想在 Go、Python、C 里同样适用。1. 单例模式到底在解决什么不是一个形式问题而是三个资源逻辑问题很多人把单例模式理解成全局唯一对象这个理解没错但太表面。真正的问题在于如果你的代码里有多个重复的实例会有哪些实质危害搞明白这个你才知道哪些场景该用单例哪些场景强行用单例反而是在挖坑。1.1 重复创建带来的三个实质危害第一个危害是资源浪费。数据库连接池、线程池、网络客户端比如 Redis 客户端、日志记录器这类对象的构建成本极高一个连接池往往要预创建几十条连接每条连接都是真实的网络套接字和操作系统资源。每次调用都 new 一个等于每一次操作都要重新握手、重新鉴权、重新建立连接。我曾经在某个老项目里见过这样的代码public class DbClient { public static void execute(String sql) { // 每次调用都创建连接池用完不关闭直接泄漏 HikariDataSource dataSource new HikariDataSource(); dataSource.setJdbcUrl(url); dataSource.setUsername(user); dataSource.setPassword(password); // 执行 SQL... } }这段代码放到线上连接数会像脱缰的野马一样涨直到把数据库的连接数配额耗尽。这类问题用单例模式能解决——让连接池只初始化一次之后所有调用复用同一个实例。第二个危害是状态不一致。如果某个对象持有共享状态配置信息、计数器、缓存多实例会导致每个实例各自维护一套状态互相之间感知不到。举个例子一个用于生成全局自增ID的类如果存在两个实例两个实例内部的currentId各自独立累加生成的ID就会重复。这种问题在分布式环境里更严重但即便在单机上多实例共享可变状态也一样会翻车。第三个危害是逻辑唯一性被破坏。有些业务规则要求所有操作必须经过同一个入口。典型的例子是任务调度器、全局事件总线、统一配置中心。如果存在多个实例可能出现双份定时任务同时执行、事件被重复消费等诡异问题。此时单例模式保证的是逻辑上的唯一控制点不只是一个对象那么简单。1.2 不是所有对象都该做成单例这里必须泼一盆冷水无状态对象不持有任何成员变量根本不需要单例模式。比如一个工具类所有方法都是纯函数——输入几个参数返回计算结果不改变任何内部状态。你把它做成单例与每次 new 一个功能上没有任何区别反而因为要处理并发下实例创建的线程安全问题凭空增加了复杂度。还有一类常见误用是把单例当全局变量用。有些人图省事把当前用户信息、临时请求数据往单例里塞结果高并发下数据互相覆盖事故现场惨不忍睹。正确的做法是单例只放跨请求共享且需要保证一致的资源请求级数据永远不要进单例。下单堆的每个接口都要吃一份全局配置配置类做成单例没问题但用户的购物车数据必须放请求上下文里放单例里就是给自己埋雷。2. 饿汉式、懒汉式到双重检查锁定线程安全如何一步步逼出复杂写法如果你去看各种入门教程单例模式的代码会从最简单的写法一路升级到最复杂的写法每一步都是被线程安全问题逼出来的。理解了这条演进线你就不会再对着五种写法背来背去了。2.1 饿汉式类加载时就创建实例线程天然安全但可能白等饿汉式是单例模式里最简洁的写法public class ConfigManager { // 类加载进内存时就完成实例化 private static final ConfigManager INSTANCE new ConfigManager(); private ConfigManager() { // 模拟加载配置文件可能耗时较长 System.out.println(配置管理器初始化加载 application.yaml); } public static ConfigManager getInstance() { return INSTANCE; } public String getConfig(String key) { return value; } }它之所以线程安全是因为 JVM 保证类的初始化阶段是线程安全的多个线程同时第一次调用getInstance()时JVM 会保证ConfigManager这个类只被初始化一次INSTANCE只被赋值一次后续线程直接拿到同一个对象。代价也一目了然不管你用不用这个对象只要类被加载比如你访问了这个类的某个静态方法实例就被创建了。如果这个类的构造过程很重加载配置、建立连接而应用启动后根本没走到这段逻辑实例就白白创建了。注意饿汉式的饿说的是类一加载就急着创建实例一天都等不了。如果项目里这个单例是启动必需品比如配置中心客户端饿汉式完全没问题简单、线程安全、不容易出错。2.2 懒汉式想用到再创建但并发下会出大乱子懒汉式顾名思义就是懒到极点getInstance() 被调用了才创建实例public class IdGenerator { private static IdGenerator instance; private IdGenerator() { // 初始化起始ID } // 加 synchronized 保证同一时刻只有一个线程能进入 public static synchronized IdGenerator getInstance() { if (instance null) { instance new IdGenerator(); } return instance; } }在 getInstance() 方法上加synchronized解决了线程安全问题但引入了新麻烦每次调用都要经过锁的竞争。就算实例已经创建好了后续线程想拿个已有的实例也得先抢锁再进方法体再退出释放锁。高并发场景下这会让所有获取单例的调用串行化性能大打折扣。2.3 双重检查锁定减少锁范围但 volatile 必不可少既然只在实例还没创建的时候才需要同步已经创建完了就别再抢锁了这就是双重检查锁定DCL, Double-Checked Locking的思路public class DoubleCheckedSingleton { private static volatile DoubleCheckedSingleton instance; private DoubleCheckedSingleton() { } public static DoubleCheckedSingleton getInstance() { // 第一次检查实例还没创建时才进入同步块 if (instance null) { synchronized (DoubleCheckedSingleton.class) { // 第二次检查进入同步块后再次确认 if (instance null) { instance new DoubleCheckedSingleton(); } } } return instance; } }这里有两个设计细节非常关键。为什么要两次检查如果有两个线程 A 和 B 同时进入getInstance()A 先拿到锁并创建了实例然后释放锁B 此时还在同步块外等待等 B 进入同步块时如果没有第二次if (instance null)检查B 就会再创建一个新实例单例就被破坏了。第二次检查的作用就是拦截那些在锁外判断过 null、进锁后才发现实例已经被别人建好的线程。为什么必须加 volatile这是整段代码里最容易忽略的点。instance new DoubleCheckedSingleton()这行代码在 JVM 层面并不是一个原子操作它可以分解为三步分配堆内存创建空的DoubleCheckedSingleton对象调用构造方法初始化对象把instance引用指向这块内存。JVM 允许指令重排序执行顺序可能是 1 → 2 → 3也可能被优化成 1 → 3 → 2。如果是后者某个线程在步骤 3 刚执行完、步骤 2 还没执行时看到instance已经不为 null直接返回这个半初始化状态的对象。别的线程拿着这个对象去调用方法就可能拿到默认值0、null而不是构造方法里设置好的值。volatile 关键字在这里有两个作用禁止指令重排序保证instance引用赋值发生在对象完全初始化之后保证可见性一个线程创建的实例其他线程能立刻看到。没有 volatile 的双重检查锁定在理论上就是错的因为它连最基本的对象完整性都无法保证。2.4 一个测试观察不加 volatile 会怎样想观察指令重排引发的半初始化状态需要一个辅助手段在构造函数里睡一觉人为拉长初始化时间。如果构造耗时足够长并且 JVM 实际发生了重排序某个线程拿到的实例可能是非 null 但内部字段还是默认值。我实际跑过的结论是在 HotSpot 20 以上的 JVM 里DCL 不加 volatile 基本不会出事因为 HotSpot 的 C2 编译器在这个特定模式上没有执行破坏性的重排序。但你看这结论它依赖的是具体 JVM 实现的行为不是 Java 语言规范保证的行为。换个 JVM比如某些嵌入式环境、换个编译器版本、换个 CPU 架构可能一夜之间就重现问题。所以我的建议是不要赌 JVM 实现老老实实加 volatile。对于单例模式这种基础代码稳定性比那一点性能收益重要得多。3. 静态内部类和枚举两个工程实践证明更省心的方案如果你觉得双重检查锁定的两个细节太烧脑别急还有两个更优雅的方案工程上我推荐优先考虑它们。3.1 静态内部类不用 synchronized也很懒加载public class LazyHolderSingleton { private LazyHolderSingleton() { // 初始化连接池等重量级资源 } // 静态内部类只有在被访问时才会被加载 private static class Holder { private static final LazyHolderSingleton INSTANCE new LazyHolderSingleton(); } public static LazyHolderSingleton getInstance() { return Holder.INSTANCE; } }这个写法的精妙之处在于JVM 的类加载机制本身就是懒加载的天然实现。外部类LazyHolderSingleton被加载进内存时内部类Holder并不会被立即加载只有第一次调用getInstance()触发了Holder.INSTANCE的访问内部类才会被 JVM 加载此时才执行new LazyHolderSingleton()。既没有 synchronized 带来锁开销也没有饿汉式不管用不用都创建的资源浪费代码还简洁。单例模式里如果你问我的首选一般场景下静态内部类写法就够用了。3.2 枚举单例能防反射和序列化破坏是多数情况下的最优解再来看看枚举写法这也是业界公认的最稳的单例方案public enum EnumSingleton { INSTANCE; private String config; public void setConfig(String config) { this.config config; } public String getConfig() { return config; } }使用方式非常简洁EnumSingleton.INSTANCE.getConfig()拿到的永远是同一个对象。这里很多人会问一个问题枚举不就是个类吗它能当单例用能。Java 语言规范明确规定了枚举类型的实例天然是唯一的构造器是私有的不允许通过 new 创建枚举实例反射无法创建newInstance()在遇到枚举类时会直接抛出异常禁止反射绕过序列化安全枚举实例的序列化机制在底层做了特殊处理反序列化时不会创建新对象直接返回现有的INSTANCE线程安全枚举实例的创建由 JVM 保证天然线程安全。这四条特性简直是单例模式梦寐以求的防破解补丁。前几年我维护过一个错误码管理系统用的就是枚举单例部门几个新人往里加错误码时根本不需要理解构造器私有化、double-checked locking 这些概念直接ErrorCodeRegistry.INSTANCE.register(...)就完事。而且这个系统经历了多轮序列化、反射工具调用从没出过多个实例的幺蛾子。不同写法的对比可以用一张表总结写法线程安全懒加载防反射破坏防序列化破坏代码复杂度推荐场景饿汉式安全否否否最低启动必需、构造不重的单例懒汉式synchronized方法安全是否否低低并发、不影响性能的场景双重检查锁定需加volatile才安全是否否中需要懒加载、追求性能的一般场景静态内部类安全是否否低懒加载性能兼顾工程首选枚举安全否是是最低需要防反射/序列化的极端安全场景4. 好端端的单例是怎么被破坏掉的容易被忽略的三个坑学完写法我劝你别急着收工。单例模式最经典的坑往往不在写法本身而在于外部因素会想方设法地绕开你的私有构造器防线。这一节我会展开讲三种最常见的破坏方式每条都是从实际项目中挖出来的。4.1 反射攻击setAccessible(true) 让你防线全无Java 反射可以拿到私有构造器然后强行调用。下面这行代码能轻松创建一个无法无天的新实例public class ReflectAttackDemo { public static void main(String[] args) throws Exception { ClassDoubleCheckedSingleton clazz DoubleCheckedSingleton.class; ConstructorDoubleCheckedSingleton constructor clazz.getDeclaredConstructor(); // 拿到的是private构造器 constructor.setAccessible(true); // 强制取消访问权限检查 DoubleCheckedSingleton newInstance constructor.newInstance(); // 直接new出来 System.out.println(newInstance DoubleCheckedSingleton.getInstance()); // false } }此时你已经成功让这个单例类拥有了两个实例。常规的单例写法饿汉式、懒汉式、DCL、静态内部类在反射面前全部失守——因为它们的私有构造器只是约定俗成不去调用而不是强制禁止调用。防御方案之一是在构造函数里加校验用一个标志位记录实例是否已创建第二次走构造器时直接抛异常public class ReflectSafeSingleton { private static boolean initFlag false; private ReflectSafeSingleton() { synchronized (ReflectSafeSingleton.class) { if (initFlag) { throw new RuntimeException(单例模式被破坏禁止反射创建新实例); } initFlag true; } } }这样即使反射调用了私有构造器第二次也会被initFlag拦截。但是注意这个方案不是绝对安全攻击者可以先反射修改initFlag字段的值再调用构造器。想要绝对安全老老实实用枚举单例——反射机制对枚举的构造器有硬性保护压根不让你调。4.2 序列化与反序列化走私有构造器也能凭空再造你有没有想过一个单例对象被序列化保存到磁盘再反序列化回内存时得到的是同一个对象还是一个新对象答案是新对象。Java 的反序列化机制通过ObjectInputStream加载字节流沿用的是不调用构造器直接在底层分配内存的机制你设置的 private 构造器拦不住它。我当年在一个分布式配置中心踩过这个坑配置快照被做成了持久化文件服务重启后从文件反序列化恢复配置结果恢复出来的对象和getInstance()拿到的对象不是同一个两块代码各改各的配置缓存一直不一致。解决办法是给单例类加一个readResolve()方法让反序列化直接返回已有的单例实例public class SerializationSafeSingleton implements Serializable { private static final SerializationSafeSingleton INSTANCE new SerializationSafeSingleton(); private SerializationSafeSingleton() { } // JVM 反序列化时会检查这个方法存在时直接用返回值替换反序列化结果 protected Object readResolve() { return INSTANCE; } public static SerializationSafeSingleton getInstance() { return INSTANCE; } }加了readResolve()后反序列化生成的那个新对象会被丢弃程序拿到的还是INSTANCE这个唯一实例。这个机制的原理是JVM 在反序列化完成前会调用readResolve()把方法的返回值作为最终的反序列化结果。4.3 多类加载器与 ThreadLocal概念上全局唯一实际却各自为政第三个坑往往藏得最深因为报错不明显甚至逻辑上看起来没坏。多类加载器环境下同一个类可能被两个不同的类加载器加载两次JVM 里就会出现两份DoubleCheckedSingleton.class的元数据各自维护一个静态字段instance。这意味着两个类加载器各自持有的单例是独立的互不相认。典型场景是 Servlet 容器里不同 Web 应用共享同一个类库、OSGi 插件化架构、自定义类加载器做热部署。遇到这种环境你需要在实例内部持有 ClassLoader 引用通过对比类加载器来判断访问者的阵营public class ClassLoaderAwareSingleton { private static ClassLoaderAwareSingleton instance; public static ClassLoaderAwareSingleton getInstance() { if (instance null) { synchronized (ClassLoaderAwareSingleton.class) { synchronized (ClassLoaderAwareSingleton.class.getClassLoader()) { // 双重保护配合类加载器维度判断 if (instance null) { instance new ClassLoaderAwareSingleton(); } } } } return instance; } }但这种写法很笨重不像枚举那样天然规避语义混乱——枚举实例在同一个类加载器命名空间内一定是唯一的类加载器隔离性也更清晰。ThreadLocal 导致假单例的原理则是ThreadLocal 为每个线程维护一份独立副本如果把单例对象存进 ThreadLocal每个线程访问到的都是自己的副本从全局看并不是只有一个实例。如果业务逻辑依赖全进程只有一个计数器用 ThreadLocal 包装单例就会翻车。但从另一个角度说当你需要每个线程有独立游标、但各线程的游标互不可见时ThreadLocal 恰好是单例模式的互补方案。5. 实战选型不同业务场景对应哪种写法以及三个容易忽略的延伸问题写到这里五种写法和破坏手段都过了一遍但如果你直接冲进项目里改代码还可能踩到场景匹配的问题。这一节给出选型建议和两三个延伸问题处理思路。5.1 选型建议从场景反推写法我自己的选型经验可以归纳成一张实战决策表项目场景推荐写法理由Spring/Spring Boot 容器管理的 Bean交给容器单例不需要手写容器默认每个 Bean 只实例化一次配合Lazy可实现懒加载全局配置中心、环境变量包装饿汉式启动必须加载延迟没有意义代码最简单需要懒加载的通用组件连接池、缓存客户端静态内部类懒加载且无线程安全问题工具类、Utility不需要单例静态方法即可无状态纯函数实例化没意义对安全性要求极高的资源token签发器、数据库密码管理器枚举单例防反射、防序列化、线程安全全部内置复杂对象且依赖构造参数工厂模式容器管理不建议手写单例单例模式不适合承载构造参数变化在 Spring 项目里绝大多数情况你根本不需要手写单例。Component、Service、Repository默认都是单例 Bean由容器管理生命周期。有人问我那双重检查锁定是不是白学了不是。框架之外还有大量非 Spring 管理的组件Redis 工具类、MQ 消费连接、本地缓存管理器这些地方手写单例的机会多得很。而且理解单例模式的线程安全本质能帮你理解 Spring 单例 Bean 的并发限制——Spring 的单例 Bean 因为全局共享成员变量天然存在并发覆盖风险所以依赖注入的 Bean 都应该设计成无状态或只读的。5.2 单例的可变状态问题单例不等于任意修改很多人把单例和全局可变变量混为一谈这是非常危险的。你确实可以通过getInstance()拿到全局唯一对象但如果这个对象内部有可变字段比如一个configMap任何线程都能修改它就会出现类级别的竞态问题。我的实践经验是单例类内部的可变状态必须单独加锁或使用原子类。比如一个全局序列号生成器内部的AtomicLong是线程安全的多线程并发调用不会重复但如果改成普通long就需要在每次incrementAndGet()时加synchronized。这个问题在面试里经常被拿出来问很多人能背出单例的五种写法却答不上来单例里被并发修改的字段怎么办。5.3 单例对象的资源释放问题这是最容易被忽略的地方。如果单例持有数据库连接、文件句柄、网络连接它在整个进程生命周期内一直存在这些资源永远不释放。进程退出时看 JVM 是否会优雅地回收public class ConnectionManager { private static class Holder { private static final ConnectionManager INSTANCE new ConnectionManager(); } private Connection connection; public static ConnectionManager getInstance() { return Holder.INSTANCE; } // 需要显式关闭时提供一个发布到容器的销毁钩子 public void shutdown() { // 关闭连接池 } }在 Spring 环境中可以借助 Bean 的PreDestroy回调优雅关闭在非 Spring 环境则需要自己在进程退出信号比如Runtime.getRuntime().addShutdownHook()里调用shutdown()方法。5.4 测试视角单例模式的可测试性缺陷最后想提醒你的是单例模式在单元测试里是个刺头。因为它提供的是全局访问点跑完一个测试用例单例的状态不会自动复位下一个测试用例可能拿到被上一个用例污染的实例。经典解决方案有两个给单例类提供reset()方法专门用于测试中重置状态注意只能在测试代码调用生产代码不要暴露用依赖注入替代直接在业务代码里getInstance()测试时注入一个可控的 mock 实例。第二种方案的副作用是让代码结构更干净——业务类只需要接收一个接口类型不关心接口背后是不是单例。这也是为什么很多现代代码规范里单例模式逐步让位于依赖注入框架替你管理唯一实例业务代码不再关心创建还是复用一个实例。6. 单例模式在并发框架里的亲戚池、上下文和 ThreadLocal单例模式的变体在实际项目中几乎无处不在我把它们归为单例思想的三个亲戚理解了它们你会有种豁然开朗的感觉。6.1 对象池与线程池是多个资源一个管理器而非只有一个对象很多人以为线程池是单例其实线程池里的线程有十几个池子本身可以创建多个。正确的类比应该是池 一堆对象 一个管理器管理器决定谁提供服务、谁空闲、谁扩容。这和单例模式的关系在于如果池管理器的实例不唯一就会出现两个管理器各自维护一组线程的错乱局面。所以正确的设计通常是池本身可以有多个不同用途、不同核心线程数但每个池的持有者应该是唯一的引用点。我在现实里见过最典型的问题一个服务里为数据库操作和HTTP 调用各建了一个线程池本来没问题。后来有人图省事在静态方法里每次调用都新建一个临时线程池用完又不shutdown()线程数逐渐失控。这不只是单例问题更是池生命周期管理问题。6.2 上下文对象Context为什么 Spring 的 ApplicationContext 是单例的ApplicationContext是 Spring 容器本身的实例它必须全进程唯一——因为整个应用的 Bean 容器只能有一个。如果存在两个 ApplicationContext意味着有两套 Bean 容器同一个ServiceBean 会被创建两份依赖关系也会分裂成两套平行的闭环网络。这个场景从深度上验证了单例模式的核心价值当唯一性是业务正确性的前提时就必须约束只有单一实例。6.3 单例模式的替代品工厂模式注册表如果你觉得枚举单例还不够灵活或者项目需要运行时按配置创建不同实例但保证每个 key 对应一个实例可以用注册表模式它其实是多个实例共用一套注册管理逻辑public class Registry { private static final MapString, Object registry new ConcurrentHashMap(); public static Object getInstance(String key, Supplier? creator) { return registry.computeIfAbsent(key, k - creator.get()); } }这种方式每次拿到的是同一 key 下的同一个对象相当于单例的分布式版本。在按租户分库、按渠道分配置的场景很实用。注意这里需要ConcurrentHashMap因为它要保证并发下同一个 key 只创建一次。收尾写单例这几年我最大的体会单例模式看起来是设计模式里最简单的一个但真正想把它写好涉及的不仅仅是五种写法本身而是对线程安全、类加载机制、反射与序列化原理、框架生命周期管理的综合理解。我见过很多工作了好几年的开发上来就写双重检查锁定却答不上来 volatile 到底防的是什么也见过在 Java 项目里坚持用裸静态字段的代码后来被多实例问题反复折腾。给新人的建议是优先用静态内部类和枚举慎用双重检查锁定不到万不得已不用裸同步的懒汉式。先保证不出错再考虑极致性能。给项目维护者的建议是单例模式写完之后想想这个单例暴露了哪些通信边界缓存、配置、状态这些边界能不能换成接口注入让测试更好做。如果哪天你在生产环境排查问题发现两个实例是罪魁祸首回来看看这篇文章里关于反射、序列化和类加载器的部分你大概就能精准地定位到问题在哪了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询