设计模式面试题精选
Java 后端真实面试专题 · 设计模式篇
真实面经里“用过哪些设计模式”“手写单例”“策略 + 工厂怎么实现”出现频率很高。设计模式题不要只背名称,建议按三段回答: ① 标准答(定义、结构、解决的问题和关键实现)→ ② 拓展与边界追问(为什么、何时不用、有什么代价)→ ③ 项目落地(具体位置、改造前后的差异)。
年限标签:
🟢 3年内🔴 3年+
1. 🟢 设计模式分哪几类?六大设计原则(SOLID)是什么?
标准答:
GoF 的 23 种经典模式按意图分三类。完整分类如下,面试时不必机械背完,但要知道常见模式属于哪一类:
| 类别 | 关注点 | 经典模式 |
|---|---|---|
| 创建型 | 对象如何创建、如何隐藏构造细节 | 工厂方法、抽象工厂、建造者、原型、单例 |
| 结构型 | 类和对象如何组合成更大的结构 | 适配器、桥接、组合、装饰器、外观、享元、代理 |
| 行为型 | 对象之间如何分工、协作和传递职责 | 责任链、命令、解释器、迭代器、中介者、备忘录、观察者、状态、策略、模板方法、访问者 |
“工厂模式”在口语里常常统称简单工厂、工厂方法和抽象工厂,但严格说 GoF 目录中的创建型模式是工厂方法和抽象工厂;简单工厂是常用的变体,并不是 GoF 23 种之一。
六大设计原则通常是在 SOLID 五原则之外再加迪米特法则:
- 单一职责(SRP):一个类因为一个主要原因发生变化。它不是要求类只能有一个方法,而是要求职责边界和变化原因相对集中。
- 开闭原则(OCP):对扩展开放、对修改关闭。通过抽象、多态、配置或插件点增加能力,尽量不改稳定的核心流程。
- 里氏替换(LSP):子类型必须能替换父类型,并遵守父类型的行为契约,而不是只满足方法签名。
- 依赖倒置(DIP):高层策略和底层细节都依赖稳定抽象,细节通过依赖注入实现;它不是“所有类都必须有接口”。
- 接口隔离(ISP):客户端不应被迫依赖自己用不到的方法,宁可拆成多个小而稳定的能力接口。
- 迪米特法则(LoD):一个对象只与直接朋友通信,减少对陌生对象内部结构的链式依赖。
这些原则之间有取舍:抽象太多会增加理解和装配成本,OCP 也不等于“任何地方都不能修改”。应先识别稳定的变化点,再建立适度的扩展边界。
拓展与边界追问:
- “最核心的原则是哪一个?”没有绝对答案。实际扩展题里经常落到 OCP,但 SRP、DIP、LSP 是它能成立的前提。
- “设计模式和设计原则是什么关系?”原则是判断设计好坏的方向,模式是反复出现的解决方案。模式不是目标,更不是类越多越高级。
- “怎么判断该不该抽象?”看同一变化是否已经出现多次、是否有明确的稳定契约,以及抽象后测试和演进是否更容易;一次性、变化未知的代码过早抽象反而会增加成本。
项目落地 ⭐:可以这样答:“我先识别项目里可能持续增加的变化点,例如支付渠道、文件导入格式和通知方式,再用接口隔离稳定契约。新增实现时只增加类和装配配置,核心流程不改;如果某个点只有一个实现且没有明确变化迹象,我会先保持简单,不为了凑模式而抽象。”
2. 🟢 手写一个单例模式?有哪几种写法?
标准答:
单例的目标是让一个类在某个作用域内只有一个可访问实例,并提供统一访问入口。先说明作用域很重要:JVM 中不同类加载器、Spring 中不同容器都可能各自有实例,业务上说的“全局唯一”不能脱离作用域。
常见实现有以下几种:
- 饿汉式:类初始化时直接创建实例,简单且由类加载保证线程安全,但实例可能一直用不到。
- 懒汉式加锁:第一次调用时创建,写法简单但每次访问都可能付出锁开销。
- 双重检查锁(DCL):外层判空减少加锁,内层判空保证只创建一次;实例必须声明为
volatile。 - 静态内部类:利用内部类的延迟初始化,通常是手写单例中最简洁的方案。
- 枚举:由 JVM 保证实例化和反序列化语义,能抵抗常见反射、序列化破坏,适合无须继承的单例。
DCL 示例:
public final class ConfigCenter {
private static volatile ConfigCenter instance;
private ConfigCenter() {
// 防止普通代码直接 new
}
public static ConfigCenter getInstance() {
if (instance == null) {
synchronized (ConfigCenter.class) {
if (instance == null) {
instance = new ConfigCenter();
}
}
}
return instance;
}
}
new ConfigCenter() 可以抽象成“分配内存、执行构造、把引用赋给变量”几个步骤;没有 volatile 时,编译器或 CPU 可能重排为先发布引用、后执行构造,另一个线程就可能读到未完全初始化的对象。
静态内部类写法:
public final class MetricsRegistry {
private MetricsRegistry() {}
private static class Holder {
private static final MetricsRegistry INSTANCE = new MetricsRegistry();
}
public static MetricsRegistry getInstance() {
return Holder.INSTANCE;
}
}
拓展与边界追问:
- “为什么要双重检查?”第一次检查避免每次访问都进入同步块,第二次检查避免多个线程排队后重复创建。
- “枚举是不是任何场景都最好?”不是。枚举不能延迟传参、不能继承其他类,且把全局状态藏起来会降低可测试性;配置、数据库客户端等业务对象通常交给依赖注入容器管理更合适。
- “单例有哪些代价?”它引入隐式全局状态,可能造成测试相互污染、生命周期难管理和并发状态共享。只有确实需要共享且生命周期明确时才使用。
- Spring 的
singleton是每个ApplicationContext一个实例,不是跨 JVM、跨进程的分布式单例;集群唯一性应使用外部协调机制。
项目落地 ⭐:可以说:“项目里的服务对象通常交给 Spring 单例作用域,由容器负责创建和销毁;需要手写时我会优先用静态内部类,若题目追问并发则写 DCL 并解释 volatile。我也会说明单例只解决进程内生命周期问题,不能拿它实现分布式锁或集群唯一。”
3. 🟢 策略模式是什么?怎么实现?解决什么问题?
标准答:
策略模式把一组可互换的算法或业务规则分别封装成策略对象,由上下文在运行时选择其中一个执行。它解决的是“同一个流程中有多个变化算法,且分支会持续增加”的问题,让调用方依赖统一接口,而不是堆叠 if-else。
典型结构包括:
- Strategy:稳定的策略接口,定义输入、输出和异常契约。
- ConcreteStrategy:每种算法一个实现,尽量只负责自己的规则。
- Context:保存或接收策略,负责公共前置校验、调用和结果汇总,不把具体算法重新写一遍。
- Selector/Factory:根据类型、配置或请求属性选择策略;它与策略本身是两个职责。
public interface DiscountStrategy {
Money calculate(Order order);
}
@Component("member")
final class MemberDiscount implements DiscountStrategy {
@Override
public Money calculate(Order order) { return order.memberPrice(); }
}
@Component("coupon")
final class CouponDiscount implements DiscountStrategy {
@Override
public Money calculate(Order order) { return order.couponPrice(); }
}
@Service
final class PriceService {
private final Map<String, DiscountStrategy> strategies;
PriceService(Map<String, DiscountStrategy> strategies) {
this.strategies = strategies;
}
Money quote(String type, Order order) {
DiscountStrategy strategy = strategies.get(type);
if (strategy == null) {
throw new IllegalArgumentException("unsupported discount type: " + type);
}
return strategy.calculate(order);
}
}
拓展与边界追问:
- “怎么彻底去掉选择分支?”可以使用
Map<key, Strategy>、枚举映射或 Spring 注入的实现集合,但仍要处理 key 重复、缺失、大小写、版本兼容和默认策略;把分支藏进一个巨大工厂并没有真正解决问题。 - “策略和状态模式有什么区别?”策略由调用方或上下文选择算法,通常一次请求选择一个;状态模式由对象内部状态驱动行为,并可能在执行后切换状态。
- “策略和模板方法怎么选?”步骤整体不同用策略;流程骨架固定、只有少数步骤可变时用模板方法。也可以组合使用。
- 策略不是为了消灭所有
if。只有分支逻辑有独立变化、测试和复用价值时才拆类;两行稳定分支拆成十个类会降低可读性。
项目落地 ⭐:可以这样答:“多渠道支付或多种促销规则都实现同一个策略接口,入口只做参数校验和选择,具体计算放在策略类里。新增渠道只增加实现和注册配置,原有渠道无需修改;我还会给每个策略单独写边界测试,避免选择器变成新的单体分支。”
4. 🟢 工厂模式?简单工厂、工厂方法、抽象工厂的区别?
标准答:
工厂模式把“创建哪个对象、如何组装对象”从业务使用方中隔离出来。调用方依赖产品接口,不直接依赖具体类和复杂构造过程。
| 变体 | 结构 | 适用场景 | 主要代价 |
|---|---|---|---|
| 简单工厂 | 一个工厂根据参数返回不同产品 | 产品集合稳定、分支少 | 新增产品通常要修改工厂分支 |
| 工厂方法 | 每类产品有对应工厂,工厂本身可多态 | 产品扩展频繁、创建逻辑差异大 | 工厂类数量增加 |
| 抽象工厂 | 一个工厂生产一族相互匹配的产品 | 需要保证同一系列组件成套出现 | 产品维度增加时接口演进较麻烦 |
简单工厂示例:
interface Parser { Document parse(byte[] source); }
final class ParserFactory {
private ParserFactory() {}
static Parser create(String format) {
return switch (format) {
case "json" -> new JsonParser();
case "xml" -> new XmlParser();
default -> throw new IllegalArgumentException("unsupported format: " + format);
};
}
}
在扩展频繁的系统中,工厂可以只负责注册表和生命周期,具体实现由依赖注入容器发现:
final class ParserFactory {
private final Map<String, Parser> parsers;
ParserFactory(Map<String, Parser> parsers) {
this.parsers = Map.copyOf(parsers);
}
Parser get(String format) {
Parser parser = parsers.get(format);
if (parser == null) throw new IllegalArgumentException("unsupported format: " + format);
return parser;
}
}
不要把工厂和依赖注入混为一谈:DI 负责把依赖装配进来,工厂负责隐藏创建/选择决策;两者经常一起使用。
拓展与边界追问:
- “简单工厂违反开闭原则吗?”如果每增加产品都改工厂,确实存在修改点;但产品集合很小且稳定时,集中分支可能比过度抽象更清晰。
- “抽象工厂和工厂方法怎么区分?”工厂方法通常关注一个产品层级的创建;抽象工厂关注多个相关产品之间的兼容组合,例如同一主题的一套组件。
- “工厂返回接口有什么好处?”隐藏具体类、集中校验和生命周期管理,调用方可以替换实现;但工厂不应吞掉创建异常或偷偷改变业务语义。
- “Spring 的
BeanFactory是不是工厂?”它确实承担对象获取和生命周期管理,但还叠加了 IoC、作用域、后处理器等容器能力,回答时不要把整个 Spring 简化成一个switch工厂。
项目落地 ⭐:可以说:“我把第三方支付客户端、文件解析器等创建细节放到工厂/注册表,业务服务只拿统一接口。创建过程中的凭证校验、超时配置和客户端复用集中处理;当实现数量增长时,用 Spring 注入 Map<String, Handler>,新增实现不需要改选择器。”
5. 🔴 责任链模式是什么?用在什么场景?
标准答:
责任链把多个处理者按顺序连接起来,请求从链头进入,每个处理者可以处理、拒绝、修改上下文,或把请求交给下一个处理者。调用方只依赖链的入口,不需要知道具体处理者的排列和内部逻辑。
最小实现可以把下一个节点显式传入:
public interface Handler {
void handle(Context context);
}
public abstract class ChainHandler implements Handler {
private Handler next;
public ChainHandler next(Handler next) {
this.next = next;
return this;
}
protected final void forward(Context context) {
if (next != null) next.handle(context);
}
}
final class ValidateHandler extends ChainHandler {
@Override public void handle(Context context) {
validate(context);
forward(context);
}
}
常见场景有 Servlet Filter、Spring Security 过滤器链、网关过滤器、订单/审批校验、风控规则和日志处理管道。链上的节点应保持单一职责,链的组装和顺序最好显式可追踪。
拓展与边界追问:
- “每个节点都必须继续传递吗?”不必须。鉴权失败、参数不合法或命中短路条件时应停止;这也是责任链与无条件广播的区别。
- “链顺序错了会怎样?”可能出现越权、重复查询、错误信息被覆盖或性能浪费。应给顺序写集成测试,并在日志中记录节点名称、耗时和短路原因。
- “责任链和管道有什么区别?”管道通常强调每个阶段都处理并传递变换后的数据;责任链强调某个处理者可以接管或拒绝请求,实际工程里两者常重叠。
- 注意循环链、重复执行和共享可变上下文。异步节点还要定义超时、失败策略和是否允许并行,不能只把同步链改成线程池。
项目落地 ⭐:可以这样答:“我把下单前的参数、权限、库存和风险校验拆成多个 Handler,由配置或装配代码决定顺序;失败节点写入统一错误码并短路,成功才进入核心服务。新增规则只增加节点和测试,不在一个方法里继续堆条件分支;同时给链路加节点耗时日志,便于定位慢点。”
6. 🟢 观察者模式是什么?
标准答:
观察者模式定义一对多依赖:主题(Subject)维护观察者集合,状态或事件发生后通知所有观察者。主题只依赖观察者约定,不依赖每个观察者的具体业务,从而把事件产生和后续处理解耦。
同步进程内实现示例:
interface OrderListener {
void onCreated(OrderCreatedEvent event);
}
final class OrderEventBus {
private final List<OrderListener> listeners = new CopyOnWriteArrayList<>();
void register(OrderListener listener) { listeners.add(listener); }
void publish(OrderCreatedEvent event) {
for (OrderListener listener : listeners) {
listener.onCreated(event);
}
}
}
Spring 的 ApplicationEventPublisher 和 @EventListener 是观察者思想的容器化实现。它默认是进程内调用,发布线程通常会等待监听器执行完成;MQ 的发布订阅则把观察者扩展到跨进程,并增加持久化、重试和消费进度等能力。
拓展与边界追问:
- “观察者异常会影响发布方吗?”同步监听器的异常可能向上传播或中断后续监听器,必须明确隔离策略:是否逐个捕获、是否允许失败、是否记录告警。异步消息则需要重试、死信和幂等。
- “事件应该在事务提交前还是提交后发布?”如果监听器依赖已提交数据,通常选择事务提交后阶段;否则监听器可能读到回滚的数据。跨服务还要考虑事务消息或 Outbox,不能只在本地事务里
publish就认为可靠送达。 - “观察者会不会造成内存泄漏?”长生命周期主题持有短生命周期观察者时可能泄漏,应提供注销机制,或交给容器管理生命周期。
- 事件对象应尽量不可变、包含稳定标识而非大对象快照;监听器需要最新数据时自行查询,并评估一致性和额外负载。
项目落地 ⭐:可以说:“用户注册/订单状态变化后,我通过 Spring 事件把发券、通知、审计等动作拆开,主流程只负责产生领域事件。同步监听器只做轻量且必须立即完成的工作,耗时或跨服务动作改用 MQ;事件消费按业务唯一键做幂等,并记录失败重试。”
7. 🟢 模板方法模式是什么?
标准答:
模板方法在父类中定义不可随意改变的算法骨架,把可变步骤留给子类实现。核心是“流程顺序由模板控制,细节由扩展点提供”,适合多个流程拥有稳定公共步骤、只有少数步骤不同的情况。
abstract class AbstractImportJob<T> {
public final ImportResult run(InputStream input) {
validateInput(input);
List<T> records = parse(input);
checkBusinessRules(records);
persist(records);
return buildResult(records);
}
protected void validateInput(InputStream input) { /* 公共校验 */ }
protected abstract List<T> parse(InputStream input);
protected void checkBusinessRules(List<T> records) { /* 可选钩子 */ }
protected abstract void persist(List<T> records);
protected ImportResult buildResult(List<T> records) { return ImportResult.of(records); }
}
模板方法通常把骨架标为 final,避免子类改变关键顺序;可选行为用 hook 方法,必须实现的变化点用抽象方法。JDK 和 Spring 中的很多 Template 类体现了相同思想,但 JdbcTemplate 更多是“模板流程 + 回调/组合”,并非只能靠继承实现。
拓展与边界追问:
- “模板方法和策略的区别?”模板方法依赖继承,强调固定骨架和局部重写;策略依赖组合,强调整个算法可替换。若变化点多、需要运行时切换,策略通常更灵活。
- “父类越来越多钩子怎么办?”这说明抽象可能过重。可以把步骤拆成可组合的处理器、使用函数式回调,或重新划分模板边界。
- “模板方法如何保证事务和异常语义?”事务边界应由模板统一管理,子类不能随意吞异常;需要明确哪些步骤可重试、失败后是否补偿、重复执行是否安全。
- 继承扩展点属于公开契约,
protected方法一旦被大量子类依赖,修改成本仍然很高,要配套测试和文档。
项目落地 ⭐:可以答:“不同格式导入都遵循读取、校验、解析、持久化、回执的顺序,我把公共流程放在模板父类,子类只实现格式解析和落库差异。模板方法本身保证事务和异常不被绕过;当流程差异开始超过一半时,我会改成组合式步骤,避免父类继续膨胀。”
8. 🔴 代理模式?静态代理和动态代理的区别?
标准答:
代理对象实现与目标对象相同或兼容的接口,调用方先经过代理,代理可以在调用前后增加控制逻辑,再决定是否调用目标。它的意图通常是访问控制、延迟加载、远程调用、缓存、事务和日志等横切能力。
- 静态代理:编译期手写代理类,类型清晰、调试简单,但每个目标类型都可能需要一个代理,重复代码多。
- JDK 动态代理:运行时生成实现接口的代理类,调用进入
InvocationHandler;目标通常需要有接口。 - CGLIB/字节码子类代理:通过生成目标类的子类拦截方法,不要求接口,但
final类/方法、构造过程和某些字节码限制会影响代理。
public final class TimingHandler implements InvocationHandler {
private final Object target;
public TimingHandler(Object target) { this.target = target; }
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
long start = System.nanoTime();
try {
return method.invoke(target, args);
} finally {
long cost = System.nanoTime() - start;
record(method.getName(), cost);
}
}
}
Spring AOP 会根据目标类型和配置选择 JDK 代理或基于子类的代理;事务、权限、日志等通常在代理拦截器中织入。代理不是“自动让代码线程安全”,也不自动解决远程调用失败。
拓展与边界追问:
- “为什么加了
@Transactional却不生效?”常见原因是同类内部自调用绕过代理、方法/类是final、异常被吞掉、事务管理器不匹配或调用发生在代理创建之前。需要从调用对象是否真的是代理、事务边界和异常类型逐层排查。 - “JDK 和 CGLIB 怎么选?”有稳定接口且只需代理接口方法时 JDK 足够;需要代理具体类时才考虑子类代理。不要为了“性能”盲目切换,先看代理限制、可测试性和框架默认行为。
- 代理会增加调用层次、反射/字节码开销和调试复杂度;高频路径应测量,而不是假设动态代理一定慢或一定没影响。
- 代理、装饰器结构相似,但代理偏控制访问,装饰器偏叠加业务能力;最终要看意图和生命周期责任。
项目落地 ⭐:可以说:“我用 Spring AOP 给服务方法统一做日志、幂等或权限校验,业务类不需要重复写横切代码。遇到事务不生效,我会检查是否发生同类自调用、方法是否可代理,并在集成测试里验证代理边界,而不是只看注解是否存在。”
9. 🟢 装饰器模式是什么?和代理有什么区别?
标准答:
装饰器让一个对象在运行时获得额外职责。装饰器和被装饰对象实现同一抽象接口,内部持有被装饰对象,调用时先执行自己的增强逻辑,再委托给内部对象;多个装饰器可以按顺序嵌套。
interface DataSource {
byte[] read(String key);
}
final class CacheDataSource implements DataSource {
private final DataSource delegate;
CacheDataSource(DataSource delegate) { this.delegate = delegate; }
@Override
public byte[] read(String key) {
byte[] cached = findCache(key);
return cached != null ? cached : loadAndCache(delegate, key);
}
}
final class DecryptDataSource implements DataSource {
private final DataSource delegate;
DecryptDataSource(DataSource delegate) { this.delegate = delegate; }
@Override
public byte[] read(String key) {
return decrypt(delegate.read(key));
}
}
DataSource source = new DecryptDataSource(new CacheDataSource(new RemoteDataSource()));
Java IO 的 BufferedInputStream、GZIPInputStream 等就是典型例子:基础流提供原始能力,外层流逐层增加缓冲、压缩等能力。
拓展与边界追问:
- “和代理最本质的区别?”两者结构都常用委托。装饰器的目标是让调用方选择并组合多种增强,通常每一层都实现同一业务能力;代理的目标是控制目标访问,增强组合由框架或基础设施决定。实际代码可能同时具备两种意图,要以职责而不是名称判断。
- 装饰顺序会改变结果,例如“缓存后解密”和“解密后缓存”缓存的内容不同;应在组装处写清顺序,并为组合场景测试。
- 装饰器应保持接口契约和异常语义,避免悄悄改变返回值、线程安全或资源关闭责任。层数过多时可以改成显式拦截器链。
- 与继承相比,装饰器可以运行时组合,但调试调用栈更深、对象生命周期更复杂。
项目落地 ⭐:可以这样答:“第三方客户端外面按需叠加重试、缓存、监控和脱敏装饰器,核心客户端只负责一次真实调用。装配配置决定顺序,单元测试分别验证每层的职责和组合行为;涉及连接关闭、线程池等资源时由最外层明确负责,避免重复释放。”
10. 🟢 建造者模式是什么?
标准答:
建造者把复杂对象的构造过程拆成可读的步骤,最后通过 build() 生成对象。它特别适合参数较多、可选参数多、参数之间有校验关系或希望得到不可变对象的场景,可以避免构造器重载和大量 setter 带来的歧义。
public final class QueryCondition {
private final String keyword;
private final Integer page;
private final Integer size;
private final Set<String> fields;
private QueryCondition(Builder builder) {
this.keyword = builder.keyword;
this.page = builder.page;
this.size = builder.size;
this.fields = Set.copyOf(builder.fields);
}
public static Builder builder() { return new Builder(); }
public static final class Builder {
private String keyword;
private Integer page = 1;
private Integer size = 20;
private Set<String> fields = new LinkedHashSet<>();
public Builder keyword(String value) { keyword = value; return this; }
public Builder page(Integer value) { page = value; return this; }
public Builder size(Integer value) { size = value; return this; }
public Builder field(String value) { fields.add(value); return this; }
public QueryCondition build() {
if (page == null || page < 1) throw new IllegalArgumentException("page must be positive");
if (size == null || size < 1 || size > 200) throw new IllegalArgumentException("invalid size");
return new QueryCondition(this);
}
}
}
@Builder、StringBuilder 和各种配置对象都体现了建造者思想,但 Lombok 只生成代码,不会自动保证业务校验、深拷贝和默认值语义;这些规则仍要在 build() 或构造器中明确。
拓展与边界追问:
- “建造者和工厂有什么区别?”工厂重点是选择并创建哪一种产品;建造者重点是按步骤组装一个复杂产品。两者可以组合:工厂选择 Builder,Builder 负责校验和组装。
- “什么时候不用 Builder?”对象只有一两个必填参数、构造逻辑简单时普通构造器更清晰;过度使用会增加类和调用噪音。
- 必填参数可放进 Builder 构造器或分阶段 Builder,避免
build()才发现缺失;但分阶段类型会增加 API 复杂度,要看约束是否值得。 - Builder 如果暴露可变集合、复用同一个 Builder 或在多线程共享,仍可能产生状态污染;生成对象时应做不可变快照。
项目落地 ⭐:可以说:“分页查询、导出配置和第三方请求参数都有较多可选项,我用 Builder 统一默认值并在 build() 集中校验,生成后对象不可变。简单 DTO 不会强行套 Builder;对于外部输入仍会在接口层做校验,避免把所有错误推迟到构建阶段。”
11. 🟢 你项目里用了哪些设计模式?(必问,最该准备)
标准答:
这道题不是让你背出一串模式名,而是考察能否把设计决策和真实代码对应起来。建议只选 2~3 个自己确实能解释、能指出文件/模块边界的模式,每个按下面顺序回答:
- 场景和变化点是什么;
- 原始实现的痛点是什么;
- 模式的角色如何分工;
- 引入后的收益、代价和测试方式;
- 如果规模变化,是否会换成别的方案。
一个可口述的结构示例:
多渠道支付中,渠道数量会增加,原入口的 if-else 难以测试和扩展。
我定义 PayService 接口,每个渠道是一个策略实现,注册表负责按渠道选择,公共签名和审计放在独立组件。
新增渠道只增加实现和配置;代价是类数量增加、渠道 key 需要治理。
我为每个策略写正常、超时、重复请求测试,并用一条集成测试验证选择器和事务边界。
回答时要把“模式”与“框架机制”分开:例如 AOP 是代理思想,Spring 事件是观察者思想,不能只说“用了 Spring 所以用了所有模式”。
拓展与边界追问:
- “为什么不用一个大类?”说明变化频率、职责边界、测试隔离和发布风险,而不是只说“代码更优雅”。
- “这个模式有什么缺点?”策略/工厂会增加类型和装配;责任链依赖顺序;观察者有失败和一致性问题;模板方法受继承限制;代理会隐藏调用边界。能主动讲代价比只讲收益更可信。
- “如果只有两个分支呢?”可以保持简单分支,等变化稳定或重复出现再抽象。模式是解决问题的工具,不是验收指标。
- “怎么证明真的用过?”准备一个正常流程、一个异常边界和一次扩展改动,能讲清日志、测试或线上排查证据,但不要虚构业务数据。
项目落地 ⭐:建议形成自己的 90 秒答案:“我重点讲策略 + 工厂和责任链。前者处理会不断增加的渠道差异,后者处理固定顺序的多级校验;两者都让核心入口保持稳定,但一个负责选择算法,一个负责传递职责。我也会说清缺点、监控和测试,而不是罗列 23 种模式。”
12. 🔴 Spring 里用到了哪些设计模式?
标准答:
可以按 Spring 的典型组件来对应模式,但要说明它们解决的具体问题:
| Spring 组件/机制 | 体现的模式 | 说明 |
|---|---|---|
BeanFactory、ApplicationContext | 工厂/IoC | 负责对象创建、装配和生命周期,而不是业务代码直接 new |
Bean 默认 singleton 作用域 | 单例 | 每个容器通常共享一个 Bean 实例,作用域可改,不是 JVM 全局唯一 |
| AOP、事务拦截器 | 代理 | 在目标调用前后织入横切逻辑 |
JdbcTemplate、RestTemplate | 模板方法 + 回调/组合 | 固定资源管理和异常转换流程,把变化操作交给回调 |
ApplicationEvent / @EventListener | 观察者 | 发布者与监听器解耦,默认是进程内事件 |
HandlerAdapter、HttpMessageConverter | 适配器/策略 | 适配不同 Controller 或消息格式实现 |
HandlerExecutionChain、过滤器和拦截器 | 责任链 | 按顺序执行、可短路请求 |
BeanPostProcessor | 模板/责任链/代理扩展点 | Bean 初始化前后允许框架和组件增强对象 |
Spring 的实现往往是多个模式叠加。例如事务既依赖代理,又依赖拦截器链和容器生命周期;不要把一个组件强行归为单一模式。
拓展与边界追问:
- “Spring 单例是不是线程安全?”不是。单例只约束实例数量,实例内部的可变字段仍需保证线程安全;无状态 Bean 更容易安全复用。
- “
JdbcTemplate是纯模板方法吗?”它主要通过组合和回调封装连接、资源关闭、异常转换等固定流程,回答时说“模板思想”比声称所有逻辑都靠继承准确。 - “AOP 为什么有自调用问题?”同类内部调用使用
this,没有经过容器代理,拦截器自然不会触发;可以调整边界、通过代理调用或使用更合适的编织方式,但不要滥用AopContext。 - “HandlerAdapter 是怎么体现适配器的?”不同 Controller 类型都被适配成统一的调用入口,DispatcherServlet 不需要知道每种 Controller 的细节。
项目落地 ⭐:可以答:“我用 Spring 容器管理服务生命周期,用 AOP/代理统一做事务和审计,用事件监听解耦后置动作;在 Web 层通过拦截器链做鉴权和限流。讲每个模式时我会补上代理边界、Bean 作用域和失败处理,避免只报组件名称。”
13. 🟢 开闭原则是什么?为什么重要?
标准答:
开闭原则是“对扩展开放、对修改关闭”。这里的“关闭”不是代码永远不能改,而是对已经稳定、被多个调用方依赖的核心行为,新增需求尽量通过新增实现、配置或插件点完成,减少改动范围。
常见实现手段包括:
- 用稳定接口隔离会变化的算法;
- 用多态、策略和工厂替换条件分支;
- 用责任链或事件扩展处理步骤;
- 用配置/注册表表达可变映射,但保留校验和默认行为;
- 为扩展点建立契约测试,防止新实现破坏旧调用方。
拓展与边界追问:
- “是不是所有修改都要避免?”不是。需求本身改变了稳定契约、发现抽象错误或修复安全漏洞时,直接修改核心代码更正确;为了追求 OCP 而保留错误抽象会更危险。
- “配置化是不是天然开闭?”不是。把几十个分支搬到配置文件仍然是复杂分支,还可能失去编译期检查;配置只适合表达稳定的变化维度。
- “如何识别扩展点?”看历史需求、变更频率、调用方数量和领域概念,而不是凭想象预留所有未来功能。
- OCP 需要回归测试、契约测试和可观测性支撑;否则“少改代码”不代表风险真的更低。
项目落地 ⭐:可以说:“支付渠道、导入格式和通知方式都是明确会扩展的维度,我让核心流程依赖接口,新增能力通过实现类和注册配置接入。对稳定规则我不会强行抽象,改动时先补测试,再判断是修改现有实现还是增加扩展点。”
14. 🟢 里氏替换原则是什么?
标准答:
里氏替换原则要求:凡是使用父类型或接口的地方,都应该可以替换成任意合法子类型,而不破坏程序正确性。重点是行为契约,不只是方法签名相同。
子类型应遵守几个约束:
- 不应强化父类型没有要求的前置条件,例如父类接受非空输入,子类不能无理由拒绝全部输入;
- 不应削弱后置条件,例如父类承诺返回有效结果,子类不能悄悄返回不符合契约的值;
- 保持父类型的不变量、异常语义和副作用边界;
- 不能通过抛出更宽的受检异常、改变线程安全或改变状态含义来“偷偷换协议”。
经典的“正方形继承矩形”问题说明:虽然数学上正方形是矩形,但若父类允许独立设置宽和高,正方形重写后会破坏调用方对父类行为的假设。因此继承关系必须来自可替换的业务契约,而不只是概念上的 is-a。
interface ReadableStore {
Optional<byte[]> read(String key);
}
final class RemoteStore implements ReadableStore {
@Override
public Optional<byte[]> read(String key) {
// 遵守接口:未找到返回 empty,不用 null 或另一套异常语义
return fetchRemote(key);
}
}
拓展与边界追问:
- “子类能抛异常吗?”可以抛父方法允许的受检异常或更窄的异常;运行时异常还要看接口契约。更重要的是不要让同一接口的不同实现有完全不同的失败语义。
- “接口实现也会违反 LSP 吗?”会。例如一个只读实现把
save()静默吞掉,或一个缓存实现偶发返回过期数据但接口承诺强一致,都是契约问题。 - “如何发现违反?”用契约测试让所有实现跑同一组行为断言,检查边界输入、异常、幂等、线程安全和资源责任。
- LSP 与 OCP、组合优于继承相互关联:替换不可靠时,扩展和多态就会变成隐患,通常应拆小接口或改用组合。
项目落地 ⭐:可以这样答:“我让支付、存储、消息发送等实现都遵守同一接口的返回值、超时和异常契约,调用方不写针对某个实现的特殊分支。新增实现先通过契约测试;如果某供应商能力不完整,就拆成更小的能力接口或用适配器补齐语义,而不是硬塞进父类型。”
15. 🔴 怎么手动往 Spring 容器注册一个 Bean?
标准答:
按使用复杂度可以分几种方式:
@Bean:在配置类方法中返回实例,适合手工组装第三方客户端或需要明确参数的对象。@Component/@Service+ 组件扫描:适合由类自身声明、容器自动发现的组件。@Import、ImportBeanDefinitionRegistrar:适合 starter 或框架按导入配置批量注册。BeanDefinitionRegistryPostProcessor:在容器实例化普通 Bean 前,动态添加BeanDefinition,适合根据扫描结果或配置生成一批 Bean。DefaultListableBeanFactory.registerSingleton:直接登记一个已经创建好的实例,生命周期和依赖处理要由调用方谨慎负责,不应当作为常规首选。
动态注册示例:
public final class HandlerRegistrar
implements BeanDefinitionRegistryPostProcessor {
@Override
public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) {
RootBeanDefinition definition = new RootBeanDefinition(TaskHandler.class);
definition.getPropertyValues().add("mode", "default");
registry.registerBeanDefinition("defaultTaskHandler", definition);
}
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory factory) {
// 可选:调整已注册定义,不在这里提前 new 业务对象
}
}
关键区别是:注册 BeanDefinition 是把“如何创建 Bean”交给容器,容器仍可执行依赖注入、作用域、初始化和后置处理;直接 registerSingleton 是把现成对象放进去,可能绕过部分生命周期回调。注册时还要处理 Bean 名称冲突、条件装配、优先级和可观测性。
拓展与边界追问:
- “
BeanDefinitionRegistryPostProcessor和BeanPostProcessor有什么区别?”前者改的是 Bean 定义,发生在实例化大量普通 Bean 之前;后者针对已经创建出的 Bean 做初始化前后增强。 - “什么时候用编程式注册?”框架适配、插件扫描、按配置生成同类组件时有价值;普通业务 Bean 用
@Bean或组件扫描更易读、易调试。 - 注册定义时不要在回调里提前调用
getBean()形成循环或破坏启动顺序;复杂动态装配应配套启动日志和失败信息。 - 多个 Bean 同类型时要明确
@Qualifier、名称或@Primary,否则注入歧义会在启动期暴露。
项目落地 ⭐:可以说:“项目有一批按配置启用的处理器,我用注册器把类名、Bean 名称和构造参数转成 BeanDefinition,让 Spring 继续管理依赖和生命周期。固定组件仍用 @Bean,不会为了展示技巧而全量改成编程式注册;启动时打印实际注册清单,便于排查配置错误。”
16. 🟢 单例模式怎么防止被反射和序列化破坏?
标准答:
普通私有构造器只能阻止常规 new,不能阻止所有机制:
- 反射可以调用
setAccessible(true)后再次执行私有构造器。构造器里可以检测已有实例并抛异常,但这只是常见反射路径的防护;模块边界、不同类加载器或底层不安全 API 仍是另一层问题。 - Java 序列化反序列化时可能绕过普通构造过程创建新对象。实现
Serializable的单例通常要提供readResolve()返回唯一实例,并谨慎处理readObject。 - 克隆也可能生成新对象,应禁止
clone()或覆盖为抛异常。
示意代码:
public final class Registry implements Serializable, Cloneable {
private static final long serialVersionUID = 1L;
private static volatile Registry instance;
private Registry() {
if (instance != null) {
throw new IllegalStateException("already initialized");
}
}
public static Registry getInstance() {
if (instance == null) {
synchronized (Registry.class) {
if (instance == null) instance = new Registry();
}
}
return instance;
}
private Object readResolve() { return getInstance(); }
@Override
protected Object clone() throws CloneNotSupportedException {
throw new CloneNotSupportedException("singleton cannot be cloned");
}
}
更推荐的无状态单例写法是枚举:
public enum IdGenerator {
INSTANCE;
public long next() { return System.nanoTime(); }
}
JVM 对枚举实例化和反序列化有特殊保证,常见反射和序列化路径无法创建第二个枚举常量。
拓展与边界追问:
- “
readResolve什么时候调用?”在标准 Java 序列化恢复对象后由序列化机制用替换返回值;它不是所有 JSON、ORM 或自定义反序列化框架都会遵守。 - “Spring 单例需要这样防护吗?”通常不需要,因为实例由容器创建,业务代码也不应序列化整个容器 Bean;应先明确对象的生命周期和使用边界。
- 多类加载器、多个应用容器或多个进程都可能各自拥有“单例”,所以不能把进程内单例当成分布式唯一资源。
- 全局单例如果持有连接、线程池或缓存,还要提供关闭和刷新策略,否则热部署和测试环境容易泄漏资源。
项目落地 ⭐:可以答:“业务服务由 Spring 管理,不手写防反射逻辑;面试手写题我会给出 DCL 或枚举,并主动说明序列化用 readResolve、克隆要禁止,以及这些措施只覆盖进程内实例。真正的集群唯一性会交给数据库约束、分布式锁或协调服务。”
17. 🟢 适配器模式是什么?
标准答:
适配器把一个已有对象的接口转换成调用方期望的接口,让原本不兼容的组件协同工作,同时隔离第三方 API 的变化。调用方依赖自己的领域接口,适配器负责参数映射、返回值转换、异常归一和能力差异处理。
对象适配器(组合)示例:
interface SmsSender {
SendResult send(Message message);
}
final class VendorSmsAdapter implements SmsSender {
private final VendorClient client;
VendorSmsAdapter(VendorClient client) { this.client = client; }
@Override
public SendResult send(Message message) {
VendorResponse response = client.submit(
new VendorRequest(message.phone(), message.content()));
return response.success()
? SendResult.ok(response.id())
: SendResult.failed(mapError(response.code()));
}
}
也有类适配器(多继承或语言支持时通过继承实现),但 Java 业务代码更常用对象适配器,因为组合更灵活、不会被单继承限制。对复杂外部系统,适配器还常作为防腐层,避免供应商的数据模型渗入核心领域。
拓展与边界追问:
- “适配器和代理、外观有什么区别?”适配器解决接口不兼容;代理保持相同接口并控制访问;外观为多个子系统提供更简单的统一入口。一个类可能同时承担多种意图,但设计时要保持职责清楚。
- “供应商没有某个能力怎么办?”不要伪造成功。可以拆分能力接口、返回明确的“不支持”结果,或在适配层做降级并记录告警。
- 适配器要处理超时、重试、幂等、签名、错误码和敏感字段脱敏;只改方法名而不统一异常语义,不能算完成适配。
- 第三方 SDK 升级时优先改适配器和契约测试,核心服务不应直接依赖供应商 DTO。
项目落地 ⭐:可以说:“支付、短信或对象存储供应商的接口都先适配成内部接口,上层只认统一的请求、结果和错误码。切换供应商时替换适配器并跑契约测试;供应商特有能力通过扩展能力接口暴露,不把公共接口塞满专属参数。”
18. 🔴 为什么说“组合优于继承”?
标准答:
继承表达“is-a”,子类复用父类实现并获得父类契约;组合表达“has-a”,对象持有另一个对象并通过委托协作。组合通常更灵活,原因包括:
- 继承把子类绑定到父类实现和
protected状态,父类改动可能影响所有子类; - Java 类只能单继承,多个能力难以通过继承拼装;
- 继承关系在编译期固定,组合可以在运行时替换策略、装饰器或适配器;
- 继承容易被误用来复用几行代码,导致语义错误和脆弱基类问题。
interface RetryPolicy {
boolean shouldRetry(int attempt, Throwable error);
}
final class RemoteService {
private final RetryPolicy retryPolicy;
RemoteService(RetryPolicy retryPolicy) {
this.retryPolicy = retryPolicy;
}
Response call(Request request) {
// 委托给策略决定是否重试,运行时可替换实现
return invokeWithPolicy(request, retryPolicy);
}
}
策略、装饰器、适配器都用组合实现能力拼装。组合不是绝对规则:当子类确实满足父类契约、需要复用稳定模板,且继承层次浅且受控时,继承仍然合理。关键是先验证 LSP 和长期变化方向,再选择关系。
拓展与边界追问:
- “组合有没有代价?”会增加委托代码、对象装配和调用层次;过度拆分会让简单逻辑难以追踪。可以用构造器、工厂或依赖注入集中组装,并保持接口粒度适中。
- “抽象类是不是不能用?”不是。模板方法、稳定的共享不变量和明确的 is-a 关系仍适合抽象类;不要把“优先组合”理解成禁止继承。
- “继承复用代码更快怎么办?”短期省代码,长期可能让父类变成所有子类的隐式依赖。若只是复用纯函数或无状态工具,优先提取组件/委托,而不是建立虚假的类型关系。
- 组合对象的生命周期、线程安全和异常边界要写清楚;持有线程池、连接等资源时,关闭责任不能互相重复或遗漏。
项目落地 ⭐:可以这样答:“需要替换的算法和横切能力我用组合,例如服务持有重试策略、缓存装饰器和供应商适配器,运行时按配置组装。只有流程骨架稳定且子类都能遵守同一契约时才用模板方法;如果继承需要大量 instanceof 或子类覆盖父类状态,我会重构成接口 + 委托。”
你能答到第几层?
- 能讲清定义、结构、适用边界、代价,并且能把模式落到真实代码和测试:说明你是在做设计,而不是背名词。
- 只会标准答:先把策略 + 工厂、责任链、代理这几个高频组合练熟,再补一个自己项目中真实发生过的异常或扩展案例。
- 设计模式题最忌“模式堆砌”。能说清“解决了什么问题、不用会怎样、为什么不用另一个模式”,比一次报出 23 个名字更有说服力。
这是面试专题的「设计模式篇」,网站上还有并发、MySQL、Redis、Spring、微服务、消息队列、JVM、安全认证、Java 基础、计基与 Linux、项目场景等系统整理。 🌐 更多真实面试专题与资料:smallredtech.com 💬 想系统学 / 简历与辅导咨询,加微信:Ahongbb666(备注「面试题」)