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 多行,每次改代码都像在雷区里跳舞。后来我们重构时发现,其实只有数据库连接池需要单例,其他完全可以拆开。
三、为什么很多人还是忍不住用单例?
原因其实很现实:方便。全局访问点意味着你不用到处传参,不用考虑对象生命周期。尤其是做工具类、配置类、日志类的时候,单例确实省事。
但方便不等于正确。我个人的习惯是:先问自己三个问题,再决定要不要用单例。
- 这个类真的只需要一个实例吗? 还是说「目前只需要一个」?
- 全局访问点会不会让依赖关系变得隐晦? 调用方直接
Xxx.getInstance(),测试时很难 mock。 - 这个类的职责是否已经过重? 如果它既管自己创建,又管业务逻辑,就该拆。
一个小技巧:如果你发现某个单例类里出现了两个以上的「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 图总结了单例模式与单一职责原则的关系,以及合理使用的决策路径。
七、总结一下
单例模式本身没有错,错的是不分场合地乱用。我见过最离谱的代码,一个单例类里塞了十几个静态方法,美其名曰「工具类」,实际上每个方法都依赖不同的外部资源——测试时根本 mock 不了。
记住三点:
- 单例违反 SRP 是常态,但可以通过分离 Holder 类来缓解
- 资源管理类、无状态工具类、只读配置 是单例的合理归宿
- 如果拿不准,先用依赖注入(比如 Spring 的 @Singleton),让框架帮你管理生命周期
嗯,今天就聊到这儿。单例模式看着简单,但用好了是利器,用不好就是技术债。希望你能从今天的讨论里带走一些实用的判断标准。