设计模式01-单例模式:唯一实例的四种实现与选型

发布时间:2026/9/7 21:54:06
设计模式01-单例模式:唯一实例的四种实现与选型 单例模式高并发下唯一实例的四种实现与选型本文梳理 Java 中单例模式的四种经典实现各自的取舍以及高并发场景下最容易踩的两个坑。一、单例在解决什么问题单例模式Singleton回答一个朴素的问题有些对象整个系统只需要一个。比如配置中心启动时加载一次全局共享订单号生成器所有订单号由同一个发号器产出数据库连接池连接资源集中管理不能各处各建一个池子日志收集器所有模块的日志汇入同一条管道。它的约束有三条类只能有一个实例这个实例由类自己创建并向全系统提供全局访问入口。听起来简单但放进高并发环境问题立刻变得立体挑战具体问题线程安全多个线程同时创建如何保证只产生一个实例性能加锁之后每次获取实例都抢锁吞吐怎么办防破坏反射、序列化反序列化能否绕过限制再造一个实例Java 社区给出的四种实现本质上就是在懒加载、锁开销、安全性三个维度上做不同的取舍。下面逐一展开。二、饿汉式类加载即创建简单到没有并发问题最直接的思路在类加载阶段就把实例创建好。JVM 保证类的静态初始化只执行一次且线程安全clinit方法会加锁所以压根不存在竞争。/** * 饿汉式单例 * 场景系统配置中心——启动必用、全局唯一、全程不变 */publicclassConfigCenter{// 类加载时即完成实例化JVM 保证线程安全privatestaticfinalConfigCenterINSTANCEnewConfigCenter();// 私有构造器阻断外部 newprivateConfigCenter(){loadConfig();}publicstaticConfigCentergetInstance(){returnINSTANCE;}publicStringget(Stringkey){returnvalue-of-key;}privatevoidloadConfig(){System.out.println([ConfigCenter] 配置加载完成类加载时执行);}}取舍分析✅ 无锁所有线程读到的是同一个 final 引用读取没有任何同步开销高并发下性能最好✅ 简单三行核心代码几乎不可能写错❌ 没有懒加载类被加载实例就创建如果实例占用资源大比如提前建立连接池而系统运行很久才用到它就是白白占着内存❌ 无法动态初始化构造器不能带参数想根据启动参数配置实例就做不到。适用判断实例轻量、启动必用的场景——配置管理器、全局常量、工具类。这也是生产中最常见的写法。三、双重检查锁DCL懒加载与性能的平衡如果实例创建成本高建立连接、初始化算法参数又不想启动时就付出这个成本就需要懒加载第一次用到才创建。最朴素的做法是给getInstance整个方法加synchronized但那样每次获取实例都要抢锁——实例创建好之后抢锁纯属浪费。于是有了双重检查锁Double-Checked Locking只在实例为空时才进同步块创建完成后全部无锁。/** * 双重检查锁DCL单例 * 场景订单号生成器——首次生成订单时才初始化雪花算法参数 */publicclassOrderNoGenerator{// volatile 关键禁止指令重排下文详解privatestaticvolatileOrderNoGeneratorINSTANCE;privateOrderNoGenerator(){initSnowflake();}publicstaticOrderNoGeneratorgetInstance(){// 第一重检查绝大多数调用在这里直接返回不碰锁if(INSTANCEnull){// 只有创建阶段才加锁synchronized(OrderNoGenerator.class){// 第二重检查防止多个线程排队等锁后重复创建if(INSTANCEnull){INSTANCEnewOrderNoGenerator();}}}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 的那一刻privatestaticfinalCacheManagerINSTANCEnewCacheManager();}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其实配置中心这类轻量实例用饿汉式更合适——选型看的是资源占用 启动依赖 唯一性要求的组合而不是谁的实现更高级。小结单例的演进线其实是三问要不要懒加载能不能接受锁开销要不要防破坏四种实现分别回答这三问的不同组合。没有最好的单例只有最贴合场景的单例。