设计模式(一):设计原则与创建型模式
设计模式(一):设计原则与创建型模式
导语:设计模式面试的第一层是「原则」——不背原则直接讲模式,会被认为是"背了 23 个名字";第二层是「创建型」——单例、工厂、Builder 是真正的必考项。本篇给出设计模式的标准答题结构、SOLID 五原则的落地口径、工厂三兄弟的准确区分、单例的六种写法与三种破坏方式,以及 Builder/原型的适用边界,共 11 题。
一、总纲与答题方法
1. 什么是设计模式?23 种模式怎么分类?该不该多用?
答:
定义:设计模式是针对特定语境下反复出现的软件设计问题的、可复用的解决方案模板——它不是可以直接复制粘贴的代码,而是描述"类与对象如何组织协作"的经验总结。它的两个价值:
- 解决设计问题(应对变化、降低耦合);
- 沟通的通用语言(说"这里用责任链"比描述一堆类关系高效得多)。
GoF 的三大分类(23 种):
记忆口诀:创建型管"生"、结构型管"配"、行为型管"动"。
面试追问:「设计模式是不是用得越多越好?」
不是。 这是最容易被追问、也最能体现成熟度的一问,标准答案要包含三层:
| 观点 | 说明 |
|---|---|
| 模式的本质是"应对变化" | 模式的价值在于降低未来变化的成本。如果一段代码根本不会变化(如工具方法、一次性脚本),上模式就是纯粹的负债 |
| 过度抽象的代价是真实的 | 每加一层抽象就多一次跳转:阅读成本上升、调试变难、新人上手变慢,"看代码要跳 8 个类才知道最终执行了什么" |
| 判断标准:变化点是否存在且可持续 | 只有当同一个地方反复出现同类变化(如不停加支付渠道、不停加校验规则)时,抽象才有正收益 |
工程上的稳妥姿势(面试可直接说):
「先写能工作的简单代码,等到第二次/第三次出现同类变化时再重构出模式」
(YAGNI + 三次法则)
—— 而不是一开始就为"未来可能的扩展"设计一堆接口与工厂。2. 设计模式题的标准回答结构是什么?(七步法)
答: 面试被问"XX 模式是什么"时,按固定结构作答,能显著提升条理性与专业度:
① 定义 —— 一句话说清它是什么
② 解决什么问题 —— 它针对的是哪种"痛点"(这是最关键的一步)
③ 结构 —— 有哪些角色、怎么协作(能画出来最好)
④ 优缺点 —— 尤其是"代价"(只说优点会被认为没实践)
⑤ 应用场景 —— 业务里哪里用
⑥ 源码/框架举例 —— JDK / Spring / Netty / Dubbo 哪里用
⑦ 主动对比 —— 说一个最容易被混淆的邻居模式(这是个"送分动作")示例(策略模式,可直接背):
「策略模式本质上是把一组可互换的算法各自封装起来,让 Context 面向策略接口编程。它主要解决大量条件分支和算法频繁变化的问题,结构上是
Context + Strategy + ConcreteStrategy。好处是新增算法不改 Context、符合开闭原则、每个算法可独立测试;代价是策略类数量会膨胀,且调用方需要知道有哪些策略。项目里可用于支付渠道、促销规则、排序规则。JDK 的Comparator就是典型——Collections.sort()接收不同的比较器。它和状态模式结构几乎一样,但策略强调"选哪种算法",状态强调"状态变化导致行为变化"。」
一句话:七步法的核心是「②解决什么问题」和「④代价」——这两步能把你和"背名字的人"区分开。
3. SOLID 五大原则是什么?最关键的是哪个?
答:
展开说明(面试要能各举一个反例):
| 原则 | 常见误解 | 反例 / 落地口径 |
|---|---|---|
| SRP | "一个类只能有一个方法" | ❌ 错。正确理解是"一个变化原因"。反例:OrderService 里同时创建订单、扣库存、发邮件、开发票——库存规则/邮件模板/发票规则任一变化都要改它 → 变化原因有 4 个,违反 SRP |
| OCP | "代码永远不能改" | 不是禁止修改,而是"通过新增实现来扩展,而不是修改稳定核心"。反例:if ("ALI".equals(type)) ... else if ("WECHAT"...) 每加一个支付方式都改核心 |
| LSP | "继承就是 is-a" | 关键是行为一致性。经典反例:Rectangle.setWidth() 被 Square 覆写成"宽高联动",导致调用方 setWidth(5); setHeight(6); 期望面积 30 却得到 36 |
| ISP | "接口越少越好" | 是按客户端需求拆分,不要设计"胖接口"逼实现类写一堆空方法。反例:Animal 接口含 fly(),让 Dog 被迫实现 fly() 抛异常 |
| DIP | "DIP 就是依赖注入" | ⚠️ 两者不同:DIP 是设计原则(依赖抽象),DI 是实现手段(把依赖从外部传进来)。DI 可以没有 DIP(注入的还是具体类),DIP 也可以不用 DI(内部 new 一个抽象的实现) |
为什么 OCP 是核心(面试必答的落点):
其余四个原则的最终目的,都是为了让"新增功能"不必修改已有稳定代码:
· SRP 把变化点收拢到单一职责的类里 → 改一个类而不是改五个
· LSP/ISP 保证抽象层是"可靠、最小"的 → 新实现可以放心替换、不必实现无用的方法
· DIP 让依赖指向抽象 → 新增实现不需要改动调用方
→ 所以:**"策略/工厂/观察者/装饰器/责任链"这些模式,全部是 OCP 的具体落地形态。**4. 除了 SOLID,还有哪些重要设计原则?
答: 两个高频补充(面试常问"solo 之外还知道什么"):
| 原则 | 一句话 | 落地体现 |
|---|---|---|
| 合成/聚合复用原则(CARP) | 优先用组合(has-a)而不是继承(is-a)来复用代码 | 继承是白盒复用(暴露父类实现细节、编译期强耦合、受单继承限制);组合是黑盒复用(只依赖接口、运行时可替换)。对象适配器(组合)优于类适配器(继承)、装饰器(组合)优于继承,都是这条原则的体现 |
| 迪米特法则(LoD,最少知识原则) | 只和"直接朋友"打交道,一个对象应尽量少了解其他对象 | 反例:order.getUser().getAddress().getCity() 这种"链式穿透"——调用方依赖了 3 层的内部结构,User 一旦重构就全线崩。正确做法是 order.getUserCity()(把"了解"收敛在 Order 内部) |
【继承 vs 组合 的选择】
继承:is-a 关系 + 需要"被当作父类使用" + 父类行为确实通用
组合:只是"想复用一段实现" —— 绝大多数情况都应该选它
⚠️ 一个常被引用的结论:**"优先使用组合"** —— 因为继承会把父类的实现"焊"进子类,
一旦父类变化,所有子类都被动受影响(也就是最容易违反 OCP)。二、创建型模式
5. 工厂三兄弟(简单工厂 / 工厂方法 / 抽象工厂)怎么区分?
答: 用一张图先记住结构差异:
| 维度 | 简单工厂 | 工厂方法 | 抽象工厂 |
|---|---|---|---|
| 关注点 | 创建单一类型产品 | 创建单一类型产品 | 创建一族相关产品 |
| 结构 | 一个具体工厂 + 判断分支 | 抽象工厂 + 多个具体工厂 | 抽象工厂定义多个创建方法 |
| 违反 OCP 的方向 | 新增产品时违反 | 基本不违反 | 新增产品等级时违反 |
| 复杂度 | 低 | 中 | 高 |
| 适用 | 产品少、几乎不扩展 | 产品会持续增加 | 产品族需要成套切换(换皮肤/多数据库) |
两个关键概念(抽象工厂必问):
· **产品等级结构**:同一类产品的不同实现(Button → WindowsButton / MacButton)
· **产品族**:同一风格/厂商下的一组产品(Windows 风格 = WindowsButton + WindowsTextBox + WindowsMenu)
记忆:
**工厂方法扩展"产品"容易,抽象工厂扩展"产品族"容易**
—— 抽象工厂加"新产品等级"(如加 Mouse)要动接口,是全模式链最痛的地方框架中的实例(面试举例):
| 模式 | 实例 |
|---|---|
| 工厂方法 | Collection.iterator()——ArrayList、LinkedList 各自返回自己的迭代器实现;LoggerFactory.getLogger() |
| 抽象工厂 | JDBC——Connection/Statement/ResultSet 构成产品族,不同数据库驱动充当具体工厂;Spring 的 BeanFactory 家族 |
| 简单工厂 | Calendar.getInstance()、NumberFormat.getInstance()、(争议)DriverManager.getConnection() |
追问:「简单工厂与静态工厂有什么区别?」
| 维度 | 静态工厂(Effective Java 推荐技巧) | 简单工厂 |
|---|---|---|
| 位置 | 创建方法内嵌在产品类里(Phone.create()、LocalDate.of()) | 独立的工厂类 |
| 职责 | 通常创建同一类产品(或其变体),并可"有名字"(of/valueOf/getInstance) | 创建多个不同类型但相关的产品 |
| 判别依据 | 方法名可读(BigInteger.probablePrime() 比构造器清晰) | 参数决定类型(必须传 type) |
注意:静态工厂 ≠ 工厂方法模式,它只是《Effective Java》推荐的"创建对象技巧",严格来说不属于 GoF 23 种——这一点说清楚是加分项。
6. 单例模式有哪几种写法?各有什么问题?
答: 六种写法,核心差异在于「是否懒加载」+「如何保证线程安全」:
| 写法 | 懒加载 | 线程安全 | 结论 |
|---|---|---|---|
| ① 饿汉式 | ❌ 类加载即创建 | ✅ JVM 保证 | 简单可靠,但可能浪费内存(以及类加载时就做初始化可能有副作用) |
| ② 普通懒汉式 | ✅ | ❌ 不安全 | 多线程下会创建多个实例(必须指出这是错的) |
③ synchronized 方法懒汉 | ✅ | ✅ | 性能差——每次 getInstance() 都加锁解锁,即使实例早已创建 |
| ④ 双重检查锁 DCL | ✅ | ✅(必须 volatile) | 性能与安全兼顾,但写法复杂、易写错 |
| ⑤ 静态内部类(Holder) | ✅ | ✅ | 推荐——利用 JVM 类加载机制(<clinit> 由 JVM 保证线程安全),优雅且高效 |
| ⑥ 枚举 | ❌ 类加载即创建 | ✅ | Effective Java 首推——最简洁 + 天然防反射与反序列化 |
// ④ DCL(注意 volatile 不能少)
public class Singleton {
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查:已创建则直接返回,避免加锁
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查:防止多个线程都通过第一次检查
instance = new Singleton();
}
}
}
return instance;
}
}
// ⑤ 静态内部类(推荐)
public class Singleton {
private Singleton() {}
private static class Holder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() { return Holder.INSTANCE; }
}
// ⑥ 枚举(最推荐)
public enum Singleton {
INSTANCE;
public void doSomething() { }
}单例的缺点(面试要主动说,否则只答"优点是省内存"显得不完整):
| 缺点 | 说明 |
|---|---|
| 违反 SRP | 单例既要保证"唯一性",又要承担业务逻辑,是两个变化原因 |
| 扩展性差 | 没有抽象层,难以替换/多态(所以现在多推荐交给 Spring 容器管理单例而不是手写) |
| 测试困难 | 全局状态导致测试之间有隐式耦合、并行测试互相污染 |
| 隐藏依赖 | 直接在方法里 Singleton.getInstance(),依赖关系不体现在构造器/参数上,调用方无法从签名看出依赖 |
7. DCL 为什么必须加 volatile?两次检查各有什么作用?
答: 这是单例最高频的追问,必须讲清"重排序"这一层。
【问题根源】对象创建不是原子操作,实际分为三步:
① 分配内存
② 初始化对象(执行构造器、填字段)
③ 把 instance 引用指向这块内存
【危险的重排序】②③ 之间没有数据依赖,JIT/CPU 可能把顺序变成 ① → ③ → ②:
T1 执行 getInstance(),执行到 ① ③(引用已赋值,但对象还没初始化完)
│
▼
T2 同时调用 getInstance(),第一次检查发现 instance != null
│
▼
T2 直接返回 instance 并开始使用 → **拿到一个"半成品"对象**
(字段还是默认值!可能引发 NPE 或诡异的业务错误,且极难复现)| 检查 | 作用 |
|---|---|
| 第一次检查(同步块外) | 性能优化:实例已创建时直接返回,完全不加锁(否则每次调用都要走 synchronized,与第 ③ 种写法没差别) |
| 第二次检查(同步块内) | 正确性保证:多个线程同时通过第一次检查、排队进入同步块时,保证只有一个线程真正创建实例 |
一句话总结:
volatile在这里提供两件事——禁止 ②③ 指令重排 + 保证引用赋值的可见性。缺了它,DCL 就是一个看起来对、线上偶发错的写法(这也是它臭名昭著的原因)。
8. 单例会被怎么破坏?如何防范?
答: 三种破坏方式,枚举对前两种天然免疫——这是"为什么推荐枚举"的真正原因。
| 破坏方式 | 原理 | 防范 |
|---|---|---|
| ① 反射破坏 | 普通单例有私有构造器,但 Constructor.setAccessible(true) + newInstance() 可以绕过访问控制创建新实例 | 枚举天然免疫:JVM 明确规定不能反射创建枚举实例,newInstance() 会抛 IllegalArgumentException: Cannot reflectively create enum objects。普通类则在构造器里加"已存在则抛异常"的判断(要防并发,需加锁/静态标志) |
| ② 反序列化破坏 | 实现 Serializable 的类,反序列化会通过反射创建新对象(不走构造器,所以构造器里的检查也拦不住) | 枚举天然免疫(JVM 在反序列化时按名字返回已有实例);普通类实现 readResolve() 返回既有实例 |
| ③ 类加载器破坏 | 同一个 .class 被两个不同的类加载器加载,会产生两个 Class 对象 → 各自有独立的静态字段 → 两个实例 | 保证单例类由同一类加载器加载;注意容器/热部署/Oracle 驱动场景(这也是"同一个类却 instanceof 失败"的经典原因) |
追问:「Spring 的 Bean 单例和 GoF 单例是一回事吗?」——不是。
| 维度 | GoF 单例 | Spring Bean singleton |
|---|---|---|
| 唯一性范围 | 每个类加载器内唯一 | 每个 IoC 容器内唯一(多个容器 = 多个实例) |
| 实现方式 | 私有构造 + 静态方法自持实例 | 容器用 ConcurrentHashMap(singletonObjects)缓存并注入 |
| 能否反序列化/反射破坏 | 需要考虑 | 不涉及(对象由容器 new,构造器可以是 public) |
| 本质 | 语言层面的模式 | 容器的 Bean 作用域策略 |
一句话:「Spring 的 singleton 是"作用域",GoF 的 Singleton 是"模式"——把 Bean 交给容器管理,通常比自己写单例更好(依赖显式、可测试、可替换)。」
9. 建造者模式(Builder)什么时候该用?和工厂有什么区别?
答:
解决的问题:对象参数多、可选参数多、构造过程复杂时,用构造器会遇到两个痛点:
【痛点一:重叠构造器(telescoping constructor)——参数爆炸】
new User(name, age, city, phone, email, ...) // 想只传 name 和 city 怎么办?
new User(name, age) // 只能靠重载组合,组合数爆炸
new User(name, city) // ⚠️ 编译期无法区分,容易传错顺序!
【痛点二:Setter 分步设置——对象可能处于"半成品"状态】
User u = new User();
u.setName("Tom"); // 此时对象不完整,别人拿到可能误用
u.setAge(20); // 也无法保证"必须设置的字段"真的被设置了Builder 的解法:把"构建过程"从"产品对象"中分离,用链式调用逐步设置 + build() 一次性产出完整对象:
User user = new User.Builder()
.name("Tom")
.age(20)
.city("LA")
.build(); // ★ build() 里可以统一做校验:必填项是否齐全、参数是否合法| 适用信号 | 说明 |
|---|---|
| 参数多(尤其是可选参数多) | 构造器参数超过 3~4 个就可考虑 |
| 构建过程有约束/校验 | 必填字段、互斥字段、范围校验,统一放在 build() |
| 需要不可变对象 | Builder 内部可变、Product 构造器私有且字段 final → 天然线程安全 |
| 构建过程需要分步、可复用 | 如"基础配置 Builder"派生出多个变体 |
与工厂的区别(高频对比):
| 维度 | 工厂模式 | Builder |
|---|---|---|
| 关注点 | 创建哪一个对象(类型的选择) | 如何一步步构造一个复杂对象 |
| 产出 | 通常一步返回产品 | 多步设置后 build() 产出 |
| 解决的问题 | 对象创建与使用解耦、隐藏创建细节 | 参数爆炸 + 构建约束 + 不可变性 |
| 典型形态 | Factory.create(type) | new Builder().a().b().build() |
| 关系 | 可以组合:Builder 内部也可以调用工厂来创建部件 | 同左 |
JDK/框架实例(面试举例):
| 实例 | 说明 |
|---|---|
StringBuilder / StringBuffer | append() 是构建过程,toString() 是产出(经典教材例子,但严格说它更像"可变字符序列") |
ServerBootstrap / Bootstrap(Netty) | group().channel().childHandler() 链式配置后 bind()/connect() 启动,最典型的实战 Builder |
OkHttpClient.Builder / Request.Builder | 网络客户端配置 |
AlertDialog.Builder(Android) | UI 构建 |
Lombok @Builder / @SuperBuilder | 编译期生成 Builder,业务代码里最常见的落地方式 |
Stream.Builder、Calendar.Builder | JDK 内置 |
10. 原型模式了解吗?浅拷贝与深拷贝怎么选?
答:
核心:通过复制已有实例(原型)来创建新对象,而不是 new。适用于创建成本高(如需要查库/远程调用/复杂计算才能初始化)或需要大量相似对象的场景。
class Sheep implements Cloneable {
private String name;
private List<String> tags; // 引用类型字段
@Override
public Sheep clone() {
try {
return (Sheep) super.clone(); // ⚠️ Object.clone() 是【浅拷贝】
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
}浅拷贝 vs 深拷贝(必问):
① 浅拷贝(shallow copy)—— 只复制「引用」,不复制引用指向的对象
⚠️ 两个对象共享同一个
List→ 改克隆对象的 list,原对象也变了(最常见的坑)。String不可变所以浅拷贝是安全的,但List/Map/自定义对象必须深拷贝。
② 深拷贝(deep copy)—— 递归复制所有引用指向的对象
✅ 两个 List 完全独立,互不影响。
深拷贝的三种实现方式:
| 方式 | 做法 | 评价 |
|---|---|---|
递归调用 clone() | 每个引用字段也手动 clone(),并保证引用链上每个类都实现了 Cloneable | 繁琐、易漏(漏一层就是浅拷贝)、引用链深时很难维护 |
| 序列化/反序列化 | 对象实现 Serializable,通过字节流复制(ObjectOutputStream → ObjectInputStream,或用 JSON 序列化) | 实现简单、能处理任意深度的对象图;但性能差、要求所有对象可序列化 |
| 手写拷贝构造器/工厂方法 | new Sheep(otherSheep) 显式逐字段复制 | 最可控、最推荐(清晰、可校验、无序列化开销) |
三个面试要点:
Object.clone()是浅拷贝——这是题目的"考点陷阱",答"clone 就是深拷贝"直接扣分;String是特例但不影响结论:String不可变,所以浅拷贝String字段是安全的;但List/Map/自定义对象就必须深拷贝;- 原型模式与"设计模式滥用"的关系:Spring 的
prototype作用域不是原型模式(只是"每次都创建新 Bean");BeanUtils.copyProperties也不是原型模式(是属性拷贝工具)。说清这个区分是加分项。
11. 创建型模式怎么选型?(对比与决策)
答: 面试常以"给你一个场景,你选哪个"来考察,用这张表 + 决策树回答最清晰:
| 模式 | 一句话定位 | 关键判据(什么时候用它) | 代价 |
|---|---|---|---|
| 单例 Singleton | 全局唯一实例 | 需要唯一的共享资源管理(配置、连接池、注册表) | 难测试、隐藏依赖、违反 SRP → 优先交给 Spring 容器 |
| 简单工厂 | 集中创建 + 参数决定类型 | 产品类型少、几乎不扩展(内部工具类) | 违反 OCP |
| 工厂方法 | 创建延迟到子类 | 产品会持续增加,且每个产品的创建逻辑彼此独立 | 类数量翻倍 |
| 抽象工厂 | 创建产品族 | 需要成套切换(换皮肤/换数据库/多云适配) | 加"产品等级"时痛 |
| Builder | 分步构建复杂对象 | 参数多/可选多/有构建约束/要不可变 | 多一个 Builder 类;简单对象用它是过度设计 |
| 原型 Prototype | 复制已有对象 | 创建成本高、或需要大量相似对象、或需要"快照"语义 | 深拷贝实现麻烦 |
框架对照速查(回答"哪里用了什么"时用):
| 模式 | Spring | JDK | Netty | Dubbo |
|---|---|---|---|---|
| 单例 | Bean 默认 singleton 作用域 | Runtime.getRuntime()、Collections.EMPTY_LIST | DefaultSelectStrategy 等 INSTANCE | ExtensionLoader 缓存 |
| 工厂方法 | BeanFactory / FactoryBean | Collection.iterator()、Files.newInputStream() | ReflectiveChannelFactory | ProxyFactory 各实现 |
| 抽象工厂 | ApplicationContext(产品族:Bean + 资源 + 事件) | java.sql(Driver 家族) | — | — |
| Builder | BeanDefinitionBuilder | StringBuilder、Stream.Builder | ServerBootstrap | ServiceConfig 链式配置 |
| 原型 | prototype 作用域(⚠️ 非原型模式) | Object.clone() | — | — |
下一篇:《设计模式(二)》进入结构型模式与框架源码应用——适配器的两种实现与选择、代理模式三件套(静态代理 / JDK 动态代理 / CGLIB)与 Spring AOP 的代理选择规则、装饰器与 Java IO、桥接与 JDBC、外观/享元/组合速览,并系统盘点 Spring / JDK / Netty / Dubbo 四大框架中的设计模式。
