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