1. 概述
单例模式是1994年由"四人帮"提出的创建型设计模式之一。
因其实现简单,我们容易过度使用它。如今,它已被视为一种反模式。在代码中引入单例前,我们应先思考:是否真的需要它提供的功能?
本文将探讨单例模式的主要缺点,并介绍几种替代方案。
2. 代码示例
首先创建一个用于演示的类:
public class Logger {
private static Logger instance;
private PrintWriter fileWriter;
public static Logger getInstance() {
if (instance == null) {
instance = new Logger();
}
return instance;
}
private Logger() {
try {
fileWriter = new PrintWriter(new FileWriter("app.log", true));
} catch (IOException e) {
e.printStackTrace();
}
}
public void log(String message) {
String log = String.format("[%s]- %s", LocalDateTime.now(), message);
fileWriter.println(log);
fileWriter.flush();
}
}
这个类实现了简单的文件日志功能,采用懒加载方式实现单例。
3. 单例模式的缺点
根据定义,单例模式确保一个类只有一个实例,并提供全局访问点。因此,只有当同时满足这两个需求时才应使用它。
**从定义就能看出,它违反了单一职责原则**。该原则要求一个类只承担一种职责。
但单例模式至少承担了两种职责:确保唯一实例 + 包含业务逻辑。
下面我们继续探讨其他陷阱:
3.1. 全局状态
众所周知,全局状态是糟糕的实践,应尽量避免。
单例本质上就是在代码中引入全局变量,只是被封装在类里而已。
既然是全局的,任何地方都能访问和修改它们。 如果它们不是不可变的,问题会更严重。
假设我们在代码多处使用Logger
类。任何地方都能访问并修改它的值。
当某个使用它的方法出问题时,如果问题根源在单例本身,我们就得检查整个代码库中所有使用它的方法——这很快会成为性能瓶颈。
3.2. 代码灵活性
软件开发中唯一确定的是:代码总会变化。
项目初期,我们可能假设某些类永远只需要一个实例,于是用单例实现。
但如果需求变化,这个假设被推翻,重构成本会非常高。
以我们的Logger
为例:最初假设只需一个日志文件。未来若需要分离错误日志和普通日志呢?单例显然不够用了。
单例会让代码紧密耦合,降低灵活性。
3.3. 隐藏依赖
单例会隐藏类的依赖关系。
换句话说:当其他类使用单例时,我们看不出这些类依赖单例实例。
看这个sum()
方法:
public static int sum(int a, int b){
Logger logger = Logger.getInstance();
logger.log("A simple message");
return a + b;
}
不查看实现细节,我们根本不知道sum()
依赖Logger
类——依赖没有通过构造器或方法参数显式传递。
3.4. 多线程问题
多线程环境下,单例实现容易踩坑。
核心问题是:全局变量对所有线程可见,而线程间互不知晓彼此的操作。
这可能导致竞态条件等同步问题。
我们之前的Logger
实现在多线程环境下不安全:多个线程可能同时调用getInstance()
,创建多个实例。
用synchronized
修复:
public static Logger getInstance() {
synchronized (Logger.class) {
if (instance == null) {
instance = new Logger();
}
}
return instance;
}
但同步操作开销大。更优方案是双重检查锁定:
private static volatile Logger instance;
public static Logger getInstance() {
if (instance == null) {
synchronized (Logger.class) {
if (instance == null) {
instance = new Logger();
}
}
}
return instance;
}
⚠️ 注意:JVM可能访问部分构造的对象,导致意外行为,所以必须用volatile
修饰instance
。
其他替代方案:
- 饿汉式初始化(非懒加载)
- 枚举单例
- Bill Pugh单例模式
3.5. 测试困难
单例会给单元测试带来麻烦。
单元测试应隔离被测代码,不应依赖可能失败的外部服务。
测试sum()
方法:
@Test
void givenTwoValues_whenSum_thenReturnCorrectResult() {
SingletonDemo singletonDemo = new SingletonDemo();
int result = singletonDemo.sum(12, 4);
assertEquals(16, result);
}
虽然测试通过,但它创建了日志文件。如果Logger
有问题,测试会失败——这违反了单元测试的隔离原则。
若要避免日志记录,可用Mockito模拟静态方法:
@Test
void givenMockedLogger_whenSum_thenReturnCorrectResult() {
Logger logger = mock(Logger.class);
try (MockedStatic<Logger> loggerMockedStatic = mockStatic(Logger.class)) {
loggerMockedStatic.when(Logger::getInstance).thenReturn(logger);
doNothing().when(logger).log(any());
SingletonDemo singletonDemo = new SingletonDemo();
int result = singletonDemo.sum(12, 4);
Assertions.assertEquals(16, result);
}
}
4. 单例的替代方案
✅ 当需要单一实例时,依赖注入是更好的选择:创建一个实例,在需要的地方作为参数传入。
这样能明确暴露依赖关系,未来需要多实例时也更容易扩展。
对于长生命周期对象,还可以使用工厂模式。
5. 总结
本文分析了单例模式的主要缺点:
- 违反单一职责原则
- 引入全局状态
- 降低代码灵活性
- 隐藏依赖关系
- 多线程实现复杂
- 测试困难
单例模式应谨慎使用,过度使用会带来不必要的限制。大多数情况下,依赖注入是更优雅的解决方案。
所有示例代码见GitHub仓库。