☰
Java设计模式面试25题精讲:创建型、结构型、行为型与SOLID原则
2026/10/6 5:26:53 网站建设 项目流程

最近帮人做面试复盘,发现一个特别有意思的现象:同样是问设计模式,有人能聊出"为什么这么设计"和"换个场景还适不适合用",有人只能背出"是什么"。Java后端岗位的面试里,创建型、结构型、行为型这三大类模式几乎是必考题,网上碎片化答案又太多,要么只给一段代码不给理由,要么只讲概念没法落地。所以我干脆整理了这份25道面试题及详细答案,按创建型、结构型、行为型归类,末尾补上综合题和SOLID设计原则,每道题都从"面试官到底想听什么"的角度作答,不只给结论,更给推理过程。

这份题单适合准备校招、社招的Java开发,也适合工作两三年想系统性梳理设计模式的读者。你不用再翻十几篇博客拼凑答案,照着题目一道一道过,能说出来、能写出来、能扛住追问,基本就稳了。

1. 创建型模式:对象怎么"出生",五个模式各有各的讲究

创建型模式在面试里是很好的切入点,因为代码量小、原理却很深。单例最容易考,几分钟就能写完,但隐藏的坑一个接一个;工厂模式几乎每个后端项目都在用;建造者模式这几年越来越流行,因为链式调用真的太舒服了。这部分我给了5道题,覆盖高频考点和容易翻车的细节。

1.1 Q1:饿汉式、懒汉式、双重检查锁的单例分别怎么写?volatile为什么必不可少?

这道题属于"开胃菜",但也是淘汰率最高的一道。面试官真正想确认的不是你会背代码,而是你知道每一步在防什么。

饿汉式最简单,类加载时就完成实例化:

public class Singleton { private static final Singleton INSTANCE = new Singleton(); private Singleton() {} public static Singleton getInstance() { return INSTANCE; } }

它的好处是天然线程安全,坏处是"懒"不下来——即使这个类从头到尾没被用到,实例也已经创建了。

懒汉式把创建动作推迟到getInstance第一次调用时,但如果直接写:

public static Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; }

两个线程同时进来,就可能各自new出一个实例,直接违背单例。于是给方法加synchronized,但这样每次读都要抢锁,性能太难看。最终落地方案是双重检查锁:

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; } }

关键的坑全在volatile上。new Singleton()在底层不是一条指令,一般是三步:分配内存、调用构造器初始化字段、把引用指向内存。Java允许CPU和编译器对这三步做指令重排,极端情况下可能先完成引用赋值、再执行构造器里的初始化逻辑。线程A走到了第3步但第2步还没完成,线程B发现instance不是null就直接拿去用,拿到的是一个部分初始化的对象。volatile禁止了这种重排,才保证"对象完全构造好之后"才对外可见。

提示:面试时把"new不再是原子操作"这个点讲出来,就已经超过绝大多数候选人了。

1.2 Q2:反射和序列化能不能破坏单例?怎么防?

这个问题是Q1的自然延伸,也是"聊天聊深了"之后必问的。普通写法下,反射可以通过setAccessible(true)强行调用private构造器,再创建一个实例;如果单例实现了Serializable,反序列化时readObject()会基于序列化数据再new一个对象,单例同样被打破。

防御方式按顺序回答:第一,在构造器里判断如果实例已经存在就直接抛异常,挡住反射;第二,实现readResolve()方法,让反序列化时直接返回已有实例;第三,也是我最推荐的一招——用枚举实现单例:

public enum Singleton { INSTANCE; }

枚举天然是单例,JVM保证每个枚举常量只有一次实例化,反射遇到枚举时会直接抛IllegalArgumentException,反序列化也走不到新创建对象的路径。代码最短,防御最强,唯一的"缺点"是很多人觉得枚举不像传统单例的样子,但面试官听到这个答案通常会眼睛一亮。

1.3 Q3:简单工厂、工厂方法、抽象工厂怎么区分?Spring用的是哪种?

先把这个基本盘讲清楚:简单工厂不算GoF正式模式,它把switch或if分支集中到一个类里,根据入参返回不同产品,新增产品就要改工厂代码,违反开闭原则。工厂方法把"创建什么产品"的决定权下放到子类——一个工厂只负责一种产品,每种产品配一个工厂子类,新增产品时只加新工厂,不改老代码。抽象工厂更进一步,它负责的是"产品族",比如一个数据库访问工厂能同时产出Connection、Statement、ResultSet这一组配套对象,保证族内产品互相匹配。

维度简单工厂工厂方法抽象工厂
是否属于GoF否是是
创建粒度一个产品一个产品一组相关产品
新增产品代价改原工厂类新增工厂类扩展产品族较复杂
典型定位入门封装框架底层跨平台/跨产品族

Spring里的BeanFactory、ApplicationContext本质上是工厂模式的深度定制——通过getBean()根据名称或类型产出对象,同时把创建过程交给IoC容器管理。更直白的例子是@Bean注解方法,本质就是在配置类里定义了一个工厂方法;而@ComponentScan扫描出来的bean则更像"默认工厂+反射创建"。严格说Spring的bean工厂并不是教科书里的某一种,它是一套容器级别的工厂抽象,你可以这样回答:"Spring不是简单套用某一个工厂模式,而是用工厂思想做了一套可扩展的Bean创建体系,通过ClassPathXmlApplicationContext、AnnotationConfigApplicationContext这类不同实现来变化读取方式。"

1.4 Q4:建造者模式和Setter方式有什么本质区别?为什么链式调用现在这么流行?

面对一个字段特别多的对象,老一代写法是构造函数重载,五六个字段要写五六套构造器,参数一多传错顺序是常事;另一种做法是用无参构造加一堆setter,对象创建时会被拆成好几行,中间还会出现一个"缺胳膊少腿"的中间状态。

Builder模式的特点是:字段通过链式赋值,最终调build()一次性生成一个不可变对象,参数校验可以在build()里集中执行。以OkHttp里常见的写法为例,核心结构是这样的:

public class HttpClient { private final String url; private final int timeout; private HttpClient(Builder builder) { this.url = builder.url; this.timeout = builder.timeout; } public static class Builder { private String url; private int timeout; public Builder url(String url) { this.url = url; return this; } public Builder timeout(int timeout) { this.timeout = timeout; return this; } public HttpClient build() { if (url == null || url.isEmpty()) throw new IllegalStateException("url不能为空"); return new HttpClient(this); } } }

注意Builder里的每个方法返回this,这就是链式调用的秘密:方法之间靠共享同一个Builder实例传递状态,最后build()统一建造。Lombok的@Builder注解帮你干的就是这件事。面试时还可以提一下,Builder特别适合构造函数参数超过4个、且构造时要做校验的场景,比如配置类、HTTP请求封装、领域对象创建。

1.5 Q5:原型模式的浅拷贝到底坑在哪里?生产环境怎么做深拷贝?

原型模式的核心是拿现有对象"复制"一个新对象,而不是重新经历一遍构建流程。Java对此的默认支持是Cloneable接口加clone()方法。最大的坑藏在浅拷贝里:Object.clone()只会把基本类型字段和引用字段的"引用地址"复制过去,两个对象会共享同一个子对象。

举个例子,一个User对象里有个List<String> tags,浅拷贝后,新对象和原对象引用的是同一个List。你往新对象的tags里加东西,原对象的tags也变了。JDK源码里很多clone()方法其实也是浅拷贝,比如ArrayList的clone,面试时可以点出这个细节,很有说服力。

生产环境做深拷贝,我不太推荐依赖麻烦的clone链式重写,更常见的有两种:一是对象都实现Serializable,用JSON序列化来回转(Jackson、Gson都行),写起来清爽:

User target = objectMapper.readValue(objectMapper.writeValueAsString(source), User.class);

二是手动new一个目标对象,把字段逐个赋值。前者通用但有一定性能开销,后者性能好但字段多了维护成本高。原型模式本身使用频率并不高,面试重点其实是考察"你分得清浅拷贝和深拷贝吗"。

2. 结构型模式:类与类之间的"胶水配方",七道题吃透核心区别

结构型模式解决的是"类和对象怎么组合成更大结构"的问题。面试官最爱在这一类里搞混几个看起来差不多的模式,比如代理和装饰器、适配器和外观,你要是能三句话点破区别,印象分会明显提升。这7道题是我圈定的必看清单。

2.1 Q6:JDK动态代理和CGLIB的底层原理分别是什么?Spring Boot默认用哪个?

这道题是AOP的必考前置题,答得是否深入直接决定面试后续走向。JDK动态代理要求目标对象实现接口,运行时会通过Proxy.newProxyInstance()生成一个实现该接口的代理类,所有方法调用都会被InvocationHandler.invoke()拦截。它的底层是反射,字节码是运行时生成的。

如果一个类没有实现接口,JDK动态代理就无能为力。CGLIB走的是另一条路:直接生成目标类的子类来充当代理对象,在子类里覆盖父类方法,再把增强逻辑编织进去。这带来两个限制:final类没法被CGLIB代理,final方法也没法增强。Spring从4.x开始,如果目标类没接口,会自动退到CGLIB;Spring Boot 2.x之后更激进,默认spring.aop.proxy-target-class=true,基本都走CGLIB了。

写个JDK代理的骨架方便你理解:

public interface UserService { void save(); } public class UserServiceImpl implements UserService { public void save() { System.out.println("保存用户"); } } // 使用 UserService proxy = (UserService) Proxy.newProxyInstance( UserServiceImpl.class.getClassLoader(), new Class[]{UserService.class}, (p, method, args) -> { System.out.println("前置增强"); Object result = method.invoke(new UserServiceImpl(), args); System.out.println("后置增强"); return result; });

回答时还可以补一句:JDK代理用反射调用方法,CGLIB用FastClass机制直接生成索引调用方法,性能上CGLIB一般更优,但现在两者差距已不大,选型主要看"有没有接口"和"能不能继承"。

2.2 Q7:代理模式和装饰器模式到底怎么区分?

这两兄弟在面试中被打错的频率极高,但你只要抓住一句话就能分清:代理模式重在控制,装饰器模式重在增强。

代理模式下,代理对象决定"目标方法要不要执行、什么时候执行、执行前后做什么",客户端其实没直接碰真实对象,它想保护或控制真实对象。装饰器模式则是把被装饰对象当作自己的成员,在调用它的同时加上额外职责,对象本身保留原能力,多个装饰器还能层层叠加。

经典例子就在Java IO里:BufferedInputStream装饰FileInputStream,给文件流加缓冲能力,是典型的装饰器;而Spring AOP里的ProxyFactory为目标Bean生成代理,方法前后插入事务、日志,是典型的代理。如果面试官追问"哪些层叫装饰器,哪些层叫代理",可以用一句话收尾:BufferedReader/about 读文件那层是装饰,Spring事务那层是代理。

2.3 Q8:适配器模式在Spring MVC和日志框架里是怎么体现的?

适配器模式的核心就是"把A的接口翻译成B能用得起的接口",让你在不改动老代码的前提下接入新组件。用生活经验理解就是USB转接头——老鼠标是USB-A口,新电脑只有Type-C口,没有转接头就插不上。

Spring MVC里最典型的体现就是HandlerAdapter。DispatcherServlet要统一处理三类风格各异的Handler——@RequestMapping注解的Controller方法、实现Controller接口的类、处理静态资源的HttpRequestHandler——它们的调用方式完全不同,于是Spring定义了一套HandlerAdapter接口,每种Handler配一个适配器,DispatcherServlet只认HandlerAdapter,不再关心具体Handler类型。

日志界也有个很好的例子:slf4j只定义一套统一日志接口,log4j、logback各写各的,于是slf4j通过适配器把统一接口翻译成各个日志框架的调用。这样你写业务代码永远面向slf4j,底层切哪个日志框架都不用改代码。

提示:回答适配器时可以顺便对比工厂模式——工厂关心"对象怎么来",适配器关心"接口怎么匹配",两者经常配合出现,但职责完全不同。

2.4 Q9:外观模式被用滥了?什么情况下该用它?

外观模式(Facade,也叫门面模式)的核心思想是给复杂子系统提供一个统一的高层入口。就像你在家不用研究配电箱里每一根线怎么走,按一下开关面板上的按钮就行,面板就是你家的外观。

在代码里,一个典型的门面类会长这样:它本身不含具体业务逻辑,而是把子系统的调用编排在一起,对外暴露几个简单方法。比如订单门面方法createOrder(),内部可能依次调用库存服务、优惠券服务、支付服务、消息服务,但客户端只需要调这一个方法。

真正该用的场景有三个标志:第一,客户端需要接触大量相互协作的子系统接口;第二,你希望把内部依赖关系集中到一个地方管理;第三,子系统迭代频繁,希望通过门面隔离变化。常见的反面教材是"门面类越写越臃肿,所有业务都塞进去",那就从门面退化成了"上帝类"。面试时可以说:门面适合编排和整理,不适合承载具体业务。

2.5 Q10:桥接模式为什么能避免子类爆炸?JDBC是典型例子吗?

先问自己一个问题:如果一个系统同时有两个维度的变化,用继承会怎样?比如图形按形状分有圆形、方形,按颜色分有红色、蓝色,用继承组合会得到圆形红色、圆形蓝色、方形红色、方形蓝色,再加一种形状或颜色,类数量成倍增长,这就是"子类爆炸"。

桥接模式的办法是放弃继承,改走组合:把形状和颜色分开,形状里持有颜色的引用。

public abstract class Shape { protected Color color; Shape(Color color) { this.color = color; } abstract void draw(); } class Circle extends Shape { Circle(Color color) { super(color); } void draw() { System.out.println("画" + color.fill() + "圆形"); } }

以后新增一种形状,只加一个Shape子类;新增一种颜色,只加一个Color实现。两个维度独立扩展,类数量从乘法变成加法。

JDBC和数据库驱动就是桥接模式的经典体现:JDBC定义统一接口,MySQL驱动、PostgreSQL驱动是不同实现,开发者面向的是JDBC抽象,底层驱动可以被自由替换。面试官如果追问Connection、Statement这些对象怎么来的,你可以顺着说"这又串到了抽象工厂模式"——桥接管分离,工厂管创建,框架里经常连用。

2.6 Q11:组合模式如何实现文件系统、菜单树的统一遍历?

组合模式是为了处理"部分和整体的层次结构"而生的:让单个对象和组合对象实现同一个接口,客户端可以把它们一视同仁地处理。文件夹里有文件还有子文件夹,但对外看,你无非是调用它们的getName()、getSize()、display(),不需要关心它到底是一个文件还是一个文件夹。

标准结构是三件套:Component顶层接口、Leaf叶子节点、Composite容器节点。容器节点内部维护一个List,add()把子节点挂进来,遍历时递归调用子节点的方法。菜单树就是最常见的例子:菜单项(Leaf)和子菜单(Composite)都用同一个show()方法,前端拿到一棵树,直接递归渲染。

写一个最小例子:

public interface Component { void show(); } class File implements Component { private String name; public void show() { System.out.println("文件:" + name); } } class Folder implements Component { private List<Component> children = new ArrayList<>(); private String name; public void add(Component c) { children.add(c); } public void show() { System.out.println("目录:" + name); for (Component c : children) c.show(); } }

这里有个设计取舍值得在面试时说:让叶子节点也能调add(),虽然统一了接口,但对File调用add()实际上是没有意义的,一般要抛UnsupportedOperationException。面试官可能问"透明性和安全性的取舍",能讨论到这个层面,说明你理解得比较透。

2.7 Q12:享元模式在JDK里有哪些经典应用?Integer缓存能体现享元吗?

享元模式解决的是"大量细粒度重复对象"的问题,核心技巧是分离内部状态和外部状态:内部状态共享,外部状态在使用时传入。思路就像图书馆把小说副本集中管理,读同一本书的人不用一人拿一本原稿,只需要登记自己的阅读进度——进度是外部状态,书是共享的。

JDK里最经典的例子就是Integer缓存:Integer.valueOf(127)拿到的对象是有缓存池的,-128到127范围内的实例会被复用。你可以打开一段代码直接测试引用相等性,127的两个Integer相等,而128的两个Integer不相等(走new),这就是为什么面试题常拿它说事。String常量池同样是享元思想:字面量相同的字符串在常量池中只有一份。线程池和数据库连接池更是享元思想的工程化体现——连接对象被重复利用,避免了频繁创建销毁资源的巨大开销。

回答时可以强调:享元模式是"用空间换时间"和"用共享省内存"之间的权衡,共享对象必须是无状态或只读的,否则多个使用方互相改状态就直接崩了。

3. 行为型模式:对象如何协作,才是代码优雅的分水岭

行为型模式关注的是对象之间的职责分配和交互方式,这里也是最容易出"结合项目谈谈"的地方。策略模式怎么消灭if-else、模板方法怎么固化算法骨架、观察者怎么解耦业务、责任链怎么串联过滤器,基本每个都是必面内容。我挑了10道最常考的题,覆盖主次分明的高频点。

3.1 Q13:策略模式怎么消灭大堆if-else?Spring里如何让策略自动注入?

策略模式的定义很朴素:把一组可互相替换的算法封装起来,让"使用算法的客户端"和"算法本身"解耦。最经典的例子就是集合框架的Comparator,List排序用你传入的不同比较策略,sort方法本身不关心具体规则。

最常见的面试场景是支付渠道选择。没设计过的代码长这样:

if ("alipay".equals(type)) { return new AliPay().pay(); } else if ("wechat".equals(type)) { return new WeChatPay().pay(); } else if ("card".equals(type)) { return new CardPay().pay(); }

一加新渠道就改这个方法,既违反开闭原则,又容易改出回归bug。用策略模式改造,先把每个渠道做成一个实现PayStrategy接口的类,再配合工厂和Map搞定路由:

public interface PayStrategy { void pay(BigDecimal amount); } // Spring注入版 @Service public class PayStrategyContext { private final Map<String, PayStrategy> strategyMap; public PayStrategyContext(List<PayStrategy> strategies) { // 这里利用Spring把策略实现都注进来 this.strategyMap = strategies.stream() .collect(Collectors.toMap( s -> s.getChannel().toLowerCase(), Function.identity())); } public void execute(String channel, BigDecimal amount) { PayStrategy strategy = strategyMap.get(channel.toLowerCase()); if (strategy == null) throw new IllegalArgumentException("不支持的渠道"); strategy.pay(amount); } }

这里有个实用技巧:让每个策略实现类声明自己属于哪个渠道,Spring注入List<PayStrategy>之后,用一行Stream转成Map,新渠道只需要新增Bean,一行if都不用改。面试官几乎必问"Map里的key从哪来",你答"每个策略类自己声明渠道标识,由Spring托管的Bean给我ArrayList"就够了。

3.2 Q14:模板方法模式如何体现"好莱坞原则"?AQS和JdbcTemplate分别扮演什么角色?

模板方法模式是在父类里定义算法的骨架,把某些步骤延迟到子类实现。好莱坞原则说得俏皮一点就是"Don't call us, we'll call you"——父类不让你主动调它的方法,反而会在恰当的时候回调你的子类方法。反向控制(控制反转)这个概念,最早就是从模板方法里孕育出来的。

Java里最让人服气的例子是AbstractQueuedSynchronizer。AQS定义了一整套同步器骨架,包括加锁排队、唤醒、阻塞的逻辑,但把tryAcquire和tryRelease这两个步骤留给了子类——ReentrantLock、Semaphore、CountDownLatch各自实现自己的获取和释放逻辑,却复用同一套AQS排队框架。你只需要重写两个方法,就能获得一个完整的高性能同步器。

JdbcTemplate同样典型:它把"建立连接、执行SQL、处理结果集、关闭资源、异常转换"这些骨架流程写死,把RowMapper留给调用方决定"一行数据怎么转成对象"。你传不同的RowMapper,同一个JdbcTemplate能查出各种类型的结果。回答时可以总结一句:模板方法就是"流程我说了算,细节你来填",和策略模式的区别在于——策略换的是整块算法,模板换的只是骨架里的某个步骤。

3.3 Q15:观察者模式与发布/订阅有什么不同?Spring事件机制怎么用?

观察者模式的核心是一对多依赖:Subject状态一变化,自动通知所有注册的Observer。它的代码结构很好理解:Subject里维护一个观察者列表,notify()的时候把每个观察者都叫一遍。JDK自带的java.util.Observable已经废弃了,原因就是它没法携带泛型,事件内容只能用Object强转,用起来很不顺手。

面试官常追问的是"观察者和发布订阅有什么区别"。我的回答是:观察者模式里,Subject和Observer还互相认识,Subject直接维护观察者列表;发布/订阅模式中间多了一个事件通道或消息代理,发布者根本不关心谁在订阅,两者完全解耦。放到工程里就是Spring事件和MQ的区别。

Spring事件几乎是企业应用里观察者模式的标准答卷。演示一下用法:

// 事件类 public class OrderCreatedEvent extends ApplicationEvent { private final Long orderId; public OrderCreatedEvent(Object source, Long orderId) { super(source); this.orderId = orderId; } } // 发布 applicationEventPublisher.publishEvent(new OrderCreatedEvent(this, orderId)); // 监听 @EventListener public void onOrderCreated(OrderCreatedEvent event) { // 发短信、更新库存、通知物流…… }

哪怕写成伪代码,这套结构在Spring项目里也能直接套用。更进阶的答法是:监听方法加上@Async就能异步解耦,但要注意事务提交后再发事件,否则监听读到的事务状态不对——这一步能明显加分。

3.4 Q16:责任链模式在Tomcat、Spring MVC里是怎么串起来的?手写一个过滤链?

责任链模式就是"让多个处理器挨个处理同一个请求,每个处理器决定自己处理还是传给下一个"。它把请求的发送者和处理者解耦,让每个处理者只关心自己能处理的那一段。

Servlet规范里的Filter就是最标准的责任链:请求按顺序经过多个过滤器,每个过滤器调用chain.doFilter()把请求传给下一个,任何一个环节不想继续就拦截。Spring MVC的HandlerInterceptor同样是链式结构,比如登录校验、权限校验、限流都是拦截器链上的节点。Netty的ChannelPipeline也长这样。

面试时如果让你手写,最小可用的代码大约是这样:

public interface Handler { void handle(Request req, HandlerChain chain); } public class HandlerChain { private List<Handler> handlers = new ArrayList<>(); private int index = 0; public void add(Handler h) { handlers.add(h); } public void doHandler(Request req) { if (index < handlers.size()) { handlers.get(index++).handle(req, this); } } }

核心点有三个:每个Handler收尾时要调用chain继续往下传;用索引记录当前位置;Handler之间完全不知道对方是谁。能说出"责任链最适合处理顺序可变的多个拦截逻辑"就已经可以了。

3.5 Q17:for-each和迭代器模式有什么关系?为什么拿到Iterator后不能直接修改集合?

面试官抛这道题基本是想验证你是不是真的写过多线程程序、翻过集合源码。for-each不是JVM的魔法字节码,而是语法糖:编译后会被改写成调用iterator()拿到Iterator,再循环调用hasNext()和next()。所以任何实现Iterable接口的类都能用for-each——这就是迭代器模式在Java集合里的通用表达。

接着问"为什么迭代过程中集合被修改会抛ConcurrentModificationException",标准路径是:ArrayList内部有个modCount计数器,每次add、remove都会把modCount加1。Iterator在创建时会记录expectedModCount,每次next()先检查两者是否一致,不一致就说明集合被别人改了,为了避免迭代结果错乱,直接抛出异常。这就是"fail-fast快速失败"机制,宁可报错也不给出不确定的数据。

真要一边遍历一边删除,推荐两招:用CopyOnWriteArrayList,或者用Iterator自己的remove(),它会同步维护expectedModCount。拿这两个方案结尾,这道题就圆满了。

3.6 Q18:状态模式和策略模式长得挺像,真正的区别在哪里?

这两个模式经常被放在一起问,因为类图结构几乎一模一样——都是一组实现类、一个上下文持有抽象引用。但语义完全不同,关键区别在"谁来切换行为"。

策略模式里,是客户端选择策略并把它传给上下文,上下文只是个执行者,策略之间是平级可互换的。用支付举例,客户端选支付宝还是微信,主动权在外部,支付行为不会自己跳到下一个渠道。状态模式里,状态对象之间是有顺序、有流转的,上下文自己维护当前状态,状态对象在执行完逻辑后会把上下文迁移到下一个状态。比如订单状态:待支付——已支付——已发货——已完成,这个流转规则被封装在状态对象内部,业务代码不需要写if-else判断订单处于哪个阶段。

面试时抛一句话就能讲透:策略是"换一种做法",状态是"换一个阶段"。另外,状态模式通常需要把转换关系固化,如果有环形的复杂状态迁移,可以先画状态图再编码,硬写状态类容易混乱。

3.7 Q19:命令模式如何实现撤销和事务?和Runnable有什么关系?

命令模式把"请求"封装成对象,这样就能在调用方和执行方之间加一层中间对象,好处是可以排队、可以记录、可以撤销。最简单的结构是Command接口里有execute(),再加一个undo()就是撤销能力的雏形。想想编辑器里的Ctrl+Z,每次操作生成一个命令对象放进历史栈,撤销时从栈里取出反操作执行一遍。

和事务的关系可以用同一个思想来理解:一组命令批量执行,如果中间某一步失败,就逆序执行undo把已生效的命令全部回滚。这也是命令模式在事务补偿上的通用方案。而Runnable和命令模式的关系很有意思——Executors把一个个Runnable当作待执行的命令,线程池就是命令的调用者,只是Runnable只定义了run(),没有undo语义罢了。

让我用代码把三件套的骨架摆出来:

public interface Command { void execute(); default void undo() {} } public class OpenFileCommand implements Command { private final FileReceiver receiver; public void execute() { receiver.open(); } public void undo() { receiver.close(); } } public class Invoker { private final Deque<Command> history = new ArrayDeque<>(); public void invoke(Command c) { c.execute(); history.push(c); } public void undo() { if (!history.isEmpty()) history.pop().undo(); } }

文件接收者是被操作的真实对象,Invoker只负责按顺序执行和记录,客户端甚至不需要知道接收者是谁。回答时能说出"命令模式把操作请求参数化,从而可以被存储、排队、回滚",这道题就过关了。

3.8 Q20:中介者模式为什么能解决网状依赖?MVC里的Controller算中介者吗?

如果没有中介者,多个对象互相协作时会产生复杂的网状引用,每个人都要知道其他所有同事的地址。中介者模式就是把所有交互收拢到一个中心对象里,让同事对象之间不再直接引用,只和中介者对话。打个比方,一个办公室五个人要互相传话,最乱的方式是每个人都跑到别人工位上喊话;中介者模式等于大家约定有事都发群里,由群管理员负责协调转达。

代码层面最典型的体现就是MVC里的Controller:View把用户操作交给Controller,Model的状态变更也通知Controller,Controller负责协调View和Model,而不是让View直接去改Model。聊天室、机场塔台也都是这个模式的现实投影。

面试时拿它和观察者模式对比会很加分:观察者模式是"一对多"的事件分发,发布者不直接关心观察者;中介者模式是"多对多"的集中协调,所有交互都经过中介者,本质上可以理解为"用观察者模式去驱动一个中心协调者"。

3.9 Q21:备忘录模式保存快照时要注意什么?

备忘录模式就是找一个"存档管理员",把对象内部状态保存成快照,以后需要时可以恢复。它的三件套是:Originator(要被保存状态的对象)、Memento(快照本身)、Caretaker(负责保管快照的管理员)。编辑器实现撤销、游戏实现存档读档,都是这套结构。

真正要注意的是三点:第一,快照成本。如果对象内部有一个巨大的List或Map,每次都整体深拷贝,内存很快就扛不住,考虑只保存变化的字段或用紧凑的数据结构存快照。第二,隔离性。要防止外部直接修改Memento内部的引用字段,否则"存档被污染",恢复出来的就是脏数据,Java里可以做成不可变对象或者把字段设置为包级私有。第三,恢复的完整性。恢复操作要一次性把状态全部还原,别恢复了一半。面试时能主动说出"不要在快照里直接存可变对象的引用,要深拷贝或只存必要字段",会比只讲定义强很多。

3.10 Q22:访问者模式的双分派是什么意思?什么场景你才会真的用访问者?

访问者模式是行为型模式里最难讲清楚的一个,面试官问它大概率是想看你的表达能力和设计判断力。它的目标是:在对象结构基本稳定的前提下,把"操作"独立出来,让你在不动数据类的情况下新增各种操作。

先解释双分派。传统多态是一次分派:调用element.operation()时,运行期根据element的具体类型决定执行哪个方法。访问者模式在此基础上多了一层:visitor.visit(element),执行哪段visit逻辑既取决于访问者的具体类型,又取决于被访问元素的具体类型,所以叫"双分派"。AST语法树遍历、文件系统统计(分别计算文件大小、数量、权限)、订单里对不同商品算不同税,都是典型场景。

一个最常见的调用链条是:element.accept(visitor),元素在accept里调用visitor.visit(this),注意这个this就会按元素真实类型找到对应的visit重载。真正逼退绝大多数人的不是写不出代码,而是场景没想明白。如果数据结构经常变,那访问者模式就是灾难,因为每加一个元素就要给所有访问者加一个visit方法。能说出"数据结构稳定、操作易变时用访问者,反过来用访问者会很痛苦",就已经把题答到点上了。

4. 综合题与SOLID设计原则:拉开差距的最后一问

最后这部分不是单纯背概念,而是面试官综合考察你设计能力的三道题。设计模式七七八八看了不少,但能不能在十分钟内做一道"设计XXX系统"的开放题,才是真正检验平时功力的地方。

4.1 Q23:五条SOLID原则分别怎么落地?开闭原则被问到时要怎么举例?

SOLID是五个设计原则的首字母缩写,面试老手都把它当作"设计模式的上位指导"。逐条过一遍,每一条都要有落地视角。

  • SRP单一职责原则:一个类应该只有一个引起它变化的原因。注意"一个"不是"一行",一个订单类只负责订单本身,不要把打印、发送短信都塞进来。
  • OCP开闭原则:对扩展开放、对修改关闭。这是最常追问的,必须准备一个具体例子。我的习惯答案是:新增支付方式时,不要改原支付服务的if-else,而是新增一个策略类并注册进去;新增报表导出格式时,不要动导出服务,而是新增一个Exporter实现。
  • LSP里氏替换原则:子类必须能无缝替换父类。最常见的反面案例就是"正方形继承长方形"——正方形的宽高两个属性会互相影响,父类setWidth(4); setHeight(5)后getArea()结果是25而不是20,替换后行为就不符合父类约定。
  • ISP接口隔离原则:客户端不该依赖它用不到的接口。一个大而全的接口拆成多个细粒度接口,类只需要实现自己用到的部分。
  • DIP依赖倒置原则:上层模块不依赖底层模块,都依赖抽象。Spring的依赖注入就是它的具体实践,实现类通过构造函数注入接口,而不是自己new一个具体类。

回答时如果你还能补一句"设计模式是SOLID的实现手段,SOLID是设计模式背后的指导思想",整个答案的维度就高一层。

4.2 Q24:面试官抛来一个"设计一个XX系统",怎么快速定位到该用哪种模式?

这类开放题是校招和社招的常见压轴,答题框架比具体答案重要得多。我建议按四步走:

第一步,先问清楚需求边界:系统要支持哪些渠道、哪些状态、哪些可能变化的点。这步最稳,也显得你像做过真实项目的人。第二步,找出变化点:这里最核心的是"什么会变"——算法会变就用策略,创建流程会变就用工厂,对象交互会变就用观察者或中介者,请求处理顺序会变就用责任链。第三步,优先考虑组合而不是单打独斗:比如设计一个支付系统,通常都是模板方法定义整体流程加策略处理渠道差异加工厂创建渠道对象,三个模式合体。第四步,克制:先想能不能用最简单的方式解决,千万不要为了用模式而用模式。

举个例子巩固一下:让你设计订单状态流转,你会先画出状态图,找出"动作发生在哪个状态"——这就是状态模式的天然触发点;如果是消息通知系统,你会想到事件监听和异步解耦——观察者模式就比较自然;如果是一个审批流程,多人按顺序审批——责任链模式能直接排上用场。把思考过程说出来,面试官能看到你的建模思路,比背出标准答案有价值得多。

最后如果问你"什么时候不该用模式",标准答案路线是:项目规模小、人员交流成本高、业务几乎不变的情况下,优先简单直接;模式是应对变化的手段,不是强制披上的外衣。

4.3 Q25:解释器、备忘录、中介者这类冷门模式,面试时说"没用过"是不是就完了?

先说结论:没用过不代表没分,但只会说"没用过"是真没分。面试官问冷门模式,通常不是真的指望你在生产环境手写过解释器,而是想看你对知识边界的态度和自学能力。

解释器模式我多用一句话带过:它定义一套文法规则,再用解释器逐条翻译执行,典型的像正则表达式引擎、SQL解析器底层都在用,但日常开发遇到规则引擎需求,一般直接用Aviator、SpEL这类现成库,手写解释器的场景确实太少。看到过、知道它的适用场景,比硬说"我很熟"诚实且加分。备忘录和中介者前面已经展开过,这里挑一个具体场景说一说就行。

这套题的末尾我再加一张速查表,复习时一眼就能定位查漏补缺:

分类题号核心考点一句话回答锚点
创建型Q1-Q2单例线程安全与防御volatile禁重排,枚举最强
创建型Q3-Q5工厂、建造者、原型工厂管创建维度,Builder管参数维度,拷贝小心浅拷贝
结构型Q6-Q7代理与装饰器代理控访问,装饰器做增强
结构型Q8-Q10适配器、外观、桥接适配管接轨,外观管收口,桥接管分离
结构型Q11-Q12组合、享元统一树形节点,共享内部状态
行为型Q13-Q15策略、模板、观察者策略换算法,模板定骨架,观察者做解耦
行为型Q16-Q18责任链、迭代器、状态链式传递请求,fail-fast防并发修改,状态自动流转
行为型Q19-Q22命令、中介者、备忘录、访问者命令可撤销,中介集中协调,快照要深拷贝,访问者双分派
综合Q23-Q25SOLID、选型、冷门模式面向变化编程,克制优于炫技

最后再多说一句实际体会。整理这份题库之前,我自己也经历过背答案的时期,后来当了面试官才发现,一个人是不是真正理解设计模式,三句话就听出来了。死记硬背的人会纠结"这个模式属于哪一类、那个模式叫什么名字",理解到位的人会直接说"这里会变,所以我用策略把变化隔离掉"。你把这些题目过一遍时,多问自己一句"这个模式到底为哪类变化服务",比把25个答案背得滚瓜烂熟重要得多。真正到了面试现场,你随口举一个自己项目里的真实例子,比背出任何标准答案都更有说服力。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询