1. 为什么从前慢这三个字恰好戳中了设计模式学习的痛点木心那首《从前慢》里写从前的日色变得慢车、马、邮件都慢放到今天的技术语境里这句话有种奇妙的错位感。我们写代码的速度越来越快框架迭代越来越频繁一个需求从提出到上线可能就几天但恰恰是在这种快里设计模式这种东西反而被很多人学得越来越慢——慢在理解慢在落地慢在从知道有这么个模式到真的能在项目里用对之间那道看不见的坎。这篇是从前慢系列里讲 Java 设计模式的第二篇。第一篇大概率是把设计模式的整体地图铺开了讲了它是什么、为什么值得学、分几大类。到了第二篇就得往深里走了不能再停留在单例模式就是保证一个类只有一个实例这种背诵层面而要回答一个更实际的问题——当你在真实项目里面对一段需要重构的代码时你怎么判断该用哪个模式怎么用得不别扭怎么用完之后同事不会在背后骂你过度设计。我做了十多年一线开发带过不少新人也 review 过大量代码。一个很普遍的现象是很多人能背出 23 种设计模式的名字和定义但真让他改一段业务代码他要么完全想不起来用要么生搬硬套把一个本来十行的类拆成五个接口加三个实现类最后维护成本翻倍。这不是设计模式的问题是学习路径的问题——设计模式从来不是学会的是改会的、疼会的。你只有先在一段烂代码上吃过苦才知道某个模式为什么长成那个样子。所以这篇的定位很明确面向已经了解设计模式基本概念、但在实际项目中不知道怎么落地的 Java 开发者。我会围绕创建型、结构型、行为型里几个最容易用错也最值得深挖的模式讲清楚它们的适用边界、判断依据、落地写法以及我踩过的那些坑。不追求把 23 种全讲一遍而是把最常打交道的几个讲到能直接抄作业的程度。读完之后你至少能做到看到一段代码能判断它该不该动动了之后该往哪个方向动。需要先说明一点设计模式本身是语言无关的思想但 Java 的语法特性接口、抽象类、泛型、内部类、Lambda会直接影响某个模式的写法。同一个策略模式Java 8 之前和之后写出来完全是两个样子。所以文中所有示例都以 Java 为准并且会标注哪些写法依赖较新的语言特性方便你根据项目的 JDK 版本做取舍。2. 单例模式从饿汉懒汉到枚举的演进以及为什么大多数场景你根本不需要它单例大概是所有人学的第一个设计模式也是最容易被滥用的一個。我见过太多项目动不动就XxxManager.getInstance()仿佛不加个 getInstance 就显得不够专业。但单例的本质约束其实非常强全局唯一实例 全局访问点。这两个条件同时成立才叫单例。很多所谓的单例其实只是我想在任何地方都能拿到这个对象那用依赖注入或者静态工具类就够了没必要上单例。2.1 五种写法的取舍为什么双重检查锁曾经是标准答案后来又不再是先把几种经典写法摆出来对比这张表我建议直接存下来面试和实际选型都用得上。写法线程安全懒加载防反射破坏防序列化破坏推荐场景饿汉式是否否否实例小、启动即用懒汉式同步方法是是否否几乎不推荐性能差双重检查锁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; } }这里那个volatile是关键也是很多人漏掉的。为什么必须加因为instance new Singleton()这行代码在字节码层面不是原子的它分三步分配内存、初始化对象、把引用指向内存地址。JVM 可能对这三步做指令重排如果重排成分配内存 → 引用指向 → 初始化那么另一个线程在第一个if判断时可能看到一个非 null 但还没初始化完的对象拿过去用就出问题了。volatile通过内存屏障禁止这种重排保证可见性和有序性。我早年在一个老项目里就踩过这个坑某段代码的 DCL 单例没加volatile本地测试怎么都复现不了一上生产环境高并发就偶发空指针查了整整两天才定位到。这种 bug 的可怕之处在于它不可稳定复现靠 code review 也容易漏只能靠规范约束。所以后来我给自己定的规矩是能用静态内部类或枚举就绝不手写 DCL。静态内部类的写法优雅得多public class Singleton { private Singleton() {} private static class Holder { private static final Singleton INSTANCE new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; } }它利用了 JVM 的类加载机制Holder类只有在第一次被引用时才会加载加载过程由 JVM 保证线程安全天然实现了懒加载加线程安全还不用加锁。这是我在绝大多数场景下的首选。2.2 枚举单例为什么被《Effective Java》力推以及它的真实局限枚举写法短得让人怀疑人生public enum Singleton { INSTANCE; public void doSomething() { } }它天然防反射枚举的构造器不能被反射调用、防序列化破坏枚举序列化时 JVM 保证反序列化得到同一个实例还天然线程安全。所以《Effective Java》直接说单元素的枚举类型经常成为实现 Singleton 的最佳方法。但我要泼一盆冷水枚举单例不是万能的它最大的局限是不支持懒加载。枚举类在类加载时就会初始化所有枚举常量如果你的单例对象初始化很重比如要读大配置文件、建连接池那启动时就会拖慢。另外枚举不能继承其他类它已经隐式继承了Enum如果你的单例需要继承某个基类枚举就用不了。还有一个现实问题很多老代码风格里团队对枚举单例接受度不高你贸然引入可能引发争论。我的实际经验是无状态、初始化轻量的单例比如各种工具类、配置持有类优先用枚举需要懒加载或需要继承的用静态内部类只有在维护老代码时才碰 DCL且必须确认有volatile。2.3 什么时候你其实不该用单例这是我想重点说的因为踩坑最多的不是怎么写单例而是该不该用单例。单例的本质问题是它引入了全局状态。全局状态意味着任何地方都能改它任何测试都可能受它影响单元测试里你没法轻易替换它。我见过一个项目一个ConfigManager单例持有数据库连接配置结果写单元测试时测试 A 改了配置测试 B 就跑挂了排查半天发现是单例在作祟。判断标准其实很简单问自己三个问题这个对象真的需要全局唯一吗还是只是图方便到处拿它有状态吗如果无状态纯工具方法用静态方法或工具类更合适。它需要被替换或 mock吗如果需要单例就是障碍应该用依赖注入。举个反例日志对象。很多人写个LogUtil.getInstance()但其实日志框架如 SLF4J本身就是通过工厂拿 logger每个类持有自己的 logger 实例根本不需要你包一层单例。再比如各种XxxFactory如果工厂本身无状态做成静态工厂方法就够了没必要单例。提示判断一个类该不该做成单例最实用的检验方法是——假设明天产品要求这个类支持多套配置并存比如多租户你的单例还能撑住吗撑不住说明它本来就不该是单例。3. 工厂模式简单工厂、工厂方法、抽象工厂到底差在哪什么时候该升级工厂家族是第二个重灾区。三种工厂名字相近很多人学完一脸懵不都是创建对象吗搞这么多花样干嘛我用一句话先给结论它们的区别不在于能不能创建对象而在于当产品种类增加时你需要改多少代码。这就是开闭原则在创建型模式里的具体体现。3.1 用一段真实的重构过程看清三者的递进关系假设你在做一个支付模块最初只支持一种支付方式。最朴素的写法public class PaymentService { public void pay(String type, BigDecimal amount) { if (alipay.equals(type)) { // 支付宝支付逻辑 } else if (wechat.equals(type)) { // 微信支付逻辑 } } }这就是典型的简单工厂雏形——用一个方法根据参数决定创建/执行哪种逻辑。简单工厂严格说不算 GoF 23 种模式之一但它最直观。问题在于每加一种支付方式你都要改这个if-else违反开闭原则而且方法会越来越臃肿。于是升级到工厂方法为每种产品定义一个工厂类。public interface Payment { void pay(BigDecimal amount); } public interface PaymentFactory { Payment create(); } public class AlipayFactory implements PaymentFactory { public Payment create() { return new AlipayPayment(); } }新增支付方式时你只需要新增一个Payment实现和一个PaymentFactory实现不改动任何已有代码。这就是工厂方法相对简单工厂的核心价值把变化点隔离到新增的类里而不是修改老代码。那抽象工厂又是什么当你的产品不是单一维度而是产品族时用它。比如支付模块不仅要创建支付方式还要创建配套的对账单生成器退款处理器且这些产品必须成套匹配支付宝的支付配支付宝的对账单。这时抽象工厂的接口里会有多个创建方法public interface PaymentSuiteFactory { Payment createPayment(); BillGenerator createBillGenerator(); RefundHandler createRefundHandler(); }一个具体工厂如AlipaySuiteFactory负责生产一整套互相兼容的产品。抽象工厂适合产品族场景工厂方法适合单一产品维度场景简单工厂适合产品种类极少且稳定的场景。3.2 工厂方法在 Java 里的现代写法Lambda 让工厂类消失Java 8 之后如果一个工厂接口只有一个方法函数式接口你完全可以用 Lambda 或方法引用来替代显式的工厂类代码量骤减MapString, SupplierPayment registry new HashMap(); registry.put(alipay, AlipayPayment::new); registry.put(wechat, WechatPayment::new); Payment payment registry.get(type).get();这段代码本质上就是一个注册表式工厂用Supplier代替了工厂接口用 Map 代替了 if-else。我在实际项目里非常偏爱这种写法因为它把产品注册变成了数据配置新增产品只需往 Map 里加一行连类都不用新建如果产品类本身简单的话。但要注意一个坑这种写法把类型判断从编译期推到了运行期。如果type传了个 Map 里没有的 keyregistry.get(type)返回 null.get()直接空指针。所以务必加防御SupplierPayment supplier registry.get(type); if (supplier null) { throw new IllegalArgumentException(不支持的支付类型: type); } return supplier.get();3.3 工厂滥用的典型症状为每个类都配一个工厂我 review 代码时见过最离谱的是一个只有三个实体类的 CRUD 项目硬生生写了三个工厂接口加三个实现。问为什么答设计模式要求这样。这就是典型的过度设计。工厂模式的价值在于封装创建逻辑的复杂性或变化性。如果一个对象的创建就是new Xxx()一行没有任何条件分支、没有复杂初始化、未来也不太可能换实现那工厂就是纯负担。判断标准创建逻辑是否复杂需要组装多个依赖、读配置、做校验产品类型是否会增长未来大概率加新类型调用方是否不应该知道具体实现类比如框架层给业务层提供扩展点三个里满足至少一个工厂才有意义。否则老老实实new别给自己找事。4. 代理模式静态代理、JDK 动态代理、CGLIB选错了性能和维护都遭殃代理模式在 Java 生态里存在感极强Spring 的 AOP、MyBatis 的 Mapper 接口、各种 RPC 框架的远程调用底层都是代理。但正因为框架帮你封装好了很多人对代理的理解停留在就是加个中间层一旦要自己手写或者排查代理相关的 bug就抓瞎了。4.1 三种代理的实现差异与选择依据静态代理最直白手写一个实现相同接口的类持有目标对象在调用前后加逻辑。public class UserServiceProxy implements UserService { private final UserService target; public UserServiceProxy(UserService target) { this.target target; } public User getUser(Long id) { long start System.currentTimeMillis(); try { return target.getUser(id); } finally { System.out.println(耗时: (System.currentTimeMillis() - start)); } } }优点是简单、无魔法、性能最好缺点是每个被代理的类都要手写一个代理类类一多就爆炸。JDK 动态代理通过InvocationHandler在运行时生成代理类要求目标类必须实现接口UserService proxy (UserService) Proxy.newProxyInstance( UserService.class.getClassLoader(), new Class[]{UserService.class}, (p, method, args) - { long start System.currentTimeMillis(); Object result method.invoke(target, args); System.out.println(method.getName() 耗时: (System.currentTimeMillis() - start)); return result; } );CGLIB通过字节码生成子类来代理不要求接口但不能代理 final 类和 final 方法。三者的选择我总结成一张表维度静态代理JDK 动态代理CGLIB是否需要接口是实现同接口是否代理类生成时机编译期运行期运行期性能最高中等反射调用较高但生成慢能否代理 final 方法能手写不涉及不能典型使用者手写场景Spring 默认有接口时Spring无接口时Spring AOP 的默认策略就是目标类有接口用 JDK 动态代理没接口用 CGLIB。这个策略不是随便定的——JDK 代理生成的代理类和目标类实现同一接口所以只能通过接口调用CGLIB 生成的是子类能代理类本身但不能碰 final。4.2 一个真实的坑JDK 动态代理里this调用不走代理这个坑我敢说 90% 的人都踩过或即将踩。假设你有个类方法 A 调用了同类的方法 B而 B 上加了事务或日志注解通过代理实现。你期望调用 A 时 B 的增强也生效结果发现没生效。原因很简单代理对象调用 A 时A 内部执行this.b()这个this是目标对象本身不是代理对象所以 B 的增强逻辑根本没机会介入。这是代理模式基于组合/继承实现的必然结果——增强只发生在从外部经过代理的调用路径上。解决办法有几种把 B 抽到另一个 Bean 里通过注入调用用AopContext.currentProxy()拿到当前代理再调需要开启exposeProxy或者干脆重构别在类内部做这种自调用。我一般推荐第一种最干净。注意这个坑在事务场景下尤其致命——你以为方法 B 有Transactional结果因为自调用事务压根没开数据一致性出问题还很难查。排查这类问题时先看调用是不是走了代理比看注解配置快得多。4.3 动态代理的性能真相别被反射慢吓到网上很多文章说JDK 动态代理基于反射性能差要用 CGLIB。这话在十几年前可能成立但现代 JVM 对反射做了大量优化比如MethodHandle、反射调用点缓存JDK 8 之后 JDK 动态代理和 CGLIB 的性能差距已经很小在大多数业务场景下完全可以忽略。真正影响性能的是代理对象的创建开销而不是调用开销。CGLIB 生成字节码比 JDK 反射生成代理类慢所以如果你的场景是频繁创建代理对象比如每次请求都 new 一个CGLIB 反而更吃亏。而 Spring 的 Bean 是单例的代理只创建一次所以这个差异被摊薄了。我的建议是别为了性能纠结选哪个按有没有接口来选就行。有接口用 JDK没接口用 CGLIB这是最省心的策略。真到了性能瓶颈用 JMH 压测出数据再优化别凭感觉。5. 策略模式与模板方法一对容易混淆的兄弟用错了代码会越来越乱策略和模板方法都属于行为型模式都涉及把可变部分抽出来所以经常被混用。但它们的适用场景有本质区别用错了会让代码结构变得很别扭。我见过把本该用策略的地方写成模板方法结果继承层次深得像俄罗斯套娃改一个逻辑要翻五层父类。5.1 核心区别一个是换算法一个是定骨架策略模式解决的是同一件事有多种做法运行时选一种。比如排序有快排、归并、冒泡计算运费有按重量、按件数、按距离。它的结构是定义一个策略接口多个实现类一个上下文持有策略并委托调用。模板方法解决的是一件事的流程固定但某些步骤的实现可变。比如处理订单的流程永远是校验 → 扣库存 → 支付 → 发通知但不同业务线的校验和发通知细节不同。它的结构是抽象类定义final的流程方法把可变步骤声明为抽象方法交给子类实现。一句话记忆策略是横向的多个平级实现模板方法是纵向的继承扩展。5.2 策略模式的现代落地用 Map 函数式接口干掉 if-else策略模式最经典的用法就是消灭 if-else。传统写法要定义接口、写多个实现类、再写个上下文类数量不少。Java 8 之后可以大幅简化public interface DiscountStrategy { BigDecimal apply(BigDecimal price); } private final MapString, DiscountStrategy strategies Map.of( none, price - price, vip, price - price.multiply(new BigDecimal(0.9)), svip, price - price.multiply(new BigDecimal(0.8)) ); public BigDecimal calc(String level, BigDecimal price) { return strategies.getOrDefault(level, p - p).apply(price); }这种写法把策略变成了配置数据新增策略只需加一行 Map 条目。但要注意当策略逻辑复杂到几十行时Lambda 里塞不下还是得老老实实写实现类。函数式写法适合逻辑简单的策略复杂策略用类更清晰。还有一个进阶技巧如果策略需要 Spring 的依赖注入比如策略里要查数据库可以用Component把每个策略注册成 Bean然后利用 Spring 的MapString, DiscountStrategy自动注入——Spring 会把所有该类型的 Bean 按名字塞进 Map。这个技巧我在多个项目里用过非常顺手Service public class DiscountService { private final MapString, DiscountStrategy strategyMap; public DiscountService(MapString, DiscountStrategy strategyMap) { this.strategyMap strategyMap; } }Bean 的名字就是 Map 的 key新增策略只要加个Component(xxx)完全不用改DiscountService。5.3 模板方法的钩子设计别把抽象类写成填空题地狱模板方法的抽象类设计有个度。我见过一个抽象类定义了 8 个抽象方法子类必须全部实现结果每个子类里一半方法是空实现或throw new UnsupportedOperationException()。这就是典型的钩子设计失败——把不该强制的步骤设成了抽象方法。正确的做法是区分必须实现的抽象方法和可选的钩子方法。钩子方法给个默认空实现子类按需覆盖public abstract class OrderProcessor { public final void process(Order order) { validate(order); if (needLockStock()) { // 钩子默认 true lockStock(order); } pay(order); afterProcess(order); // 钩子默认空实现 } protected abstract void validate(Order order); protected abstract void pay(Order order); protected boolean needLockStock() { return true; } protected void afterProcess(Order order) { } }这样大部分子类只需实现两个核心方法特殊子类再覆盖钩子。判断一个步骤该不该做成抽象方法标准是是否所有子类都必须有不同实现只要有一个子类能复用默认逻辑就该做成钩子。另外模板方法的流程方法一定要加final防止子类覆盖整个流程——这是模板方法的契约流程骨架由父类掌控子类只能填步骤。6. 观察者模式与责任链事件驱动和解耦的两把利器但都有失控风险这两个模式在现代框架里无处不在Spring 的ApplicationEvent、Servlet 的 Filter、Netty 的 Pipeline但自己手写时如果不注意边界很容易搞出难以调试的幽灵调用。6.1 观察者模式同步还是异步这是个必须提前想清楚的问题观察者模式的核心是一对多依赖被观察者状态变化时通知所有观察者。Java 自带的Observer/Observable已经废弃Java 9 起标记 deprecated所以实际项目里都是自己实现或用框架的。自己实现的关键决策点是通知是同步还是异步同步通知简单但有个致命问题任何一个观察者抛异常或卡住都会影响被观察者和其他观察者。我见过一个订单创建后发通知的场景其中一个观察者要调外部接口结果外部接口超时整个下单流程被拖慢十几秒。这就是同步通知的坑。异步通知丢线程池能解决阻塞问题但引入了新问题异常被吞、顺序不保证、线程池满了怎么办。所以异步通知必须配异常处理和拒绝策略private final ExecutorService executor new ThreadPoolExecutor( 2, 4, 60, TimeUnit.SECONDS, new ArrayBlockingQueue(100), new ThreadPoolExecutor.CallerRunsPolicy() // 队列满时由调用线程执行保证不丢 ); public void notifyObservers(Event event) { for (Observer observer : observers) { executor.submit(() - { try { observer.onEvent(event); } catch (Exception e) { log.error(观察者处理失败: {}, observer.getClass(), e); } }); } }注意那个CallerRunsPolicy——队列满时让调用线程自己执行虽然会阻塞但至少不丢事件。这个策略在事件不能丢的场景下比直接丢弃安全得多。6.2 责任链模式链的顺序和中断逻辑是调试噩梦的根源责任链把多个处理器串成链请求沿链传递每个处理器决定处理并继续处理并中断还是跳过继续。Servlet Filter、审批流、参数校验链都是这个模式。手写责任链最常见的两种实现链表式和数组式。链表式灵活但每次都要遍历找下一个数组式用 List 存处理器用索引推进更简单可控我一般用数组式public class HandlerChain { private final ListHandler handlers; private int index 0; public void doHandle(Request req) { if (index handlers.size()) { handlers.get(index).handle(req, this); } } }每个Handler拿到chain后决定要不要调chain.doHandle(req)继续往下走。这种写法把是否继续的控制权交给了处理器很灵活。但责任链有两个大坑第一链的顺序敏感。校验链里如果权限校验排在参数校验后面那非法参数可能先触发权限异常报错信息误导排查。所以链的顺序必须有明确约定最好在配置处写注释说明。第二中断逻辑不透明。一个请求进来到底经过了哪几个处理器、在哪个处理器被中断如果不打日志排查起来就是黑盒。我的做法是在链的入口和每个处理器的进出都打 debug 日志并且给每个处理器一个名字出问题时一眼能看出走到哪了。提示责任链的处理器如果是有状态的比如计数器一定要用原型模式或每次新建别用单例——多个请求共享一个有状态的处理器数据会串。这个坑我在一个限流器里踩过限流计数被所有请求共享导致限流阈值完全失真。7. 我在实际项目里判断该不该上设计模式的一套土办法讲了这么多模式最后必须回到那个最实际的问题面对一段具体代码我怎么知道该不该重构、该上哪个模式我总结了一套自己用了很多年的判断流程不玄乎就是几个自问自答。7.1 三个问题过滤掉 80% 的过度设计问题一这段代码未来变化的概率有多大如果这个模块是稳定的、半年没动过、业务上也不太可能变那就别动它。设计模式是为变化准备的没有变化的地方上模式就是纯成本。我见过给一个写死的常量配置上策略模式的纯属自嗨。问题二变化的方向是增加种类还是修改逻辑增加种类加一种支付方式、加一种导出格式适合用策略/工厂因为可以靠新增类扩展修改逻辑同一个算法内部调整不适合硬套模式反而增加跳转层级。问题三不重构的痛是否已经真实发生了如果只是看起来不优雅忍忍如果已经导致改一个需求要动五个文件、加一个类型要改三处 if-else、线上出过因为耦合导致的 bug那就该动手了。痛感是最好的重构信号没有痛感的重构都是耍流氓。7.2 重构的落地节奏小步走每步可回滚真决定重构了我的习惯是绝不一次性大改。具体节奏先补测试。没有测试的重构等于裸奔。至少把当前行为的关键路径用单元测试覆盖住重构后跑一遍绿了才敢继续。先抽接口不动实现。把要变化的逻辑抽成接口原实现类实现该接口调用方改为依赖接口。这一步不改变任何行为风险最低。再逐个替换实现。新增的策略/工厂实现逐个接入每接一个跑一次测试。最后清理旧代码。确认新路径稳定后再删旧的 if-else 分支别急着删留一两个版本观察。这个节奏慢但稳。我见过太多一把梭重构改完发现某个边界场景没覆盖回滚都来不及。7.3 几个我反复验证过的经验点模式是手段不是目的。代码可读性永远优先于用了几个模式。一个清晰的 if-else 有时比三层策略调用更好懂尤其是团队新人多的时候。优先用语言特性替代模式。Java 8 的 Lambda、方法引用、Optional、Stream 能解决很多以前需要模式才能解决的问题。能用函数式简洁表达的别硬套类结构。框架已经提供的别自己造。Spring 的事件机制、AOP、Conditional已经覆盖了大量观察者、代理、工厂场景自己手写之前先确认框架有没有现成的。文档和注释比模式本身重要。你用了策略模式但没在类注释里写清楚这里为什么用策略、新增策略要改哪里那下一个维护的人照样懵。模式降低了耦合但没降低理解成本注释得补上。这套判断方法不完美但它帮我避开了大量为了设计而设计的坑。设计模式这东西学的时候要贪多用的时候要克制这个分寸感只能靠项目喂出来。我到现在也不敢说自己每个模式都用得恰到好处但至少能做到每次决定上模式之前先问自己那三个问题答不上来就先不动手。