创建型模式(上)——单例、工厂方法与抽象工厂

摘要

创建型设计模式(Creational Patterns)解决的是对象如何被创建的问题。表面上看,new 一个对象是最简单的操作,但在复杂系统中,直接使用 new 会引发一系列深层问题:对象的创建逻辑散落在代码各处难以统一维护、高层模块被迫依赖具体实现类(违反 DIP)、需要控制对象数量时没有统一入口、复杂对象的构建过程污染调用方代码。创建型模式的核心价值就是将对象的创建过程与使用过程解耦。本文深入剖析三个最重要的创建型模式:单例模式——为什么最简单的单例实现是错的,双检锁(DCL)的内存可见性陷阱与 volatile 的必要性,以及为什么枚举是 Java 中实现单例的最优解;工厂方法模式——如何通过”让子类决定实例化哪个类”来实现 OCP,以及它与简单工厂的本质区别;抽象工厂模式——当产品族(一组相互关联的产品)需要一起切换时,如何设计接口层来屏蔽”系列产品”的切换代价。每个模式都用真实场景的反例驱动,解释”不用这个模式会痛在哪里”。


第 1 章 为什么对象创建是个问题

1.1 直接 new 的代价

在面向对象编程中,new 关键字是创建对象最直接的方式。对于简单的小程序,直接 new 完全没有问题。但在大型系统中,无节制地使用 new 会带来三类结构性问题:

问题一:创建逻辑的散布

假设系统中有 50 处地方需要创建 HttpClient 对象,每处都写了:

HttpClient client = new HttpClient();
client.setConnectTimeout(3000);
client.setReadTimeout(5000);
client.setMaxRetries(3);

当运维要求将超时从 3 秒改为 5 秒时,你需要找到这 50 处并逐一修改——而且还可能有些地方用了不同的配置,导致系统行为不一致。

问题二:高层依赖低层实现(违反 DIP)

// UserService 直接 new 了 MySQLUserRepository
public class UserService {
    private UserRepository repo = new MySQLUserRepository();  // 强耦合到 MySQL
}

这违反了 DIP:高层模块 UserService 不应该知道底层的具体实现是 MySQL 还是 MongoDB。

问题三:无法控制对象的生命周期和数量

某些对象(数据库连接池、线程池、配置管理器)在整个系统中只应存在一个实例,但直接 new 无法防止误用者创建多个实例。

1.2 创建型模式的共同主题

GoF(Gang of Four)将创建型模式定义为:将系统与其对象的创建、组合和表示方式相分离的模式。它们共同实现了两个关键目标:

  • 封装具体类的知识:系统只知道抽象类型,不知道具体的实现类;
  • 隐藏实例的创建和组合方式:调用方只知道”我要一个什么东西”,不知道”这个东西是怎么被创建出来的”。

第 2 章 单例模式(Singleton)

2.1 动机:为什么需要单例

单例模式(Singleton Pattern)解决的问题是:确保一个类只有一个实例,并提供一个全局访问点

哪些对象需要单例?判断标准是:如果创建多个实例会导致资源浪费、状态不一致或行为错误,就需要单例。典型案例:

  • 数据库连接池HikariCPDruid):连接池维护着一组数据库连接,创建两个独立的连接池意味着系统同时持有两倍数量的连接,浪费资源,且两个池互不感知,无法做全局限流;
  • 线程池ExecutorService):类似连接池,多个线程池无法做全局任务调度和资源控制;
  • 配置管理器:系统配置应该有唯一的读取源,如果不同模块持有不同的 ConfigManager 实例,可能因各自加载了不同版本的配置文件而行为不一致;
  • 日志记录器:日志框架(Log4j、Logback)的 LoggerFactory 就是典型的单例工厂,确保所有地方记录的日志汇聚到同一个目标。

2.2 最简单实现的致命缺陷

最直觉的单例实现是”懒汉式”:

// 懒汉式:第一次使用时才创建实例
public class ConfigManager {
    private static ConfigManager instance;  // 静态唯一实例
    
    private ConfigManager() {
        // 私有构造器:防止外部 new
        loadConfig();  // 读取配置文件(耗时操作)
    }
    
    public static ConfigManager getInstance() {
        if (instance == null) {          // ① 检查实例是否存在
            instance = new ConfigManager(); // ② 不存在则创建
        }
        return instance;
    }
}

这个实现在单线程环境下完全正确。但在多线程环境下,它有致命缺陷:

线程 A 执行到 ① 处,判断 instance == null(为 true),准备执行 ②;此时线程 B 也执行到 ①,判断 instance == null(仍然为 true,因为线程 A 还没执行完 ②);两个线程都认为 instance 不存在,各自创建了一个实例,单例被破坏

2.3 同步方法:正确但低效

最直接的修复是加 synchronized

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

这是线程安全的,但有性能问题:getInstance() 是一个极高频调用的方法(几乎每次需要配置的地方都会调用),而 synchronized 会在每次调用时获取类锁,即使 instance 已经创建完毕,依然需要同步——这是不必要的性能开销。

在高并发系统中,这个锁会成为热点竞争点,严重影响吞吐量。

2.4 双重检锁(DCL):精妙设计与内存模型陷阱

双重检锁(Double-Checked Locking,DCL)试图解决上述性能问题:只在 instance 为 null 时才加锁,一旦实例创建完毕,后续调用无需同步:

public class ConfigManager {
    // 注意:必须加 volatile!这是理解 DCL 的核心
    private static volatile ConfigManager instance;
    
    public static ConfigManager getInstance() {
        if (instance == null) {              // ① 第一次检查(无锁,快速路径)
            synchronized (ConfigManager.class) {
                if (instance == null) {      // ② 第二次检查(有锁,防止重复创建)
                    instance = new ConfigManager();  // ③ 创建实例
                }
            }
        }
        return instance;
    }
}

为什么需要两次 null 检查?

  • 第一次检查(无锁):快速路径,一旦 instance 已创建,直接返回,无需加锁;
  • 第二次检查(有锁):线程 A 和 B 都通过了第一次检查,都在等待锁。A 获得锁,创建了实例,释放锁。B 获得锁后再次检查,发现实例已存在,跳过创建——防止重复创建。

为什么 volatile 是必须的?这是 DCL 最精妙也最容易被忽略的细节。

instance = new ConfigManager() 这行代码在 JVM 层面不是原子操作,它分为三步:

  1. 在堆上分配内存;
  2. 调用 ConfigManager 的构造方法,初始化对象;
  3. instance 引用指向分配的内存地址。

由于 JVM 的指令重排序优化,步骤 2 和 3 可能被重排为:先执行步骤 3(instance 指向了尚未初始化的内存),再执行步骤 2(初始化对象)。

在这个重排序下的多线程场景:

  • 线程 A 执行到步骤 3:instance 已经非 null(指向了内存地址),但对象还未初始化;
  • 线程 B 执行第一次检查:instance != null,直接返回——返回了一个未初始化的对象

volatile 关键字在这里的作用是禁止指令重排序,确保对象完全初始化后,instance 的引用才对其他线程可见。这是 Java 内存模型(JMM)中 volatile 的 happens-before 保证。

DCL 的常见错误

忘记 volatile 是 DCL 最常见的错误。在 Java 5 之前,volatile 的语义不足以保证 DCL 的正确性(JMM 在 Java 5 中通过 JSR-133 加强)。在 Java 5+ 中,带有 volatile 的 DCL 是正确且高效的。

2.5 静态内部类:利用类加载机制的优雅实现

还有一种更优雅的方案,利用 JVM 的类加载机制保证线程安全:

public class ConfigManager {
    private ConfigManager() {
        loadConfig();
    }
    
    // 静态内部类:JVM 保证类加载是线程安全的
    // 且只在 getInstance() 被调用时才加载 Holder 类(懒加载)
    private static class Holder {
        private static final ConfigManager INSTANCE = new ConfigManager();
    }
    
    public static ConfigManager getInstance() {
        return Holder.INSTANCE;
    }
}

这个实现的精妙之处:

  • 线程安全:JVM 在类加载阶段(<clinit>)有加锁保证,Holder.INSTANCE 的初始化是线程安全的,无需显式同步;
  • 懒加载Holder 类只有在 getInstance() 第一次被调用时才被加载,实现了真正的懒加载;
  • 无性能开销:没有任何锁操作,调用性能与直接字段访问相同。

2.6 枚举单例:Josh Bloch 的推荐

《Effective Java》的作者 Josh Bloch 在第 3 版第 89 条中明确指出:单元素枚举类型是实现 Singleton 的最佳方法

// 枚举单例:仅此一行,完美实现
public enum ConfigManager {
    INSTANCE;  // 唯一实例
    
    private final Properties config = new Properties();
    
    ConfigManager() {
        // 枚举构造器在类加载时由 JVM 调用,线程安全
        try (InputStream is = getClass().getResourceAsStream("/app.properties")) {
            config.load(is);
        } catch (IOException e) {
            throw new RuntimeException("Failed to load config", e);
        }
    }
    
    public String get(String key) {
        return config.getProperty(key);
    }
    
    public String get(String key, String defaultValue) {
        return config.getProperty(key, defaultValue);
    }
}
 
// 使用
String dbUrl = ConfigManager.INSTANCE.get("db.url");

枚举单例的优势:

天然防反射攻击:通过反射调用私有构造器是破坏普通单例的常见手段,而 JVM 禁止通过反射创建枚举实例(java.lang.reflect.Constructor.newInstance() 会抛出 IllegalArgumentException)。

天然防序列化破坏:普通单例实现了 Serializable 接口后,反序列化会创建新实例(破坏单例),需要手动实现 readResolve() 方法来修复。枚举的序列化由 JVM 保证:枚举实例序列化只写出枚举名,反序列化时直接返回已存在的枚举实例,无需额外代码。

代码极简:无需任何同步代码,无需处理线程安全,JVM 保证枚举实例在类加载时被创建且只被创建一次。

2.7 单例模式的适用边界与反例

单例模式最大的争议是:它引入了全局状态,使得代码难以测试

在单元测试中,如果 UserService 依赖了 ConfigManager.INSTANCE,你无法在测试中替换 ConfigManager 为一个 Mock 对象(枚举无法被继承)。这导致 UserService 的测试必须使用真实的配置文件。

因此,单例模式的正确使用场景是:

  • 无状态的工具类(或只有不可变状态):如 Jackson ObjectMapper,它不持有连接资源,配置不变后线程安全;
  • 与框架集成时由框架管理生命周期:Spring 的 Bean 默认就是单例,但通过 IoC 容器管理,可以在测试时注入 Mock;
  • 真正的全局资源:如操作系统接口、硬件接口,本质上只有一个。

单例的反例

将业务 Service(如 UserServiceOrderService)实现为手工单例是常见的过度使用。在 Spring 中,这些 Bean 的单例生命周期由容器管理,且可以通过依赖注入替换,远比手工单例灵活。


第 3 章 工厂方法模式(Factory Method)

3.1 动机:谁来决定创建哪个类

设想一个日志框架的设计。日志可以写到文件(FileLogger)、写到数据库(DatabaseLogger)、写到控制台(ConsoleLogger),甚至写到远程日志服务(RemoteLogger)。

如果在使用日志的业务代码里直接 new FileLogger(),就将业务代码与具体的日志实现绑定在了一起——换一种日志方式需要修改所有业务代码。

工厂方法模式(Factory Method Pattern)解决的问题是:将对象的创建延迟到子类中决定,让一个类的实例化延迟到其子类

3.2 简单工厂:不是模式,但有必要先理解

在引入工厂方法模式之前,先看”简单工厂”——虽然它不是 GoF 23 种模式之一,但在实践中非常常见:

// 简单工厂(Static Factory Method)
public class LoggerFactory {
    public static Logger createLogger(String type) {
        switch (type) {
            case "FILE":     return new FileLogger();
            case "DATABASE": return new DatabaseLogger();
            case "CONSOLE":  return new ConsoleLogger();
            default: throw new IllegalArgumentException("Unknown logger type: " + type);
        }
    }
}
 
// 调用方
Logger logger = LoggerFactory.createLogger("FILE");

简单工厂把创建逻辑集中在一处,解决了”创建逻辑散落”的问题。但它违反了 OCP:每次新增日志类型(如 RemoteLogger),必须修改 LoggerFactoryswitch 语句。

3.3 工厂方法模式的结构

工厂方法模式引入了一层抽象:定义一个创建对象的接口(工厂接口),但让子类决定实例化哪个类。工厂方法的关键词是**“让子类决定”**。

// 抽象产品:所有 Logger 的公共接口
public interface Logger {
    void log(String message);
    void log(String message, Throwable cause);
}
 
// 具体产品一:文件日志
public class FileLogger implements Logger {
    private final String filePath;
    
    public FileLogger(String filePath) {
        this.filePath = filePath;
    }
    
    @Override
    public void log(String message) {
        // 写入文件逻辑
    }
    
    @Override
    public void log(String message, Throwable cause) {
        log(message + "\n" + stackTraceToString(cause));
    }
}
 
// 具体产品二:数据库日志
public class DatabaseLogger implements Logger {
    // ...
}
 
// 抽象创建者:定义工厂方法接口
public abstract class Application {
    // 工厂方法:由子类实现,决定创建哪种 Logger
    protected abstract Logger createLogger();
    
    // 模板方法:使用工厂方法创建的对象
    public void run() {
        Logger logger = createLogger();  // 调用工厂方法(多态)
        logger.log("Application started");
        doWork(logger);
    }
    
    protected abstract void doWork(Logger logger);
}
 
// 具体创建者一:生产环境应用,使用文件日志
public class ProductionApplication extends Application {
    @Override
    protected Logger createLogger() {
        return new FileLogger("/var/log/app.log");
    }
    
    @Override
    protected void doWork(Logger logger) {
        // 生产逻辑
    }
}
 
// 具体创建者二:开发环境应用,使用控制台日志
public class DevelopmentApplication extends Application {
    @Override
    protected Logger createLogger() {
        return new ConsoleLogger();
    }
    
    @Override
    protected void doWork(Logger logger) {
        // 开发调试逻辑
    }
}

3.4 工厂方法与简单工厂的本质区别

维度简单工厂工厂方法
结构一个工厂类,一个 create() 方法抽象工厂接口/抽象类,子类实现具体工厂
扩展方式修改工厂类的 switch(违反 OCP)新增具体工厂子类(遵守 OCP)
创建逻辑集中在工厂类中分散在各个具体工厂子类中
适用场景产品类型固定、扩展不频繁产品类型需要频繁扩展

工厂方法模式将 createLogger() 的实现下放到子类,每次新增日志类型只需新增一个 Application 的子类,不需要修改任何已有代码——严格遵守了 OCP。

3.5 JDK 中工厂方法模式的体现

JDK 标准库中大量使用了工厂方法模式:

java.util.Collection.iterator()Collection 接口定义了 iterator() 工厂方法,具体集合类(ArrayListLinkedListHashSet)各自返回适合自身数据结构的 Iterator 实现(ArrayList 返回 ItrLinkedList 返回 ListItr)。

java.util.concurrent.Executors:这是一个工厂类(更像简单工厂),提供多种线程池的创建方法(newFixedThreadPool()newCachedThreadPool()newScheduledThreadPool()),屏蔽了 ThreadPoolExecutor 的复杂配置。

javax.xml.parsers.DocumentBuilderFactory:通过 newInstance() 返回一个工厂,工厂再通过 newDocumentBuilder() 返回具体的 XML 解析器实现,允许在运行时通过系统属性切换不同的 XML 解析器(Xerces、Saxon 等)。


第 4 章 抽象工厂模式(Abstract Factory)

4.1 动机:产品族的切换问题

工厂方法解决了”创建单一类型产品”的问题。但现实中,还有一类更复杂的场景:一组相互关联的产品需要一起被创建,而且这一组产品需要能整体切换。

经典的例子是跨平台 UI 组件库:

  • Windows 风格:WindowsButtonWindowsCheckboxWindowsScrollbar
  • macOS 风格:MacButtonMacCheckboxMacScrollbar
  • Linux/GTK 风格:GtkButtonGtkCheckboxGtkScrollbar

关键约束:不能混用不同风格的组件——在同一个界面里,Button 是 Windows 风格但 Checkbox 是 macOS 风格,视觉上会不一致。整套组件必须来自同一个”风格族”(产品族)。

如果用工厂方法模式,为 Button、Checkbox、Scrollbar 各定义一个独立的工厂,那么在切换风格时,需要修改三个地方——容易遗漏,且无法保证”必须一致”的约束。

抽象工厂模式(Abstract Factory Pattern)的解决方案是:定义一个工厂接口,它能够创建属于同一产品族的所有产品。切换产品族只需换一个工厂实现。

4.2 抽象工厂模式的结构

// 抽象产品族:Button
public interface Button {
    void render();
    void onClick(Runnable handler);
}
 
// 抽象产品族:Checkbox
public interface Checkbox {
    void render();
    boolean isChecked();
}
 
// 抽象工厂:能创建一整个产品族
public interface UIFactory {
    Button createButton();
    Checkbox createCheckbox();
    // 如果需要更多组件,在这里添加工厂方法
}
 
// 具体工厂一:Windows 风格产品族
public class WindowsUIFactory implements UIFactory {
    @Override
    public Button createButton() {
        return new WindowsButton();  // Windows 风格 Button
    }
    
    @Override
    public Checkbox createCheckbox() {
        return new WindowsCheckbox();  // Windows 风格 Checkbox
    }
}
 
// 具体工厂二:macOS 风格产品族
public class MacUIFactory implements UIFactory {
    @Override
    public Button createButton() {
        return new MacButton();
    }
    
    @Override
    public Checkbox createCheckbox() {
        return new MacCheckbox();
    }
}
 
// 应用层:只依赖抽象工厂和抽象产品,不知道具体是 Windows 还是 Mac
public class Application {
    private final Button button;
    private final Checkbox checkbox;
    
    // 接收一个工厂,创建整套 UI 组件
    public Application(UIFactory factory) {
        this.button = factory.createButton();
        this.checkbox = factory.createCheckbox();
    }
    
    public void render() {
        button.render();
        checkbox.render();
    }
}
 
// 启动时根据操作系统选择工厂(配置在这里变化,而非业务逻辑)
public class Main {
    public static void main(String[] args) {
        UIFactory factory;
        String os = System.getProperty("os.name").toLowerCase();
        if (os.contains("win")) {
            factory = new WindowsUIFactory();
        } else if (os.contains("mac")) {
            factory = new MacUIFactory();
        } else {
            factory = new GtkUIFactory();
        }
        
        Application app = new Application(factory);  // 整套 UI 风格一致
        app.render();
    }
}

4.3 抽象工厂在企业系统中的真实应用

UI 组件的例子虽然直观,但在 Java 企业开发中,抽象工厂的典型应用场景是数据访问层(DAL)的多数据库支持

// 抽象工厂:数据访问对象工厂
public interface DAOFactory {
    UserDAO createUserDAO();
    OrderDAO createOrderDAO();
    ProductDAO createProductDAO();
}
 
// 具体工厂一:MySQL 实现
public class MySQLDAOFactory implements DAOFactory {
    private final DataSource dataSource;  // MySQL 连接池
    
    @Override
    public UserDAO createUserDAO() { return new MySQLUserDAO(dataSource); }
    @Override
    public OrderDAO createOrderDAO() { return new MySQLOrderDAO(dataSource); }
    @Override
    public ProductDAO createProductDAO() { return new MySQLProductDAO(dataSource); }
}
 
// 具体工厂二:MongoDB 实现
public class MongoDAOFactory implements DAOFactory {
    private final MongoClient mongoClient;
    
    @Override
    public UserDAO createUserDAO() { return new MongoUserDAO(mongoClient); }
    @Override
    public OrderDAO createOrderDAO() { return new MongoOrderDAO(mongoClient); }
    @Override
    public ProductDAO createProductDAO() { return new MongoProductDAO(mongoClient); }
}

业务层只依赖 DAOFactory 抽象,从 MySQL 迁移到 MongoDB 只需要在启动时切换工厂实现——业务代码一行不改。

4.4 工厂方法 vs 抽象工厂

维度工厂方法抽象工厂
解决的问题单一产品的创建延迟到子类一族相关产品的整体切换
工厂接口一个创建方法多个创建方法(每种产品一个)
扩展新产品类型新增工厂子类(容易)修改工厂接口(影响所有实现,困难)
扩展新产品族不适用新增工厂实现类(容易)
代码量较少较多(接口 + 多个实现类)

选择原则

  • 只有一种产品需要工厂化 → 工厂方法;
  • 多种相关产品需要整体切换 → 抽象工厂;
  • 如果产品族中的产品类型会频繁新增 → 抽象工厂的接口会频繁改动,谨慎使用,可能工厂方法更合适。

4.5 抽象工厂的扩展困难:固有的设计权衡

抽象工厂有一个固有的设计权衡,值得明确指出:扩展新的产品类型很困难

假设现在要在 UI 工厂中加入 Scrollbar 的创建,需要:

  1. 修改 UIFactory 接口,添加 createScrollbar() 方法;
  2. 修改所有工厂实现类(WindowsUIFactoryMacUIFactoryGtkUIFactory),各自添加实现。

这违反了 OCP——已有的稳定代码(工厂实现类)需要被修改。这是抽象工厂的固有局限:它对”新增产品族”(新增一个 DarkModeUIFactory)是 OCP 友好的,但对”新增产品类型”(在现有族中新增组件)是 OCP 不友好的。

在使用前,需要对产品族的内容做出相对稳定的判断。如果产品族的组成经常变化,抽象工厂会带来大量的接口修改代价。


总结

本篇深入剖析了三个创建型设计模式:

  • 单例模式的核心是控制实例数量和生命周期。懒汉式在多线程下不安全;DCL 是正确方案但必须配合 volatile 防止指令重排序;静态内部类利用 JVM 类加载机制实现优雅的懒加载线程安全;枚举单例是 Java 推荐的最优实现,天然防反射和序列化攻击。单例最大的代价是引入全局状态,测试困难——在 Spring 管理的 Bean 中,优先让框架控制单例生命周期,而非手工实现;

  • 工厂方法模式通过”让子类决定实例化哪个类”将创建逻辑从调用方分离,支持 OCP——新增产品类型只需新增工厂子类。与简单工厂的本质区别在于:简单工厂通过 switch 集中管理(修改已有类),工厂方法通过继承分散管理(新增子类);

  • 抽象工厂模式解决”产品族整体切换”的问题,定义一个能创建整族产品的工厂接口,切换产品族只需换一个工厂实现。固有局限是”扩展新产品类型”困难——工厂接口需要修改,影响所有实现。适用于产品族组成稳定、但产品族本身需要整体切换的场景。

下一篇继续创建型模式中的建造者模式(Builder)与原型模式(Prototype),解决”复杂对象构建”和”对象克隆”的设计问题:03 创建型模式(下)——建造者与原型模式


参考资料

  • GoF,《Design Patterns: Elements of Reusable Object-Oriented Software》, 1994
  • Joshua Bloch,《Effective Java》3rd ed., Item 3: Enforce the singleton property with a private constructor or an enum type
  • Brian Goetz,《Java Concurrency in Practice》, Chapter 16: Java Memory Model(DCL 的内存模型分析)

思考题

  1. 工厂方法模式将对象的创建委托给子类,而抽象工厂模式创建一组相关对象。在一个需要支持多种数据库(MySQL、PostgreSQL、MongoDB)的 ORM 框架中,你会使用哪种工厂模式来创建 Connection、Statement、ResultSet 等对象?JDBC 的 DriverManager 采用了哪种模式?
  2. Builder 模式在 Java 中常用于构造复杂对象(如 Protobuf 的 Message.Builder、OkHttp 的 Request.Builder)。Builder 模式相比’伸缩构造函数’(Telescoping Constructor)和 JavaBeans 模式(setter 方法),在不可变性和线程安全性方面有什么优势?Lombok 的 @Builder 注解生成的 Builder 与手写 Builder 有什么差异?
  3. 单例模式的’双重检查锁’(DCL)实现需要 volatile 关键字修饰实例变量。如果不加 volatile,在 JMM 中可能出现什么问题(提示:指令重排导致获取到未完成初始化的对象)?枚举单例(enum Singleton)被认为是 Java 中最安全的单例实现——它能防御反射攻击和序列化攻击,其原理是什么?