设计模式实战解读(一):单例模式——全局唯一实例的正确打开方式
本文是「设计模式实战解读」系列第一篇。系列文章统一按照定义 → 痛点场景 → 模式结构 → 核心实现 → 真实应用 → 常见变种 → 优缺点 → 避坑指南 → FAQ的结构展开,每篇聚焦一个模式讲透。
一句话定义
单例模式(Singleton):确保一个类只有一个实例,并提供一个全局访问点。
归属:创建型模式。
一、没有单例时的痛点
假设你正在做一个配置管理模块,系统启动时需要从 Nacos/Apollo 加载配置并缓存到内存里:
// 问题代码:每次需要配置时都 new 一个ConfigManagerconfigA=newConfigManager();// 加载一次远程配置ConfigManagerconfigB=newConfigManager();// 又加载一次远程配置// configA 和 configB 是两个独立实例// 1. 重复加载浪费网络 IO// 2. 两份缓存不一致(A 修改了配置,B 看不到)// 3. 如果配置里有状态(如 version),两份实例会分裂类似的痛点还出现在:数据库连接池、线程池管理器、日志打印器、ID 生成器——这些组件如果被 new 多份,要么浪费资源,要么产生不一致的行为。
核心诉求:全局只需要一份,任何地方拿到的都是同一个。
二、模式结构
┌──────────────────────────────┐ │ Singleton │ ├──────────────────────────────┤ │ - instance: Singleton │ ← 唯一实例(静态字段) ├──────────────────────────────┤ │ - Singleton() │ ← 私有构造(禁止外部 new) │ + getInstance(): Singleton │ ← 全局访问点 │ + businessMethod() │ ← 业务方法 └──────────────────────────────┘三要素:
- 私有构造函数——禁止外部
new - 静态实例字段——类级别持有唯一实例
- 公开静态方法——全局获取入口
三、核心实现(五种写法对比)
3.1 饿汉式(推荐在大多数场景使用)
publicclassSingleton{// 类加载时就创建实例(JVM 保证线程安全)privatestaticfinalSingletonINSTANCE=newSingleton();privateSingleton(){}publicstaticSingletongetInstance(){returnINSTANCE;}}优点:实现简单,线程安全,无同步开销。
缺点:类加载时就创建,如果实例很重且未必被使用,造成浪费。
适用:实例创建成本低、确定会被使用的场景。
3.2 懒汉式 + 双重检查锁(DCL)
publicclassSingleton{// volatile 防止指令重排序privatestaticvolatileSingletoninstance;privateSingleton(){}publicstaticSingletongetInstance(){if(instance==null){// 第一次检查(无锁)synchronized(Singleton.class){// 加锁if(instance==null){// 第二次检查instance=newSingleton();}}}returninstance;}}优点:懒加载 + 线程安全 + 锁粒度小。
缺点:代码稍复杂,volatile 有轻微性能开销。
适用:实例创建成本高、不确定是否会被使用。
为什么需要 volatile?因为instance = new Singleton()不是原子操作,JVM 可能先分配内存、再赋值引用、最后执行构造函数(指令重排)。不加 volatile,其他线程可能拿到一个"半初始化"的实例。
3.3 静态内部类(推荐的懒加载方案)
publicclassSingleton{privateSingleton(){}// 内部类在第一次被引用时才加载(JVM 保证线程安全)privatestaticclassHolder{privatestaticfinalSingletonINSTANCE=newSingleton();}publicstaticSingletongetInstance(){returnHolder.INSTANCE;}}优点:懒加载 + 线程安全 + 无同步开销 + 代码简洁。
缺点:无法传参初始化。
适用:大多数需要懒加载的场景。这是实际项目中最推荐的写法。
3.4 枚举单例(最安全的写法)
publicenumSingleton{INSTANCE;privatefinalAtomicLongcounter=newAtomicLong(0);publiclongnextId(){returncounter.incrementAndGet();}}// 使用longid=Singleton.INSTANCE.nextId();优点:天然防反射、防序列化破坏、代码极简、线程安全。
缺点:不能继承其他类(枚举隐式 extends Enum)、无法懒加载。
适用:对安全性要求极高、防止反射攻击的场景。Effective Java 推荐的写法。
3.5 五种写法对比
| 写法 | 线程安全 | 懒加载 | 防反射 | 防序列化 | 代码复杂度 |
|---|---|---|---|---|---|
| 饿汉式 | ✓ | ✗ | ✗ | ✗ | 低 |
| DCL | ✓ | ✓ | ✗ | ✗ | 中 |
| 静态内部类 | ✓ | ✓ | ✗ | ✗ | 低 |
| 枚举 | ✓ | ✗ | ✓ | ✓ | 最低 |
| 容器管理 (Spring) | ✓ | ✓ | N/A | N/A | 零(框架做) |
四、真实应用场景
4.1 框架级应用
Spring IoC 容器:Spring Bean 默认scope=singleton。整个容器中同一个 BeanDefinition 只有一个实例。这不是 GoF 单例(不是类级别唯一),而是容器级别唯一——但核心思想一致。
Runtime.getRuntime():JDK 标准库中的经典饿汉单例。
Slf4j LoggerFactory:每个类获取的 Logger 实例在内部是缓存的,同一个 name 返回同一个实例。
4.2 业务级应用
| 业务场景 | 单例对象 | 为什么用单例 |
|---|---|---|
| 数据库连接池 | HikariDataSource | 多份连接池浪费连接资源 |
| 分布式 ID 生成 | Snowflake Worker | 全局唯一 workerId 保证不重复 |
| 配置中心客户端 | NacosConfigManager | 只需一份缓存,变更统一监听 |
| 本地缓存 | Caffeine Cache | 缓存命中率依赖数据集中在一处 |
| 限流器 | RateLimiter | 全局统一计数才能准确限流 |
| 线程池 | ThreadPoolExecutor | 多份线程池破坏全局资源控制 |
4.3 iPaaS 场景中的典型单例
在流程引擎类项目中,以下组件适合用单例:
- FlowOrchestrator(流程编排器):编排逻辑无状态,全局一个实例即可
- InterruptSignalCache(中断信号缓存):全局 Guava Cache,所有流程共享
- ExecutionMetrics(执行指标收集):全局计数器,汇总后推送到监控系统
- SnowflakeIdGenerator(ID 生成器):基于 workerId 的全局唯一实例
五、常见变种
5.1 多例模式(Multiton)
有时不是"全局只要一个",而是"某个 key 对应一个"。比如按租户 ID 隔离的缓存实例:
publicclassTenantCache{privatestaticfinalMap<String,TenantCache>INSTANCES=newConcurrentHashMap<>();privateTenantCache(StringtenantId){// 初始化该租户的缓存}publicstaticTenantCachegetInstance(StringtenantId){returnINSTANCES.computeIfAbsent(tenantId,TenantCache::new);}}5.2 可销毁单例
某些场景下(热加载、测试隔离)需要销毁后重建单例:
publicclassReloadableSingleton{privatestaticvolatileReloadableSingletoninstance;publicstaticvoiddestroy(){instance=null;// 销毁}publicstaticReloadableSingletongetInstance(){if(instance==null){synchronized(ReloadableSingleton.class){if(instance==null){instance=newReloadableSingleton();}}}returninstance;}}5.3 线程级单例(ThreadLocal)
全局唯一不是诉求,线程内唯一才是:
publicclassThreadLocalSingleton{privatestaticfinalThreadLocal<ThreadLocalSingleton>INSTANCE=ThreadLocal.withInitial(ThreadLocalSingleton::new);publicstaticThreadLocalSingletongetInstance(){returnINSTANCE.get();}}典型场景:JDBC Connection(线程内复用,线程间隔离)、RequestContext。
六、优缺点
| 优点 | 缺点 |
|---|---|
| 全局唯一,避免重复创建 | 隐藏了类之间的依赖关系 |
| 共享资源的统一管控 | 对单元测试不友好(全局状态难 mock) |
| 延迟初始化节省资源 | 违反单一职责(既管创建又管业务) |
| 提供全局访问点 | 多线程场景容易踩坑 |
七、避坑指南
坑 1:反射攻击破坏单例
// 恶意代码通过反射绕过私有构造Constructor<Singleton>c=Singleton.class.getDeclaredConstructor();c.setAccessible(true);Singletonanother=c.newInstance();// 第二个实例!防御:在构造函数里加校验:
privateSingleton(){if(INSTANCE!=null){thrownewIllegalStateException("Singleton already initialized");}}或者直接用枚举单例(JVM 禁止反射创建枚举实例)。
坑 2:序列化/反序列化破坏单例
实现了 Serializable 的单例,反序列化时会创建新实例。
防御:添加readResolve()方法:
privateObjectreadResolve(){returnINSTANCE;// 反序列化时返回已有实例}坑 3:Spring 中误用 prototype scope
Spring Bean 默认是 singleton,但如果一个 singleton Bean 注入了一个 prototype Bean,prototype 不会每次都新建——因为注入只发生一次。
防御:用@Lookup注解或ObjectFactory<T>来获取 prototype Bean。
坑 4:单例持有可变状态导致线程安全问题
单例本身是安全的,但如果它持有可变状态(如 HashMap),多线程并发读写会出问题。
防御:单例的内部字段要么不可变(final),要么用线程安全容器(ConcurrentHashMap、AtomicLong)。
坑 5:类加载器隔离导致"多个单例"
在 Tomcat 等容器中,不同 ClassLoader 会各自加载一份类——导致看似是单例,实际有多个实例。
防御:确保单例类在 parent ClassLoader 中加载,或者用容器提供的单例管理机制。
八、常见问题(FAQ)
Q:Spring 的 Bean 是单例模式吗?
A:Spring 的 singleton scope 是容器级别的唯一(每个 ApplicationContext 维护一份),不是 GoF 意义上的类级别唯一。一个类在多个 ApplicationContext 中可以有多个实例。但在业务代码中效果等同于单例,因为通常只有一个容器。
Q:单例和静态类(工具类)有什么区别?
A:静态类不能实现接口、不能被 mock、不能被 Spring 管理、不能做延迟初始化。单例是一个"对象",可以实现接口、可以被注入、可以多态。如果组件需要面向接口编程或被测试框架 mock,用单例;如果纯粹是无状态的工具方法,用静态类。
Q:微服务时代还需要单例吗?
A:需要。微服务让进程级别的"全局"范围变小了(从整个系统缩小到单个服务内),但单个服务内依然有"全局唯一"的诉求——连接池、缓存、配置客户端、ID 生成器。单例的适用范围从不跨进程边界。
Q:什么情况下不应该用单例?
A:① 对象持有大量请求级别的状态(应该每次 new);② 对象需要在测试中被频繁替换(应该用依赖注入);③ 对象的生命周期比进程短(如用户会话级对象)。
Q:DCL 中 volatile 能不能省略?
A:不能。省略 volatile 会导致指令重排序问题——线程 A 可能观察到 instance 非 null,但实例还未完成构造函数的初始化。这在高并发下是真实的 bug,JDK 5+ 的 volatile 语义才修复了这个问题。
九、小结
单例模式是最简单的设计模式,也是最容易用错的。核心记住三点:
- 优先用静态内部类或枚举,不要写 DCL 除非有充分理由
- Spring 项目里直接用 @Component + @Autowired,让框架管单例
- 单例内部状态必须线程安全——这是 90% 单例 bug 的来源
下一篇我们聊工厂模式——当对象创建变得复杂时,如何把"创建逻辑"从业务代码中解耦出来。
标签:#设计模式 #单例模式 #Singleton #Java #Spring #线程安全 #DCL #volatile #枚举单例 #创建型模式 #软件工程 #面向对象
