主题
创建型模式:单例、工厂、建造者、原型
一句话:创建型模式把「用哪个类、怎么构造、构造几次」从调用点移到一处可控的边界。它换来的是创建逻辑可替换、可校验,代价是多一层间接;没有变化需求时,直接
new更便宜。
为什么需要
new MyClass() 隐含三个假设:编译时知道具体类、一步构造完成、每次得到独立实例。任一假设不成立,创建逻辑就会散落到调用点:
- 运行时才决定实现(支付渠道、存储后端)→ 工厂;
- 构造参数多且有依赖顺序(客户端配置、请求对象)→ 建造者;
- 多实例导致状态不一致或资源浪费(配置、连接池)→ 单例;
- 构造成本高、需要复制现有对象(灰度配置、模板)→ 原型。
核心机制
单例:控制实例数量,不是全局变量
单例要解决的是「多个实例会破坏一致性或浪费资源」,不是「我想在任何地方拿到它」。实现方式按安全等级排序:
| 实现 | 懒加载 | 线程安全 | 防反射 | 防反序列化 | 适用 |
|---|---|---|---|---|---|
DCL + volatile | 是 | 是 | 否 | 否(需 readResolve) | 需要延迟初始化且构造代价高 |
| 静态内部类 | 是 | 是(JVM 类初始化) | 否 | 否(需 readResolve) | 大多数场景首选 |
| 枚举 | 否(类加载即初始化) | 是 | 是 | 是 | 需要绝对防反射/反序列化 |
DCL 与 volatile:三步与重排序
java
public final class DclSingleton {
private static volatile DclSingleton instance; // ① volatile 修饰引用
private DclSingleton() {}
public static DclSingleton getInstance() {
DclSingleton local = instance; // ② 第一次读,减少 volatile 读开销
if (local == null) {
synchronized (DclSingleton.class) {
local = instance; // ③ 第二次读,防止重复创建
if (local == null) {
local = new DclSingleton(); // ④ 分配、初始化、赋值三步
instance = local; // ⑤ volatile 写,发布对象
}
}
}
return local;
}
}new 在 JMM 里不是原子操作:分配内存 → 调用构造器初始化 → 把引用指向内存。没有 volatile 时,JIT 可以把初始化与引用赋值重排序,另一个线程在 ② 看到非 null,却拿到构造器还没跑完的对象,字段是默认值。
volatile 在这里做两件事:
- 禁止重排序:volatile 写之前的操作(构造器初始化)不会被排到写之后;
- 保证可见性:volatile 写 happens-before 后续对同一字段的读,线程 B 能看到完整初始化后的对象。
注意:volatile 不保证复合操作原子性,instance++ 这类操作仍需锁;DCL 的原子性来自 synchronized,volatile 只负责安全发布。
工厂三兄弟:看扩展维度和产品关系
| 模式 | 结构 | 新增一种产品要改什么 | 适用信号 |
|---|---|---|---|
| 简单工厂 | 一个类 + switch | 改工厂的 switch,违反 OCP | 分支少于 5 且长期稳定 |
| 工厂方法 | 一个工厂接口 + N 个工厂 | 加一个工厂类,旧代码不动 | 单一产品维度频繁扩展 |
| 抽象工厂 | 一个工厂接口创建一组产品 | 加一个产品族要改所有工厂接口 | 多个产品必须配套切换(Windows/Mac 控件) |
Spring 里对应关系:BeanFactory#getBean 是简单工厂式查找,FactoryBean#getObject 是工厂方法,ApplicationContext 创建一组配套 Bean,接近抽象工厂。FactoryBean 的坑是:@Autowired 拿到的是 getObject() 的返回值,想拿工厂本身要加 & 前缀(如 &myFactoryBean)。
建造者:分步构造 + 构建期校验
java
public final class Task {
private final String name;
private final int timeoutMs;
private Task(Builder b) { this.name = b.name; this.timeoutMs = b.timeoutMs; }
public static final class Builder {
private String name;
private int timeoutMs = 3_000;
public Builder name(String v) { this.name = v; return this; }
public Builder timeoutMs(int v) { this.timeoutMs = v; return this; }
public Task build() {
if (name == null || name.isBlank()) throw new IllegalArgumentException("name required");
if (timeoutMs <= 0) throw new IllegalArgumentException("timeout > 0");
return new Task(this);
}
}
}建造者的价值不只是「参数有名字」,还包括:对象可以做成不可变(字段 final、无 setter)、校验集中在 build()、构造顺序显式。代价是每个类多一个 Builder,类数量翻倍;参数少于 4 个且没有校验时,构造器或静态工厂更直接。
原型:复制现有对象
原型适合「构造代价高、且新对象与旧对象大部分相同」的场景。Java 的 Cloneable 有两个坑:
super.clone()是浅拷贝,可变字段(Map、List)仍指向同一对象;clone()绕过构造器,final字段和不变量的校验可能被跳过。
java
public GrayConfig copy() {
return new GrayConfig(new HashMap<>(this.rules), new ArrayList<>(this.whiteList));
}拷贝构造器比 Cloneable 更可控;需要深拷贝且对象图复杂时,序列化往返可用,但要注意性能和 transient 字段。Spring 的 @Scope("prototype") 不是原型模式:它每次调用构造器/工厂方法创建新 Bean,不涉及 clone()。
实战场景
一、DCL 漏了 volatile:偶发的半初始化对象
现象:一个自研的汇率客户端用双重检查锁做懒加载。压测一切正常,生产每隔几天出现一次 NullPointerException: rateTable is null,位置在单例对象刚创建后的第一次调用;重启即恢复,无法稳定复现,代码评审也看不出问题。
排查:DCL 的正确性依赖三步的顺序,漏一环就是可见性问题。
| 定位层 | 取证据 | 判定 |
|---|---|---|
字段是否 volatile | 检查持有实例的字段声明 | 无 volatile → 重排序可能让引用先于初始化发布 |
| 锁的粒度 | synchronized 是否锁在类对象上且检查在锁内二次判断 | 只检查一次会创建多实例 |
| 构造是否完成即发布 | 构造函数里是否调用外部(init() 在构造内) | 构造中发布 this 会让半初始化对象逃逸 |
| 复现手段 | 用 jcstress 或高并发压力测试反复创建 | 概率性出现即符合内存模型预期 |
| 影响面 | 是否所有线程共用同一实例 | 单例被共享,问题会持续一段时间 |
new 的三步(分配内存 → 执行构造 → 把引用指向内存)在 JMM 下允许重排序;没有 volatile 的写-读屏障,第二个线程可能在构造完成前拿到非 null 引用。
对症做法:
java
// 1. 优先用静态内部类 Holder:懒加载且无需同步,天然正确
public final class RateClient {
private static class Holder { static final RateClient INSTANCE = new RateClient(); }
public static RateClient get() { return Holder.INSTANCE; }
private RateClient() { /* 构造内不暴露 this */ }
}
// 2. 需要枚举语义时用 enum 单例,序列化与反射安全由语言保证
public enum RateClientEnum { INSTANCE; }
// 3. 一定要 DCL 时,volatile 不是可选优化
private static volatile RateClient instance;
public static RateClient get() {
RateClient local = instance;
if (local != null) return local;
synchronized (RateClient.class) {
local = instance;
if (local == null) { local = new RateClient(); instance = local; }
return local;
}
}复盘要点:判据是静态检查——出现 synchronized + 双重 if (x == null) 的写法必须同时出现 volatile,否则 CI 失败。更省心的做法是把懒加载单例统一成 Holder 或交给 Spring 容器管理,让“实例数量控制”这件事只有一种写法;单例的意义是控制实例数量,不是做全局变量。
二、八参数构造函数把金额和币种传反
现象:账务侧报“美元账户出现人民币金额”,涉及 40 余笔(经验值,需校准)。定位到一个 Money 对象用 new Money(0, "USD", "CNY", amount, ...) 创建——两个 String 参数顺序被调换,编译器和 IDE 都不报错。同类构造函数在项目里有 12 处调用点,无法判断其他处是否也错。
排查:没有构建期校验的创建点,风险按调用点数量累积。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 参数同类型数量 | 构造函数里相同类型的相邻参数个数 | 相邻 String/long ≥ 2 → 顺序风险 |
| 调用点形态 | new Money(...) 的字面量参数是否有含义可读 | 位置参数裸值 → 不可评审 |
| 是否有不变式校验 | 构造后是否校验“币种非空、金额精度合法” | 无校验 → 错误对象流入下游 |
| 是否可空参数 | 部分场景需要传 null 占位 | 占位 null 是 Builder 的信号 |
| 同类创建分散度 | 同一对象的创建逻辑在几处重复 | 分散 → 规则变更后难统一 |
对症做法:
java
// 1. Builder:参数有名字,构建期校验,非法对象造不出来
public final class Money {
private final BigDecimal amount; private final String currency; private final Precision precision;
private Money(Builder b) {
if (b.currency == null || b.currency.length() != 3) throw new IllegalStateException("bad currency");
if (b.amount.scale() > b.precision.scale()) throw new IllegalStateException("scale exceeds precision");
this.amount = b.amount; this.currency = b.currency; this.precision = b.precision;
}
public static Builder builder() { return new Builder(); }
public static final class Builder {
private BigDecimal amount; private String currency; private Precision precision = Precision.TWO;
public Builder amount(BigDecimal v) { this.amount = v; return this; }
public Builder currency(String v) { this.currency = v; return this; }
public Builder precision(Precision v){ this.precision = v; return this; }
public Money build() { Objects.requireNonNull(amount, "amount"); return new Money(this); }
}
}
Money price = Money.builder().currency("USD").amount(new BigDecimal("12.30")).build();
// 2. 值对象提供静态工厂,收敛常见创建形态
public static Money ofUsd(String raw) { ... }
public static Money zero(String currency) { ... }text
3. 已有 12 处调用点先做一次性收口:加一条静态检查禁止直接 new Money(,
并把每处调用改成 Builder 或静态工厂;
4. 分步构造 + 构建期校验是建造者的价值,链式赋值本身不是目的。复盘要点:判据是“非法对象能否被构造出来”。构建期校验之后,这类错误从“线上对账发现”变成“单元测试失败”;同时“同一对象的创建入口数”应收敛到 1 处(静态工厂或 Builder),否则规则变更要改所有点。原型模式(clone)在这里不适用:复制一个已经错误的对象只放大问题。
三、单例配置对象被就地修改:一次改动影响全部线程
现象:一个 @Component 单例持有 Map<String, Integer> timeouts,某处在启动时按环境做了 timeouts.put("pay", 60000),另有一处在请求处理里 computeIfAbsent 写缓存。三天后出现“支付超时值在不同线程上忽大忽小”,且只在高并发时出现。
排查:单例 + 可变状态 = 全局共享的可变状态。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 实例数量 | 打印 System.identityHashCode(bean) 跨请求比对 | 同一实例 → 状态被共享 |
| 可变成员 | 单例里是否有非 final 字段、可变集合 | 有 → 需要并发保护或改不可变 |
| 写路径 | 谁在运行期写这些字段(启动钩子、请求处理、定时任务) | 请求路径写单例状态是主要风险 |
| 并发异常 | ConcurrentModificationException、HashMap 相关异常 | 出现即确认非线程安全容器被并发写 |
| 观测一致性 | 同一 key 在不同线程读到的值 | 不一致 → 写覆盖或可见性问题 |
对症做法:
java
// 1. 配置在构造期定稿,之后只读:用不可变视图暴露
@Component
public class TimeoutConfig {
private final Map<String, Integer> timeouts;
public TimeoutConfig(TimeoutProperties props) {
this.timeouts = Map.copyOf(props.getValues()); // 构造后不可变
}
public int of(String key) { return timeouts.getOrDefault(key, 3000); } // 没有 put 方法
}
// 2. 需要缓存时用显式并发容器 + 明确的缓存语义,与配置分开
private final Map<String, Integer> resolved = new ConcurrentHashMap<>();
// 3. 请求级数据不放在单例里:走方法参数、Web 作用域代理或 ThreadLocal(必须 remove)text
4. 若确需运行期调整配置,改成“新版本对象整体替换引用”(volatile 字段换指向),
而不是原地修改集合——原子替换读起来只可能看到旧值或新值,不会看到中间状态。复盘要点:单例的评审口径是“成员变量只能是注入的协作对象和 final 常量”,这条可以静态检查;运行期可变的必须是显式并发容器或整体替换的引用。控制实例数量本身没错,出问题的是把单例当成全局变量用——这也是它与 DDD 里“单例无状态”约束的同一条底线,见 Spring IoC 容器原理。
最小上手
单例的序列化与反序列化测试(JUnit 5):
java
import java.io.*;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertSame;
class SingletonSerializationTest {
@Test
void enum_singleton_survives_serialization() throws Exception {
var bytes = new ByteArrayOutputStream();
try (var out = new ObjectOutputStream(bytes)) { out.writeObject(EnumSingleton.INSTANCE); }
try (var in = new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) {
assertSame(EnumSingleton.INSTANCE, in.readObject());
}
}
}
enum EnumSingleton { INSTANCE }运行:mvn -q test -Dtest=SingletonSerializationTest。把枚举换成普通 implements Serializable 的 DCL 单例,这个断言会失败——反序列化会创建新对象,除非实现 readResolve() 返回 INSTANCE。
生产动作:误用与代价
- 现象:压测时偶发 NPE 或读到 0/默认值 → 证据:单例用 DCL 但字段没加
volatile;代码审查javap -p -c看不到屏障,最可靠的是用 JCStress 写并发用例复现 → 根因:不安全发布 → 处置:加volatile,或直接用静态内部类/枚举。 - 现象:反序列化后
!=单例 → 证据:ObjectInputStream读出的对象哈希不同 → 根因:readObject默认创建新实例 → 处置:实现private Object readResolve() { return INSTANCE; },或改枚举。 - 现象:Spring 容器里出现两个「单例」 → 证据:
applicationContext.getBeansOfType(X.class)返回多个,或两个ApplicationContext各有一份 → 根因:Spring 单例的作用域是BeanFactory,不是 JVM;同一类可以有多个 BeanDefinition,多个上下文也会各建一份 → 处置:统一容器、检查@Component与@Bean是否重复注册;跨上下文共享用显式注册或外部单例。 - 现象:反射创建了第二个实例 → 证据:
getDeclaredConstructor().setAccessible(true).newInstance()成功 → 根因:私有构造器不是语言级保护 → 处置:用枚举;或用模块系统/安全管理器限制反射(JDK 17+ 下安全管理器已废弃,优先枚举)。 - 现象:两个 ClassLoader 各有一个单例 → 证据:
a.getClass().getClassLoader() != b.getClass().getClassLoader()→ 根因:静态变量属于 ClassLoader → 处置:把单例放到共享父 ClassLoader,或改用容器管理。 - 现象:Lombok
@Builder构建出的对象字段是 0/null → 证据:User.builder().name("x").build().getAge()返回 0,而字段声明是= 18→ 根因:Builder 不读取字段初始化表达式 → 处置:加@Builder.Default,或在build()里补默认值。 - 现象:单例里塞了可变状态,接口串数据 → 证据:不同用户请求读到彼此的数据 → 根因:把「唯一实例」当成「无状态」 → 处置:单例只放无状态逻辑;有状态数据放请求作用域、ThreadLocal 或外部存储。
边界
- 单例不是全局变量的替代品:需要传递依赖时用注入,不要在每个类里
Singleton.getInstance(),否则测试无法替换。 - 工厂不是越多越好:产品只有一个实现、短期不会扩展时,直接
new或构造器注入更清楚。 - 抽象工厂扩展成本高:新增一个产品类型要改所有工厂接口和实现;产品族稳定、族内产品常扩展时它才划算。
- 建造者不适合小对象:4 个以下参数、没有校验和顺序要求,构造器更易读。
- 原型要明确深浅拷贝:含可变字段的对象默认浅拷贝,线上最常见的 bug 是「改了副本,原对象也变了」。
讲清楚
创建型模式解决「对象怎么来」。单例控制实例数量,DCL 的关键是
volatile保证安全发布:new分分配、初始化、赋值三步,缺volatile可能让别的线程看到半初始化对象;更稳的写法是静态内部类或枚举。工厂按扩展维度选:简单工厂改 switch,工厂方法加类,抽象工厂加产品族;产品族稳定时抽象工厂才划算。建造者把多参数、校验和不可变对象放在一起,代价是多一个类。原型用于复制成本高的对象,super.clone()是浅拷贝,可变字段必须手动复制。最后,Spring 的单例是容器级,不是 JVM 级;序列化、反射、多 ClassLoader 都能造出第二个实例,防护手段各不相同。
初稿 2025-06 | 最后修订 2026-10