返回资源中心

设计模式面试题精选

技术文章设计模式真实面试题:单例几种写法、策略+工厂、责任链、观察者、模板方法、代理、开闭原则与里氏替换、Spring 中的设计模式等,每题含标准答案、拓展与项目应用。小红学堂更新于 2026年8月30日314 次阅读

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 中不同容器都可能各自有实例,业务上说的“全局唯一”不能脱离作用域。

常见实现有以下几种:

  1. 饿汉式:类初始化时直接创建实例,简单且由类加载保证线程安全,但实例可能一直用不到。
  2. 懒汉式加锁:第一次调用时创建,写法简单但每次访问都可能付出锁开销。
  3. 双重检查锁(DCL):外层判空减少加锁,内层判空保证只创建一次;实例必须声明为 volatile
  4. 静态内部类:利用内部类的延迟初始化,通常是手写单例中最简洁的方案。
  5. 枚举:由 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 的 BufferedInputStreamGZIPInputStream 等就是典型例子:基础流提供原始能力,外层流逐层增加缓冲、压缩等能力。

拓展与边界追问

  • “和代理最本质的区别?”两者结构都常用委托。装饰器的目标是让调用方选择并组合多种增强,通常每一层都实现同一业务能力;代理的目标是控制目标访问,增强组合由框架或基础设施决定。实际代码可能同时具备两种意图,要以职责而不是名称判断。
  • 装饰顺序会改变结果,例如“缓存后解密”和“解密后缓存”缓存的内容不同;应在组装处写清顺序,并为组合场景测试。
  • 装饰器应保持接口契约和异常语义,避免悄悄改变返回值、线程安全或资源关闭责任。层数过多时可以改成显式拦截器链。
  • 与继承相比,装饰器可以运行时组合,但调试调用栈更深、对象生命周期更复杂。

项目落地 ⭐:可以这样答:“第三方客户端外面按需叠加重试、缓存、监控和脱敏装饰器,核心客户端只负责一次真实调用。装配配置决定顺序,单元测试分别验证每层的职责和组合行为;涉及连接关闭、线程池等资源时由最外层明确负责,避免重复释放。”


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

@BuilderStringBuilder 和各种配置对象都体现了建造者思想,但 Lombok 只生成代码,不会自动保证业务校验、深拷贝和默认值语义;这些规则仍要在 build() 或构造器中明确。

拓展与边界追问

  • “建造者和工厂有什么区别?”工厂重点是选择并创建哪一种产品;建造者重点是按步骤组装一个复杂产品。两者可以组合:工厂选择 Builder,Builder 负责校验和组装。
  • “什么时候不用 Builder?”对象只有一两个必填参数、构造逻辑简单时普通构造器更清晰;过度使用会增加类和调用噪音。
  • 必填参数可放进 Builder 构造器或分阶段 Builder,避免 build() 才发现缺失;但分阶段类型会增加 API 复杂度,要看约束是否值得。
  • Builder 如果暴露可变集合、复用同一个 Builder 或在多线程共享,仍可能产生状态污染;生成对象时应做不可变快照。

项目落地 ⭐:可以说:“分页查询、导出配置和第三方请求参数都有较多可选项,我用 Builder 统一默认值并在 build() 集中校验,生成后对象不可变。简单 DTO 不会强行套 Builder;对于外部输入仍会在接口层做校验,避免把所有错误推迟到构建阶段。”


11. 🟢 你项目里用了哪些设计模式?(必问,最该准备)

标准答

这道题不是让你背出一串模式名,而是考察能否把设计决策和真实代码对应起来。建议只选 2~3 个自己确实能解释、能指出文件/模块边界的模式,每个按下面顺序回答:

  1. 场景和变化点是什么;
  2. 原始实现的痛点是什么;
  3. 模式的角色如何分工;
  4. 引入后的收益、代价和测试方式;
  5. 如果规模变化,是否会换成别的方案。

一个可口述的结构示例:

多渠道支付中,渠道数量会增加,原入口的 if-else 难以测试和扩展。
我定义 PayService 接口,每个渠道是一个策略实现,注册表负责按渠道选择,公共签名和审计放在独立组件。
新增渠道只增加实现和配置;代价是类数量增加、渠道 key 需要治理。
我为每个策略写正常、超时、重复请求测试,并用一条集成测试验证选择器和事务边界。

回答时要把“模式”与“框架机制”分开:例如 AOP 是代理思想,Spring 事件是观察者思想,不能只说“用了 Spring 所以用了所有模式”。

拓展与边界追问

  • “为什么不用一个大类?”说明变化频率、职责边界、测试隔离和发布风险,而不是只说“代码更优雅”。
  • “这个模式有什么缺点?”策略/工厂会增加类型和装配;责任链依赖顺序;观察者有失败和一致性问题;模板方法受继承限制;代理会隐藏调用边界。能主动讲代价比只讲收益更可信。
  • “如果只有两个分支呢?”可以保持简单分支,等变化稳定或重复出现再抽象。模式是解决问题的工具,不是验收指标。
  • “怎么证明真的用过?”准备一个正常流程、一个异常边界和一次扩展改动,能讲清日志、测试或线上排查证据,但不要虚构业务数据。

项目落地 ⭐:建议形成自己的 90 秒答案:“我重点讲策略 + 工厂和责任链。前者处理会不断增加的渠道差异,后者处理固定顺序的多级校验;两者都让核心入口保持稳定,但一个负责选择算法,一个负责传递职责。我也会说清缺点、监控和测试,而不是罗列 23 种模式。”


12. 🔴 Spring 里用到了哪些设计模式?

标准答

可以按 Spring 的典型组件来对应模式,但要说明它们解决的具体问题:

Spring 组件/机制体现的模式说明
BeanFactoryApplicationContext工厂/IoC负责对象创建、装配和生命周期,而不是业务代码直接 new
Bean 默认 singleton 作用域单例每个容器通常共享一个 Bean 实例,作用域可改,不是 JVM 全局唯一
AOP、事务拦截器代理在目标调用前后织入横切逻辑
JdbcTemplateRestTemplate模板方法 + 回调/组合固定资源管理和异常转换流程,把变化操作交给回调
ApplicationEvent / @EventListener观察者发布者与监听器解耦,默认是进程内事件
HandlerAdapterHttpMessageConverter适配器/策略适配不同 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?

标准答

按使用复杂度可以分几种方式:

  1. @Bean:在配置类方法中返回实例,适合手工组装第三方客户端或需要明确参数的对象。
  2. @Component/@Service + 组件扫描:适合由类自身声明、容器自动发现的组件。
  3. @ImportImportBeanDefinitionRegistrar:适合 starter 或框架按导入配置批量注册。
  4. BeanDefinitionRegistryPostProcessor:在容器实例化普通 Bean 前,动态添加 BeanDefinition,适合根据扫描结果或配置生成一批 Bean。
  5. 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 名称冲突、条件装配、优先级和可观测性。

拓展与边界追问

  • BeanDefinitionRegistryPostProcessorBeanPostProcessor 有什么区别?”前者改的是 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(备注「面试题」)

设计模式面试题精选 | 小红学堂