16、单例模式与设计原则:单例模式如何违反单一职责、如何合理使用单例

聊到单例模式,很多人的第一反应是「这玩意儿我熟」。确实,单例是 GoF 23 种模式里最简单、最常用的之一。但我在项目中见过太多人把它用成了「万能工具」,结果反而把代码搞得很拧巴。

今天咱们就掰开揉碎,聊聊单例模式到底怎么「得罪」了单一职责原则,以及什么时候用它才是明智之举。

一、先回顾一下:单例模式到底在干嘛?

单例模式的核心诉求很简单:保证一个类只有一个实例,并提供一个全局访问点。说白了,就是「这个类你只能 new 一次,之后谁要都给你同一个对象」。

典型的实现长这样:

public class ConfigManager {
    private static ConfigManager instance;
    
    private ConfigManager() {
        // 私有构造,防止外部 new
    }
    
    public static ConfigManager getInstance() {
        if (instance == null) {
            instance = new ConfigManager();
        }
        return instance;
    }
    
    public String getConfig(String key) {
        // 读取配置
    }
}

嗯,代码很简洁。但问题就藏在这份简洁里。

二、单例模式如何违反单一职责原则?

单一职责原则(SRP)说:一个类应该只有一个引起它变化的原因。翻译成人话就是:一个类只干一件事。

但你看上面的 ConfigManager,它干了多少事?

  • 职责一:管理配置数据(读取、缓存、提供配置项)
  • 职责二:控制自身实例化逻辑(判断是否已创建、线程安全、延迟加载)
  • 职责三:提供全局访问点(静态方法、可能还要处理并发)

一个类扛了三份活。你想想看,如果哪天你想把配置管理改成从数据库读取而不是文件,你得改 ConfigManager。如果哪天你想把单例改成多例(比如支持多租户),你还得改 ConfigManager。两个完全不相关的需求,都指向同一个类——这就是典型的 SRP 被破坏。

核心矛盾:单例模式把「对象管理」和「业务逻辑」强行绑在了一起。你本来只想写一个配置管理器,结果还得操心它是不是被 new 了两次。

我在一个老项目中见过更极端的例子:一个单例类里混了日志、缓存、数据库连接池、还有业务规则引擎。那个类有 3000 多行,每次改代码都像在雷区里跳舞。后来我们重构时发现,其实只有数据库连接池需要单例,其他完全可以拆开。

三、为什么很多人还是忍不住用单例?

原因其实很现实:方便。全局访问点意味着你不用到处传参,不用考虑对象生命周期。尤其是做工具类、配置类、日志类的时候,单例确实省事。

但方便不等于正确。我个人的习惯是:先问自己三个问题,再决定要不要用单例。

  1. 这个类真的只需要一个实例吗? 还是说「目前只需要一个」?
  2. 全局访问点会不会让依赖关系变得隐晦? 调用方直接 Xxx.getInstance(),测试时很难 mock。
  3. 这个类的职责是否已经过重? 如果它既管自己创建,又管业务逻辑,就该拆。

一个小技巧:如果你发现某个单例类里出现了两个以上的「public 方法组」(比如一组是配置读取,一组是实例管理),那大概率已经违反了 SRP。

四、合理使用单例的场景

说了这么多问题,单例是不是就该被抛弃?当然不是。它只是被滥用了。下面这几个场景,我个人认为单例是合理的选择。

1. 资源管理类

比如数据库连接池、线程池。这些资源创建成本高,且复用比新建更划算。一个连接池实例就够了,多了反而浪费。

public class DatabasePool {
    private static final DatabasePool INSTANCE = new DatabasePool();
    
    private DatabasePool() {
        // 初始化连接池,很重
    }
    
    public static DatabasePool getInstance() {
        return INSTANCE;
    }
    
    public Connection getConnection() {
        // 从池中获取
    }
}

这里注意:我用了饿汉式(类加载时就创建)。因为连接池初始化很重,延迟加载反而可能让第一次请求变慢。而且饿汉式天然线程安全,省心。

2. 无状态工具类

比如日志记录器、ID 生成器。这些类本身不持有用户状态,只是提供一组方法。单例可以避免重复创建对象。

注意:如果工具类内部有可变状态(比如计数器),一定要处理好线程安全。我曾经在 ID 生成器里用 AtomicLong 踩过坑——单例 + 并发 = 必须加锁或使用原子类。

3. 配置/环境信息

系统启动时加载一次,之后只读。比如应用配置、环境变量。这类数据全局唯一,且不会变化,单例很合适。

五、如何让单例不那么「违反 SRP」?

如果你非要用单例,又想保持代码干净,可以试试分离职责。把「实例管理」和「业务逻辑」拆到两个类里。

举个例子:

// 只负责业务逻辑
public class ConfigService {
    private final ConfigLoader loader;
    
    public ConfigService(ConfigLoader loader) {
        this.loader = loader;
    }
    
    public String get(String key) {
        return loader.load(key);
    }
}

// 只负责单例管理
public class ConfigServiceHolder {
    private static ConfigService instance;
    
    public static synchronized ConfigService getInstance() {
        if (instance == null) {
            instance = new ConfigService(new FileConfigLoader());
        }
        return instance;
    }
}

这样,ConfigService 只关心配置读取,ConfigServiceHolder 只关心实例管理。测试时你可以直接 new ConfigService 并传入 mock 的 ConfigLoader,完全不受单例影响。

核心思路:让单例变成一个「胶水层」,而不是业务类本身。业务类保持普通类的样子,单例只负责「怎么拿到这个类的一个实例」。

六、一张图看懂本章核心

下面这张 SVG 图总结了单例模式与单一职责原则的关系,以及合理使用的决策路径。

单例模式与单一职责原则 ❌ 违反 SRP 的写法 ConfigManager 同时承担: • 配置数据读取与缓存(业务逻辑) • 实例创建与生命周期管理(对象管理) • 全局访问点提供(静态方法) ✅ 合理使用的写法 分离为两个类: • ConfigService:只负责配置读取 • ConfigServiceHolder:只负责单例管理 • 测试时可直接 new ConfigService 合理使用单例的决策路径 1. 这个类是否真的全局唯一? → 是:继续;否:不要用单例 2. 创建成本是否很高? → 是:考虑饿汉式或双重检查锁;否:考虑依赖注入 3. 是否无状态或只读? → 是:单例安全;否:注意线程安全 4. 是否方便测试? → 否:考虑用 Holder 模式分离职责

七、总结一下

单例模式本身没有错,错的是不分场合地乱用。我见过最离谱的代码,一个单例类里塞了十几个静态方法,美其名曰「工具类」,实际上每个方法都依赖不同的外部资源——测试时根本 mock 不了。

记住三点:

  • 单例违反 SRP 是常态,但可以通过分离 Holder 类来缓解
  • 资源管理类、无状态工具类、只读配置 是单例的合理归宿
  • 如果拿不准,先用依赖注入(比如 Spring 的 @Singleton),让框架帮你管理生命周期

嗯,今天就聊到这儿。单例模式看着简单,但用好了是利器,用不好就是技术债。希望你能从今天的讨论里带走一些实用的判断标准。


公众号:蓝海资料掘金营,微信 deep3321