Skip to content

设计模式:创建型 ​

S4-003建议学高级约 90 分钟JDK 21企业开发进阶 · 软件工程与设计模式

1. 本节定位 ​

《SOLID 原则》讲了设计规则;从本节起讲具体的模式。创建型模式只回答一个问题:

text
"对象怎么造"—— 直接 new?交给工厂?用建造者一步步搭?

★ 判断标准始终是同一个 ✓:

创建逻辑的"变化点"在哪里?把变化点隔离出去,就是创建型模式的价值 ✓✓

2. 学习目标 ​

  • 能说出五种单例写法的差别,并知道只有枚举能挡住反射与反序列化 ★★★;
  • 知道"单例是全局状态",能不用就不用 ★★★;
  • 能区分简单工厂 / 注册式工厂 / 工厂方法 / 抽象工厂 ★★;
  • 知道建造者解决什么问题,以及什么时候不该用 ★★★。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

先看单例的一个真实事故 ✗:

text
某项目用"双检锁"写了一个配置单例,上线后偶发"配置为空"。
排查发现:**没加 volatile** → 别的线程可能拿到"构造未完成"的对象 ✗✗
(这就是 Java 内存模型里的"指令重排"—— 对象引用先被赋值,构造还没执行完)

★ 本节把单例放进两个攻击场景里测一遍 ✓✓:

攻击能挡住吗
反射调用私有构造器普通写法全部失守 ✗✗
反序列化同上 ✗✗
枚举两个都挡住 ✓✓

5. 核心概念 ​

5.1 单例的五种写法与两个攻击面 ★★★ ​

五种写法(本节实测):

写法懒加载线程安全性能
饿汉✗✓最快 ✓
同步懒汉✓✓最慢 ✗✗(每次进监视器)
双检锁✓✓(必须加 volatile)快 ✓
静态内部类✓✓与饿汉同量级 ✓✓
枚举✗✓快 ✓

★ 实测:并发下是否唯一 ✓(2000 线程各拿一次):

text
  实现方式        实例个数
  饿汉            1
  同步懒汉        1
  双检锁          1
  静态内部类      1
  枚举            1

★ 五种写法在并发下都能保证唯一 ✓ ——但下面两个攻击面才是分水岭 ✓✓

★ 攻击一:反射 ✗

java
Constructor<DoubleChecked> ctor = DoubleChecked.class.getDeclaredConstructor();
ctor.setAccessible(true);                  // ★ 私有构造器挡不住它
DoubleChecked a = ctor.newInstance();      // ✗ 造出了第二个实例
写法结果
双检锁 / 静态内部类 ✗✗造出第二个实例 ——单例被破坏
枚举 ✓✓抛 Cannot reflectively create enum objects(JVM 层硬性检查)

★ 攻击二:反序列化 ✗

写法结果
普通类 ✗✗反序列化得到新对象(除非加 readResolve())
枚举 ✓✓按名字查找已有常量 → 还是同一个 ✓

★ 结论 ✓✓:

只有枚举能同时挡住反射与反序列化 ✓

所以《Effective Java》推荐「单元素枚举是单例的最佳实现」 ✓✓

★ 但枚举也有局限 ✗:

局限说明
不能继承类枚举已隐式继承 Enum
不能懒加载类加载时就初始化所有常量
序列化形式受限不能自定义 readObject

★ 更重要的认知 ✓✓:

单例是"全局状态",它会隐藏依赖、污染测试 ✗

本项目的测试隔离问题(T2-011)就与共享状态有关 ✓

现代做法:用容器管理"单实例 Bean"(Spring 默认单例),而不是手写 getInstance() ✓✓

5.2 工厂:三种形态与"改几处" ★★★ ​

★ 简单工厂 ✗(每加类型都要改它):

java
static Notification create(String channel) {
    return switch (channel) {
        case "email" -> new EmailNotification();
        case "sms"   -> new SmsNotification();
        default -> throw new IllegalArgumentException("未知渠道:" + channel);
    };
}

★ 注册式工厂 ✓✓(加类型只需注册一行):

java
RegistryFactory.register("email", EmailNotification::new);
RegistryFactory.register("dingtalk", DingTalkNotification::new);   // ★ 工厂代码不动

★ 实测对比 ✓:

要做的改动简单工厂注册式工厂
加一个新渠道修改 switch(动老代码) ✗注册一行 ✓✓
多人同时加渠道改同一个方法(冲突) ✗各加各的 ✓

★ 真实框架里的对应 ✓✓:

框架注册表
SpringbeanDefinitionMap(BeanFactory.getBean(name))✓
MyBatisTypeHandlerRegistry / MapperRegistry ✓
JDBCDriverManager(各驱动自己注册)✓

★ 工厂方法 vs 抽象工厂 ✓:

模式解决典型
工厂方法 ★★创建步骤固定,某一步因类型而异(本质是"模板方法 + 创建")报表生成器
抽象工厂 ★创建"一族"相关对象跨数据库的 Dialect 工厂

5.3 建造者:参数一多就该上场 ★★★ ​

✗ 12 参数的构造器:

java
new BadOrder("ORD-1", "U-1", "P-1", 2, 9900L,
             "上海市浦东新区", "张三", "138****0000",
             null, true, "尽快发货", "app");

★ 问题:参数顺序错一个就出 bug,而且编译器不报错 ✗✗

✓ 建造者:

java
Order.builder("ORD-1")
        .userId("U-1")
        .productId("P-1")
        .quantity(2)
        .address("上海市浦东新区")
        .urgent(true)
        .build();

★ 三项收益 ✓✓:

收益说明
字段名可读 ★★不用数参数位置
可给默认值 ✓quantity 默认 1、remark 默认空
校验集中且能一次报全 ★★★对比构造器只能报第一个错误

★ 第三个收益的实测(本项目代码里的输出):

text
      订单参数有误:[userId 不能为空, productId 不能为空, quantity 必须为正数, 加急订单必须有地址]

★ 什么时候用建造者(判断口诀) ✓✓:

信号说明
参数超过 4 个 ✓可读性下降
有很多可选参数 ★★★最典型的信号
需要在构建前做校验 ✓且想一次报全部问题

★ 反过来:3 个参数的简单对象用构造器就够了 ✗ ——上建造者只会更啰嗦 ✓

5.4 五种创建型的对照 ★★ ​

模式解决的问题典型例子
简单工厂把 if-else 创建逻辑收起来按 type 造对象
注册式工厂 ★★新增类型不改工厂(开闭)Spring BeanFactory
工厂方法创建步骤固定、某步因类型而异模板方法 + 创建
抽象工厂创建"一族"相关对象跨库 Dialect
建造者 ★★参数多、可选项多、要校验StringBuilder、OkHttp
单例全局唯一实例Spring 的单例 Bean

★ 一条实践提醒 ✓✓:

创建型模式的价值在"变化点被隔离",不在"多一层" ✓

如果一个对象只有 2 个参数、也永远不会变,直接 new 就是最好的设计 ✓✓

6. 递进示例 ​

6.1 最小示例:静态内部类单例(推荐写法之一) ​

java
public class Config {
    private Config() { }

    private static class Holder {
        static final Config INSTANCE = new Config();
    }

    public static Config getInstance() { return Holder.INSTANCE; }
}

★ 为什么它又好又快 ✓:利用"类加载只执行一次" —— 既懒加载(首次访问才初始化)又无需加锁 ✓✓

6.2 业务示例:用建造者做参数校验 ​

java
Order order = Order.builder(orderNo)
        .userId(userId)
        .quantity(quantity)          // 内部会校验 > 0
        .build();                    // ★ 校验失败时一次性列出所有问题

6.3 反例:手动实现单例的"双重检查"却忘了 volatile ​

java
private static DoubleChecked instance;      // ✗ 少了 volatile

7. 完整可运行代码与实测 ​

代码:labs/design-patterns/src/main/java/cn/kb/learn/dp/ 下的 SingletonLesson、FactoryBuilderLesson

运行:

powershell
cd labs
.\mvnw.cmd -pl design-patterns compile
java "-Dstdout.encoding=UTF-8" -cp design-patterns/target/classes cn.kb.learn.dp.SingletonLesson
java "-Dstdout.encoding=UTF-8" -cp design-patterns/target/classes cn.kb.learn.dp.FactoryBuilderLesson

实测结论(关键三条) ✓:

#结论
①五种单例在并发下都唯一;但反射与反序列化只有枚举挡得住 ✓✓
②同步懒汉最慢(每次进监视器);静态内部类与饿汉同量级 ✓
③注册式工厂让"加类型"从改老代码变成加新类 ✓✓

8. Debug 观察点 ​

  1. 用反射去破坏自己写的单例:看它是否真的"唯一" ★★★;
  2. 把单例序列化再反序列化:对比是否还是同一个对象 ★;
  3. 把双检锁的 volatile 去掉:跑并发压测,观察偶发问题(难复现,正是它的可怕之处)⚠️;
  4. 用建造者替代一个 6 参数的构造器:对比调用处的可读性 ✓。

9. 常见错误 ​

#错误说明
1双检锁忘了 volatile ✗✗可能拿到构造未完成的对象 ✓
2以为"私有构造器"能防住反射 ✗✗setAccessible(true) 就能绕过 ✓
3手写单例做全局状态 ✗✗隐藏依赖、污染测试 ✓
4把单例当"数据库连接"用 ✗✗连接是有状态资源,绝不能单例 ✓
53 个参数也上建造者 ✗过度设计 ✓
6每加一个类型就改工厂的 switch ✗违反开闭 ✗

10. 动手实验 ​

目标:亲手破坏一次单例,理解"为什么推荐枚举"。

步骤:

  1. 跑 SingletonLesson,确认五种的实例个数都是 1 ✓;
  2. 写反射代码破坏你项目里的某个单例 ★★★;
  3. 改用枚举实现,再攻一次 → 观察被 JVM 拒绝 ✓✓;
  4. 把某个 6 参数的构造器改成建造者,对比调用处 ✓;
  5. 把项目里的 if-else 工厂改成注册式,统计改动行数 ★。

验收方式:能说出"反射为什么能破坏单例、枚举为什么不能"。

扩展任务:给"消息通知"设计一个可扩展的创建方案:

  • 支持邮件/短信/企微,新增渠道不改老代码 ✓;
  • 每个渠道有必填参数(如短信要手机号、邮件要地址)✓;
  • 用建造者校验参数 ✓;
  • 说明:哪些地方用工厂、哪些用建造者、为什么 ★★

11. 练习题 ​

  1. 判断:用了私有构造器,单例就无法被反射破坏。(对 / 错)
  2. 填空:能同时挡住反射与反序列化的单例写法是 ______;双检锁必须加 ______ 关键字。
  3. 简答:为什么"单例是全局状态"这件事值得警惕?
  4. (代码阅读)
java
public class Cache {
    private static Cache instance;
    public static Cache getInstance() {
        if (instance == null) { instance = new Cache(); }
        return instance;
    }
}

这段代码有什么问题? 5. (设计)给"订单导出"设计创建方案:支持 CSV / Excel / PDF,新增格式不改老代码,每种格式有各自的可选参数。

参考答案(要点)
  1. 错。反射可以 setAccessible(true) 绕过私有构造器 ✗★
  2. 枚举;volatile ✓
  3. 因为它把依赖藏起来了 ✗ ——调用方看不出自己依赖了什么,测试之间会互相污染 ✓★
  4. ① 没有同步,并发下可能创建多个实例 ✗✗; ② 即使加 synchronized,每次都要进监视器(性能差) ⚠️
  5. 要点:注册式工厂按格式取实现 + 每种格式自己的 Builder 处理可选参数 ✓

12. 掌握标准 ​

  • [ ] 能说出五种单例写法的差别与推荐顺序 ★★★;
  • [ ] 知道反射与反序列化会破坏单例,而枚举挡得住 ★★★;
  • [ ] 知道单例是全局状态,优先用容器管理 ★★★;
  • [ ] 能区分三种工厂,并知道注册式工厂的可扩展优势 ★★★;
  • [ ] 知道建造者的三个收益与适用信号 ★★★;
  • [ ] 不做过度设计(3 个参数不用建造者)✓。

13. 扩展资源 ​

14. 下一步学习 ​

《设计模式:结构型》:装饰器与代理 —— 用组合替代继承。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引