3261 字
约 10 分钟
11
S18-J02 · 创建型模式:对象怎么来

适用读者:写过 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

三条结论,全部来自上面的输出:

  1. 普通单例的私有构造挡不住反射——只要 setAccessible(true) 就能进;所谓"防反射"只能靠在构造里判断"实例是否已存在",而这要求静态字段已经赋值。如果反射发生在 get() 之前,静态字段还是 null,守卫根本不会触发(第一版实验里就造出了第二个实例,才改成先 get() 再反射)。
  2. 枚举由 JDK 直接兜底:Constructor.newInstance 对 enum 抛 IllegalArgumentException: Cannot reflectively create enum objects(抛出点是 java.lang.reflect.Constructor.newInstanceWithCaller)。这不是你的代码在防,是语言在防。
  3. 枚举的 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. 自测

  1. 静态内部类 holder 为什么能既延迟加载又线程安全?(提示:类初始化的锁)
  2. DCL 里去掉 volatile 会出什么问题?给出一个可能的重排序列。
  3. 枚举单例被反射时抛的是哪个异常、由哪一层抛出?
  4. 你的项目里有几个 Factory 类只有一条产品线?它们该留吗?
  5. 建造者的 build() 里应该做哪三类检查?
  6. 拷贝构造器相比 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 文档口径(推断,未在本文跑容器)。

S18-J02 · 创建型模式:对象怎么来
https://www.clxhxhhr.top/posts/5436/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。