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仓库


原始标题:Drawbacks of the Singleton Design Pattern