设计模式实战解读(一):单例模式——全局唯一实例的正确打开方式

本文是「设计模式实战解读」系列第一篇。系列文章统一按照 定义 → 痛点场景 → 模式结构 → 核心实现 → 真实应用 → 常见变种 → 优缺点 → 避坑指南 → FAQ 的结构展开,每篇聚焦一个模式讲透。


一句话定义

单例模式(Singleton):确保一个类只有一个实例,并提供一个全局访问点。

归属:创建型模式。


一、没有单例时的痛点

假设你正在做一个配置管理模块,系统启动时需要从 Nacos/Apollo 加载配置并缓存到内存里:

java 复制代码
// 问题代码:每次需要配置时都 new 一个
ConfigManager configA = new ConfigManager(); // 加载一次远程配置
ConfigManager configB = new ConfigManager(); // 又加载一次远程配置

// configA 和 configB 是两个独立实例
// 1. 重复加载浪费网络 IO
// 2. 两份缓存不一致(A 修改了配置,B 看不到)
// 3. 如果配置里有状态(如 version),两份实例会分裂

类似的痛点还出现在:数据库连接池、线程池管理器、日志打印器、ID 生成器------这些组件如果被 new 多份,要么浪费资源,要么产生不一致的行为。

核心诉求:全局只需要一份,任何地方拿到的都是同一个。


二、模式结构

复制代码
┌──────────────────────────────┐
│         Singleton            │
├──────────────────────────────┤
│ - instance: Singleton        │  ← 唯一实例(静态字段)
├──────────────────────────────┤
│ - Singleton()                │  ← 私有构造(禁止外部 new)
│ + getInstance(): Singleton   │  ← 全局访问点
│ + businessMethod()           │  ← 业务方法
└──────────────────────────────┘

三要素:

  1. 私有构造函数 ------禁止外部 new
  2. 静态实例字段------类级别持有唯一实例
  3. 公开静态方法------全局获取入口

三、核心实现(五种写法对比)

3.1 饿汉式(推荐在大多数场景使用)

java 复制代码
public class Singleton {
    // 类加载时就创建实例(JVM 保证线程安全)
    private static final Singleton INSTANCE = new Singleton();

    private Singleton() {}

    public static Singleton getInstance() {
        return INSTANCE;
    }
}

优点 :实现简单,线程安全,无同步开销。
缺点 :类加载时就创建,如果实例很重且未必被使用,造成浪费。
适用:实例创建成本低、确定会被使用的场景。

3.2 懒汉式 + 双重检查锁(DCL)

java 复制代码
public class Singleton {
    // volatile 防止指令重排序
    private static volatile Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {                  // 第一次检查(无锁)
            synchronized (Singleton.class) {     // 加锁
                if (instance == null) {          // 第二次检查
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

优点 :懒加载 + 线程安全 + 锁粒度小。
缺点 :代码稍复杂,volatile 有轻微性能开销。
适用:实例创建成本高、不确定是否会被使用。

为什么需要 volatile? 因为 instance = new Singleton() 不是原子操作,JVM 可能先分配内存、再赋值引用、最后执行构造函数(指令重排)。不加 volatile,其他线程可能拿到一个"半初始化"的实例。

3.3 静态内部类(推荐的懒加载方案)

java 复制代码
public class Singleton {
    private Singleton() {}

    // 内部类在第一次被引用时才加载(JVM 保证线程安全)
    private static class Holder {
        private static final Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return Holder.INSTANCE;
    }
}

优点 :懒加载 + 线程安全 + 无同步开销 + 代码简洁。
缺点 :无法传参初始化。
适用 :大多数需要懒加载的场景。这是实际项目中最推荐的写法。

3.4 枚举单例(最安全的写法)

java 复制代码
public enum Singleton {
    INSTANCE;

    private final AtomicLong counter = new AtomicLong(0);

    public long nextId() {
        return counter.incrementAndGet();
    }
}

// 使用
long id = 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 隔离的缓存实例:

java 复制代码
public class TenantCache {
    private static final Map<String, TenantCache> INSTANCES = new ConcurrentHashMap<>();

    private TenantCache(String tenantId) {
        // 初始化该租户的缓存
    }

    public static TenantCache getInstance(String tenantId) {
        return INSTANCES.computeIfAbsent(tenantId, TenantCache::new);
    }
}

5.2 可销毁单例

某些场景下(热加载、测试隔离)需要销毁后重建单例:

java 复制代码
public class ReloadableSingleton {
    private static volatile ReloadableSingleton instance;

    public static void destroy() {
        instance = null; // 销毁
    }

    public static ReloadableSingleton getInstance() {
        if (instance == null) {
            synchronized (ReloadableSingleton.class) {
                if (instance == null) {
                    instance = new ReloadableSingleton();
                }
            }
        }
        return instance;
    }
}

5.3 线程级单例(ThreadLocal)

全局唯一不是诉求,线程内唯一才是:

java 复制代码
public class ThreadLocalSingleton {
    private static final ThreadLocal<ThreadLocalSingleton> INSTANCE =
        ThreadLocal.withInitial(ThreadLocalSingleton::new);

    public static ThreadLocalSingleton getInstance() {
        return INSTANCE.get();
    }
}

典型场景:JDBC Connection(线程内复用,线程间隔离)、RequestContext。


六、优缺点

优点 缺点
全局唯一,避免重复创建 隐藏了类之间的依赖关系
共享资源的统一管控 对单元测试不友好(全局状态难 mock)
延迟初始化节省资源 违反单一职责(既管创建又管业务)
提供全局访问点 多线程场景容易踩坑

七、避坑指南

坑 1:反射攻击破坏单例

java 复制代码
// 恶意代码通过反射绕过私有构造
Constructor<Singleton> c = Singleton.class.getDeclaredConstructor();
c.setAccessible(true);
Singleton another = c.newInstance(); // 第二个实例!

防御:在构造函数里加校验:

java 复制代码
private Singleton() {
    if (INSTANCE != null) {
        throw new IllegalStateException("Singleton already initialized");
    }
}

或者直接用枚举单例(JVM 禁止反射创建枚举实例)。

坑 2:序列化/反序列化破坏单例

实现了 Serializable 的单例,反序列化时会创建新实例。

防御 :添加 readResolve() 方法:

java 复制代码
private Object readResolve() {
    return INSTANCE; // 反序列化时返回已有实例
}

坑 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 语义才修复了这个问题。


九、小结

单例模式是最简单的设计模式,也是最容易用错的。核心记住三点:

  1. 优先用静态内部类或枚举,不要写 DCL 除非有充分理由
  2. Spring 项目里直接用 @Component + @Autowired,让框架管单例
  3. 单例内部状态必须线程安全------这是 90% 单例 bug 的来源

下一篇我们聊工厂模式------当对象创建变得复杂时,如何把"创建逻辑"从业务代码中解耦出来。


标签:#设计模式 #单例模式 #Singleton #Java #Spring #线程安全 #DCL #volatile #枚举单例 #创建型模式 #软件工程 #面向对象

相关推荐
老码观察4 小时前
设计模式实战解读(二):工厂模式——对象创建的解耦艺术
设计模式·log4j
mingshili4 小时前
[Python] Python中自带模块级的单例模式-不需要定义单例类
python·单例模式
闪电麦坤956 小时前
从第一性原理理解单例模式
单例模式
看山是山_Lau6 小时前
原型模式:当复制比重新创建更高效时
设计模式·原型模式
用户356302904876 小时前
【设计模式】观察者模式——事件通知机制
设计模式
追烽少年x7 小时前
STL中的设计模式(二)
c++·设计模式
悟05157 小时前
设计模式-模板模式
设计模式
BLSxiaopanlaile7 小时前
有关创建型的几个设计模式总结
设计模式
蜡笔小马8 小时前
14.C++设计模式-状态模式
c++·设计模式·状态模式