跳到正文

创建型模式:单例、工厂、建造者、原型 ​

一句话:创建型模式把「用哪个类、怎么构造、构造几次」从调用点移到一处可控的边界。它换来的是创建逻辑可替换、可校验,代价是多一层间接;没有变化需求时,直接 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 在这里做两件事:

  1. 禁止重排序:volatile 写之前的操作(构造器初始化)不会被排到写之后;
  2. 保证可见性: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

把一门技术讲透到既懂也会用