创建型模式(上)——单例、工厂方法与抽象工厂
摘要
创建型设计模式(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)解决的问题是:确保一个类只有一个实例,并提供一个全局访问点。
哪些对象需要单例?判断标准是:如果创建多个实例会导致资源浪费、状态不一致或行为错误,就需要单例。典型案例:
- 数据库连接池(
HikariCP、Druid):连接池维护着一组数据库连接,创建两个独立的连接池意味着系统同时持有两倍数量的连接,浪费资源,且两个池互不感知,无法做全局限流; - 线程池(
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 层面不是原子操作,它分为三步:
- 在堆上分配内存;
- 调用
ConfigManager的构造方法,初始化对象; - 将
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(如
UserService、OrderService)实现为手工单例是常见的过度使用。在 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),必须修改 LoggerFactory 的 switch 语句。
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() 工厂方法,具体集合类(ArrayList、LinkedList、HashSet)各自返回适合自身数据结构的 Iterator 实现(ArrayList 返回 Itr,LinkedList 返回 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 风格:
WindowsButton、WindowsCheckbox、WindowsScrollbar; - macOS 风格:
MacButton、MacCheckbox、MacScrollbar; - Linux/GTK 风格:
GtkButton、GtkCheckbox、GtkScrollbar。
关键约束:不能混用不同风格的组件——在同一个界面里,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 的创建,需要:
- 修改
UIFactory接口,添加createScrollbar()方法; - 修改所有工厂实现类(
WindowsUIFactory、MacUIFactory、GtkUIFactory),各自添加实现。
这违反了 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 的内存模型分析)
思考题
- 工厂方法模式将对象的创建委托给子类,而抽象工厂模式创建一组相关对象。在一个需要支持多种数据库(MySQL、PostgreSQL、MongoDB)的 ORM 框架中,你会使用哪种工厂模式来创建 Connection、Statement、ResultSet 等对象?JDBC 的
DriverManager采用了哪种模式?- Builder 模式在 Java 中常用于构造复杂对象(如 Protobuf 的
Message.Builder、OkHttp 的Request.Builder)。Builder 模式相比’伸缩构造函数’(Telescoping Constructor)和 JavaBeans 模式(setter 方法),在不可变性和线程安全性方面有什么优势?Lombok 的@Builder注解生成的 Builder 与手写 Builder 有什么差异?- 单例模式的’双重检查锁’(DCL)实现需要
volatile关键字修饰实例变量。如果不加volatile,在 JMM 中可能出现什么问题(提示:指令重排导致获取到未完成初始化的对象)?枚举单例(enum Singleton)被认为是 Java 中最安全的单例实现——它能防御反射攻击和序列化攻击,其原理是什么?