适用读者:写过
new、写过if (type.equals(...))造对象、也被问过"单例有几种写法"的人。
前置:S18-J01(选型判据)。
覆盖旧文:A-2379(单例/工厂/抽象工厂/建造者/原型段)、A-2609(单例 268 点 + 工厂 325 点 + 建造者 126 点 + 原型 88 点)、A-2326(工厂 142 点 + 单例 139 点)、A-377(工厂 141 点)——合计 503 点。
扩展点:E-S18-002(单例在容器时代的位置)、E-S18-003(简单工厂不属于 GoF 23 种)。
上一篇:S18-J01 · 设计模式总论:分类、选型判据与骨架
下一篇:S18-J03 · 结构型模式:对象怎么拼。
1. 一句话结论
创建型模式只做一件事:把"选哪个实现、按什么顺序装配、能不能有第二个"这三个决定,从调用方搬到一处可控的地方。 三个决定对应三个模式族——工厂管"选哪个"、建造者管"怎么装"、单例管"只能有一个"、原型管"照着复制一个"。
创建型是最容易被滥用的类别,因为它最容易"看起来有设计"。所以本文每个模式都给成本栏:多写几个类,换回来的是什么。
2. 单例:从饿汉到枚举,再到容器
2.1 五种写法与它们的真实差异
| 写法 | 延迟初始化 | 线程安全 | 抗反射 | 抗反序列化 | 适用 |
|---|---|---|---|---|---|
饿汉 static final INSTANCE = new() |
❌ 类加载即建 | ✅(类初始化加锁) | ❌ | ❌ | 实例轻、一定要用 |
懒汉 synchronized getInstance() |
✅ | ✅ 但每次加锁 | ❌ | ❌ | 几乎不用(性能差) |
双重检查 DCL + volatile |
✅ | ✅ | ❌(需自己加守卫) | ❌ | 实例重、初始化慢 |
| 静态内部类 holder | ✅ | ✅ | ❌ | ❌ | 推荐的懒加载写法 |
枚举 INSTANCE |
❌ | ✅ | ✅(JDK 拦) | ✅(JVM 保证) | 推荐的单实例写法 |
volatile 在 DCL 里不是装饰:没有它,instance = new X() 的"分配内存 / 初始化 / 赋值引用"三步可能被重排,另一个线程会拿到半初始化对象。这是 Java 内存模型层面的问题,不是"加个锁就行"。
2.2 实测:反射能不能造出第二个实例
实测环境:Java 17.0.20,单文件运行(java SingletonLab.java,2026-11-06)。
[dcl] get() -> SingletonLab$DclSingleton@14a2f921
[dcl] reflection blocked: singleton already exists
[enum] reflection blocked by JDK: Cannot reflectively create enum objects
[enum] valueOf identity: true
三条结论,全部来自上面的输出:
- 普通单例的私有构造挡不住反射——只要
setAccessible(true)就能进;所谓"防反射"只能靠在构造里判断"实例是否已存在",而这要求静态字段已经赋值。如果反射发生在get()之前,静态字段还是 null,守卫根本不会触发(第一版实验里就造出了第二个实例,才改成先get()再反射)。 - 枚举由 JDK 直接兜底:
Constructor.newInstance对 enum 抛IllegalArgumentException: Cannot reflectively create enum objects(抛出点是java.lang.reflect.Constructor.newInstanceWithCaller)。这不是你的代码在防,是语言在防。 - 枚举的
valueOf返回同一实例(==为 true),反序列化也天然保序——这是《Effective Java》推荐枚举单例的原始理由。
2.3 容器时代:Spring 的单例不是 GoF 的单例
| 维度 | GoF 单例 | Spring 单例 Bean |
|---|---|---|
| 保证范围 | 每个 ClassLoader 一个 | 每个容器(ApplicationContext)一个 |
能否 new |
构造私有,不能 | 能,随时可以 new 一个 |
| 多实例场景 | 不存在 | 父子容器、多容器(多测试上下文)各一份 |
| 销毁 | 无 | 容器关闭时回调 @PreDestroy |
| 主要风险 | 全局可变状态 | 有状态 Bean 被并发共享 |
所以"Spring Bean 默认单例"这句话,正确读法是"默认复用同一个对象",而不是"全局唯一"。真正要小心的是:在单例 Bean 里放可变成员变量。
@Service
public class CounterService { // 单例 Bean
private int count = 0; // ❌ 所有请求共享,且非原子
public int inc() { return ++count; }
}
这段代码的问题不是"单例",是"把请求级状态放进了应用级对象"。修法是改成 AtomicInteger(仍共享,语义要确认)或挪进方法局部变量 / 请求作用域。
2.4 单例的三个替代方案
在业务代码里,手写单例往往能用更便宜的方式替代:
| 替代 | 做法 | 适用 | 代价 |
|---|---|---|---|
| 容器作用域 | @Scope("singleton") / prototype / request |
Spring 工程(首选) | 依赖容器 |
| 参数注入 | 把"唯一资源"作为构造参数传下去 | 工具类、SDK 内部 | 调用方要显式传递 |
| 静态工具类 | 无状态方法 + 静态调用 | 纯函数(格式化、校验) | 无法 mock,难替换 |
判据:有状态 → 交给容器管生命周期;无状态 → 静态工具类就够;需要被替换/测试 → 参数注入。三者都不满足时,才需要手写单例(典型场景是 SDK 内部没法依赖容器)。
判据:需要"全局唯一"的是资源(连接池、配置快照、ID 生成器),不是业务对象。资源交给容器管生命周期;业务对象每次 new 或由工厂产出。
3. 工厂三兄弟:边界在"变化维度"
| 模式 | 解决的变化 | 结构 | 新增一种产品要改几处 | 是否属 GoF 23 种 |
|---|---|---|---|---|
| 简单工厂 | 按参数选实现 | 一个类 + switch/注册表 | 1 处(工厂内)+ 1 个新类 | ❌ 不属于(习惯叫法) |
| 工厂方法 | 子类决定造什么 | 抽象工厂接口 + N 个子类工厂 | 新增 1 个产品类 + 1 个工厂类 | ✅ |
| 抽象工厂 | 产品族一起换 | 工厂接口含多个创建方法 | 新增 1 个族(N 个类)+ 1 个工厂 | ✅ |
E-S18-003 要说的就是第一行:简单工厂不是 GoF 的 23 种之一。面试里说"我用简单工厂"没问题,但别说"23 种里的简单工厂"。这个更正不是抠字眼——它解释了为什么"简单工厂"在真实项目里出现频率远高于另外两个:它最便宜。
注册表形态(推荐默认):
static final Map<String, Supplier<PayChannel>> REG =
Map.of("alipay", Alipay::new, "wechat", Wechat::new, "card", Card::new);
static PayChannel channel(String code) {
var s = REG.get(code);
if (s == null) throw new IllegalArgumentException("unknown channel: " + code);
return s.get();
}
实测(Java 17.0.20):channel("card").fee(10000) → 150;channel("unionpay") → IllegalArgumentException: unknown channel: unionpay。
Spring 里的三个"工厂"要分清:
| 名字 | 是什么 | 什么时候用 |
|---|---|---|
BeanFactory / ApplicationContext |
IoC 容器,管 Bean 生命周期 | 你需要"拿到一个已装配好的对象" |
FactoryBean<T> |
一个"生产 T 的 Bean",getObject() 返回产品 |
产品的构造过程复杂(如 SqlSessionFactory) |
@Bean 方法 |
容器里的工厂方法 | 第三方类无法加注解时 |
抽象工厂在业务代码里其实罕见——它真正的战场是替换整个技术栈族(比如 MySQL 族 vs Oracle 族的 DAO 全家桶)。如果你只有一条产品线,抽象工厂就是纯负债。
4. 建造者:参数爆炸与不可变对象
建造者解决的不是"创建",是"构造参数的顺序与合法性"。它有三层收益,价值递增:
| 层 | 收益 | 例子 |
|---|---|---|
| 1 | 可读性:命名参数替代位置参数 | Order.builder("O-1").item("sku-A", 1200).build() |
| 2 | 校验集中:build() 是唯一的合法性关口 |
空订单在 build() 抛 IllegalStateException |
| 3 | 不可变:build() 后对象冻结,天然线程安全 |
字段全 final + List.copyOf() 防御性拷贝 |
实测(同上环境):
[builder] Order(O-1,[sku-A, sku-B],2000)
[builder] reject empty order
第二行是关键:没有 build() 里的校验,这个对象就是"可以存在的非法状态"。很多项目的 bug 不是算错,是"非法对象被创建出来了,直到第 5 层才炸"。
与 Lombok @Builder 的关系:@Builder 省掉样板代码,但它不会替你做"必填检查"和"防御性拷贝"。所以要么在 build() 手写校验(@Builder 支持自定义 build()),要么用 @Builder(buildMethodName = ...) 外挂校验。只加注解不加校验,等于把可读性买回来了、把正确性丢了。
何时不用建造者:参数 ≤4 个且无校验 → 直接用 record 或构造器。建造者的成本是"多一个类 + 多一层 API"。
5. 原型:浅拷贝、深拷贝与代价
| 复制方式 | 做法 | 成本 | 风险 |
|---|---|---|---|
| 浅拷贝 | Object.clone() / 拷贝构造器 |
低 | 引用字段共享,改一个动两个 |
| 深拷贝(手写) | 逐层 new + 拷贝 | 中 | 容易漏字段,加字段时忘改 |
| 深拷贝(序列化) | JSON / Java 序列化往返 | 高(CPU + 内存翻倍) | 需要可序列化;丢 transient |
| 深拷贝(拷贝构造器) | new Order(other) 显式逐字段 |
中 | 推荐:加字段时编译器会提醒 |
Cloneable 的坑值得单独记:它是个标记接口,不包含 clone() 方法;不实现它就 clone() 会抛 CloneNotSupportedException;它也不保证深拷贝。Effective Java 的建议是"避免使用 Cloneable,用拷贝构造器或静态工厂"。
真实项目里"原型"最常见的身影不是 clone(),而是模板复制:从一个已配置好的活动模板复制出一场新活动(活动配置、奖品、库存规则全量复制)。这时要问的是"哪些字段必须重置"(活动 ID、时间、库存),而这不是设计模式能回答的,是业务不变式。
6. 判据表:什么时候上创建型
| 场景 | 上 | 不上 | 理由 |
|---|---|---|---|
| 按类型造对象,分支会增长 | 注册表 / 工厂方法 | 两个分支的 if-else | 分支数决定收益 |
| 构造参数 >5 且有必填 | 建造者 | — | 可读性 + 校验 |
| 需要"全局唯一资源" | 容器管理的单例 | 手写 GoF 单例 | 生命周期该由容器负责 |
| 需要防反射/防反序列化破坏 | 枚举单例 | — | 语言级保证 |
| 复制一个复杂配置 | 拷贝构造器 | clone() |
编译器帮你查漏字段 |
| 要替换整个技术栈族 | 抽象工厂 | — | 产品族才是它的场景 |
7. 常见误区
| 误区 | 真相 |
|---|---|
| "单例保证全局唯一" | 只保证单 ClassLoader;分布式下要外部协调(Redis 锁/选主) |
| "Spring 单例 = GoF 单例" | 是"每容器一个",可被 new,多容器各一份 |
| "简单工厂是 23 种之一" | 不在 GoF 23 种里;它是一种惯用写法 |
| "建造者必须配 Lombok" | Lombok 只省样板,校验和防御性拷贝仍要手写 |
"clone() 默认是深拷贝" |
默认浅拷贝;Cloneable 只是标记接口 |
| "工厂越多越解耦" | 每多一层工厂,调试栈多一层,且新增产品仍要改注册表 |
8. 自测
- 静态内部类 holder 为什么能既延迟加载又线程安全?(提示:类初始化的锁)
- DCL 里去掉
volatile会出什么问题?给出一个可能的重排序列。 - 枚举单例被反射时抛的是哪个异常、由哪一层抛出?
- 你的项目里有几个
Factory类只有一条产品线?它们该留吗? - 建造者的
build()里应该做哪三类检查? - 拷贝构造器相比
clone(),在"加字段"这件事上强在哪?
下一篇
S18-J03 · 结构型模式:对象怎么拼
创建型解决"对象从哪来",结构型解决"对象怎么组合出更大的能力"。下一篇的重点是三个最容易混淆的模式——代理、装饰、适配器——以及一句判据:接口变不变、行为加不加、访问控不控。
本篇属于系列《S18 · 架构方法与领域建模》。旧文来源:A-2379/A-2609/A-2326/A-377;扩展点 E-S18-002、E-S18-003。反射与建造者示例为 Java 17.0.20 实测;Spring 单例语义按 Spring Framework 6.x 文档口径(推断,未在本文跑容器)。