单例模式:高并发下"唯一实例"的四种实现与选型
本文梳理 Java 中单例模式的四种经典实现各自的取舍,以及高并发场景下最容易踩的两个坑。
一、单例在解决什么问题
单例模式(Singleton)回答一个朴素的问题:有些对象,整个系统只需要一个。
比如:
- 配置中心:启动时加载一次,全局共享;
- 订单号生成器:所有订单号由同一个发号器产出;
- 数据库连接池:连接资源集中管理,不能各处各建一个池子;
- 日志收集器:所有模块的日志汇入同一条管道。
它的约束有三条:类只能有一个实例;这个实例由类自己创建;并向全系统提供全局访问入口。
听起来简单,但放进高并发环境,问题立刻变得立体:
| 挑战 | 具体问题 |
|---|---|
| 线程安全 | 多个线程同时创建,如何保证只产生一个实例? |
| 性能 | 加锁之后,每次获取实例都抢锁,吞吐怎么办? |
| 防破坏 | 反射、序列化反序列化能否绕过限制再造一个实例? |
Java 社区给出的四种实现,本质上就是在"懒加载、锁开销、安全性"三个维度上做不同的取舍。下面逐一展开。
二、饿汉式:类加载即创建,简单到没有并发问题
最直接的思路:在类加载阶段就把实例创建好。JVM 保证类的静态初始化只执行一次且线程安全(<clinit>方法会加锁),所以压根不存在竞争。
/** * 饿汉式单例 * 场景:系统配置中心——启动必用、全局唯一、全程不变 */publicclassConfigCenter{// 类加载时即完成实例化,JVM 保证线程安全privatestaticfinalConfigCenterINSTANCE=newConfigCenter();// 私有构造器,阻断外部 newprivateConfigCenter(){loadConfig();}publicstaticConfigCentergetInstance(){returnINSTANCE;}publicStringget(Stringkey){return"value-of-"+key;}privatevoidloadConfig(){System.out.println("[ConfigCenter] 配置加载完成(类加载时执行)");}}取舍分析
- ✅ 无锁:所有线程读到的是同一个 final 引用,读取没有任何同步开销,高并发下性能最好;
- ✅ 简单:三行核心代码,几乎不可能写错;
- ❌ 没有懒加载:类被加载实例就创建,如果实例占用资源大(比如提前建立连接池),而系统运行很久才用到它,就是白白占着内存;
- ❌ 无法动态初始化:构造器不能带参数,想根据启动参数配置实例就做不到。
适用判断:实例轻量、启动必用的场景——配置管理器、全局常量、工具类。这也是生产中最常见的写法。
三、双重检查锁(DCL):懒加载与性能的平衡
如果实例创建成本高(建立连接、初始化算法参数),又不想启动时就付出这个成本,就需要懒加载:第一次用到才创建。
最朴素的做法是给getInstance整个方法加synchronized,但那样每次获取实例都要抢锁——实例创建好之后,抢锁纯属浪费。
于是有了双重检查锁(Double-Checked Locking):只在实例为空时才进同步块,创建完成后全部无锁。
/** * 双重检查锁(DCL)单例 * 场景:订单号生成器——首次生成订单时才初始化雪花算法参数 */publicclassOrderNoGenerator{// volatile 关键:禁止指令重排(下文详解)privatestaticvolatileOrderNoGeneratorINSTANCE;privateOrderNoGenerator(){initSnowflake();}publicstaticOrderNoGeneratorgetInstance(){// 第一重检查:绝大多数调用在这里直接返回,不碰锁if(INSTANCE==null){// 只有创建阶段才加锁synchronized(OrderNoGenerator.class){// 第二重检查:防止多个线程排队等锁后重复创建if(INSTANCE==null){INSTANCE=newOrderNoGenerator();}}}returnINSTANCE;}// 高频调用路径上没有任何锁publiclongnextId(){returnSystem.currentTimeMillis()+100_000L;}privatevoidinitSnowflake(){System.out.println("[OrderNoGenerator] 雪花算法参数初始化(首次 getInstance 时执行)");}}为什么 volatile 不能省
new OrderNoGenerator()在 JVM 层面不是一步完成的,它至少拆成三步:
- 分配对象内存;
- 执行构造器初始化;
- 把内存地址写入 INSTANCE 引用。
CPU 和 JIT 可能把第 2、3 步重排——先写引用、再初始化。于是可能出现:线程 A 刚执行完第 1、3 步,还没来得及初始化;线程 B 经过第一重检查发现 INSTANCE 非空,直接拿去用,拿到的是一个"半初始化"的对象,后续调用随时可能出错。
volatile禁止了这种重排,保证"引用可见"一定发生在"初始化完成"之后。这也是 DCL 写法里最容易漏掉的一个字——漏掉之后低并发下测不出来,上线后偶发诡异 bug。
取舍分析
- ✅ 懒加载:首次使用才初始化;
- ✅ 高性能:创建完成后的调用完全无锁;
- ❌ 代码复杂:双重检查 + volatile,少一个都不行,容易被"简化"出问题。
适用判断:实例创建成本高、启动不一定用到的场景——ID 生成器、连接池、重量级客户端。
四、静态内部类:借 JVM 之手实现懒加载,还不用自己加锁
有没有一种写法,既有饿汉式的"零锁开销",又有 DCL 的"懒加载",还不需要手动写检查逻辑?
有。思路是:把实例的创建放进一个静态内部类,而 JVM 只在内部类第一次被访问时才加载它。
/** * 静态内部类单例 * 场景:本地缓存管理器——懒加载 + 无锁 */publicclassCacheManager{privateCacheManager(){initCache();}// 外部类加载时,Holder 并不会被加载privatestaticclassHolder{// 真正触发加载的是首次访问 Holder.INSTANCE 的那一刻privatestaticfinalCacheManagerINSTANCE=newCacheManager();}publicstaticCacheManagergetInstance(){returnHolder.INSTANCE;}publicObjectget(Stringkey){returnnewObject();// 示意}privatevoidinitCache(){System.out.println("[CacheManager] 缓存初始化(首次 getInstance 时执行)");}}取舍分析
- ✅ 懒加载 + 无锁:加载时机由 JVM 类加载机制保证,性能与饿汉式一致;
- ✅ 优雅:没有 DCL 的双重检查和 volatile,可读性好;
- ❌ 不能传参:和饿汉式一样,构造器无法接收运行期参数。
适用判断:需要懒加载、但不需要动态参数的场景——缓存管理器、分布式锁客户端、注册中心客户端。
五、枚举:JVM 层面的"绝对唯一"
前面三种实现都挡不住两记冷箭:
- 反射:
Constructor.setAccessible(true)强行调用私有构造器; - 序列化:把实例序列化到磁盘再反序列化回来,得到一个新对象。
而枚举的实例由 JVM 直接创建,反射调用枚举构造器会抛异常,反序列化也走 JVM 内部机制、不会新建实例。换句话说,防破坏这件事由 JVM 兜底,而不是靠代码自觉。
/** * 枚举单例 * 场景:全局事件注册表——对唯一性要求极高 */publicenumGlobalEventRegistry{INSTANCE;GlobalEventRegistry(){initRegistry();}publicvoidpublish(Objectevent){System.out.println("[GlobalEventRegistry] 发布事件: "+event);}publicstaticGlobalEventRegistrygetInstance(){returnINSTANCE;}privatevoidinitRegistry(){System.out.println("[GlobalEventRegistry] 注册表初始化(枚举类加载时执行)");}}取舍分析
- ✅ 绝对安全:线程安全由 JVM 保证,反射、序列化两条破坏路径都被封死;
- ✅ 极简:几行代码;
- ❌ 无懒加载:枚举类加载时实例即创建。
适用判断:唯一性比启动成本更重要的场景——全局计数器、日志总线、事件注册表。
六、四种实现怎么选
| 实现 | 懒加载 | 获取实例的锁开销 | 防反射/序列化 | 可否传参初始化 | 一句话定位 |
|---|---|---|---|---|---|
| 饿汉式 | ❌ | 无 | ❌ | ❌ | 简单场景的默认选择 |
| DCL | ✅ | 创建后无 | ❌ | ✅ | 重实例 + 要懒加载 |
| 静态内部类 | ✅ | 无 | ❌ | ❌ | 懒加载的优雅写法 |
| 枚举 | ❌ | 无 | ✅ | ❌ | 唯一性至上的终极方案 |
选型口诀:
- 实例轻、启动必用 → 饿汉式;
- 实例重、要懒加载、可能要传参 → DCL;
- 要懒加载但不用传参、想简单 → 静态内部类;
- 唯一性不容有失 → 枚举。
七、两个必须记住的坑
- DCL 少了 volatile:表面上看代码没问题,低并发测试也全绿,高并发下会偶发拿到未初始化完成的对象。这是单例话题里最高频的考点,也是线上事故的真实来源;
- 把"性能"当作唯一指标:很多团队不管三七二十一上 DCL,其实配置中心这类轻量实例用饿汉式更合适——选型看的是"资源占用 + 启动依赖 + 唯一性要求"的组合,而不是谁的实现更"高级"。
小结
单例的演进线其实是三问:要不要懒加载?能不能接受锁开销?要不要防破坏?四种实现分别回答这三问的不同组合。没有最好的单例,只有最贴合场景的单例。