资讯详情

Java设计模式实战:单例、工厂、策略、观察者的核心用法与避坑指南

📅 2026/10/14 6:37:49 | 华诺云谱 👁 阅读
Java设计模式实战:单例、工厂、策略、观察者的核心用法与避坑指南
做了几年Java以后我越来越觉得设计模式这东西不是用来在面试时背的而是用来解决具体问题的。这篇内容聚焦4种高频模式——单例、工厂、策略、观察者每套都配上可以直接跑的代码示例讲清楚它们到底解决什么问题、落地时有哪些坑。适合刚学完Java语法、想提升代码质量的开发者也适合写过一阵业务代码、正在为改不动代码头疼的人。我尽量把话说得直接一点少绕弯子。1. 为什么把目光锁在这4种模式上1.1 不是所有模式都需要背下来设计模式一共23种Gof那本书我翻了不下五遍但说实话日常业务代码里真正高频出现的其实就那几种。我统计过自己经手的几个项目单例、工厂、策略、观察者这4种加起来占了至少七成的使用场景。剩下的建造者、代理、装饰器等等不是不重要是它们的应用场景相对窄新手一上来全铺开很容易学着学着就放弃了。这4种模式有一个共同特点它们都解决的是“代码组织”层面的问题不涉及复杂的类层次推导也不需要你先理解什么SOLID原则才能上手。你只要有一个真实的业务场景立刻就能套进去。而且学完马上能在自己的项目里用上正反馈来得特别快。1.2 四类问题四种解药设计模式本质上是一套“套路”每一种套路对应一类问题的固定解法。我习惯把这4种模式理解成下面这张对照表模式解决的核心问题典型场景没有它时最痛苦的写法单例模式全局只需要一个实例且要控制创建时机配置管理器、连接池、线程池到处new资源浪费、状态不同步工厂模式调用方不知道也不该知道具体创建哪个实现类支付渠道、消息处理、文件解析业务代码里到处是switch分支策略模式同一行为有多种算法变体可以互相替换折扣计算、排序规则、费率计算if-else一层套一层改一处牵全身观察者模式一个状态变化需要触发多个后续动作且彼此解耦下单后发通知、减库存、加积分主流程里硬编码一串调用加一个动作改一次主逻辑这4种模式之所以高频是因为它们对应的场景在几乎每个业务系统里都会出现。你做一个订单系统会用到单例管理配置、工厂创建支付处理器、策略计算折扣、观察者广播下单事件。不需要刻意制造使用场景场景自己会来找你。2. 单例模式一个实例走天下2.1 先看需求配置管理器为什么非要唯一假设你在做一个后台服务系统里有一份全局配置启动时从配置文件加载业务代码到处要读。如果每次读取都new一个ConfigManager结果就是同一个配置文件被反复加载字符串解析一遍又一遍内存里躺着好几份一模一样的配置副本。更要命的是如果某个模块改了一下配置对象里的字段其他模块读到的还是旧对象数据就不一致了。单例模式的核心诉求很简单对于一个类在整个JVM生命周期内只允许存在一个实例并且提供一个全局访问点。写一个最基础的懒汉式单例只需要三行核心代码public class ConfigManager { private static ConfigManager instance; public static synchronized ConfigManager getInstance() { if (instance null) { instance new ConfigManager(); } return instance; } }这个方法声明了synchronized保证多线程环境下不会同时创建两个实例。但问题也在这里只要调用getInstance()就会加锁而实际上绝大多数调用场景是实例已经被创建了这锁加得毫无必要。早年我刚学的时候觉得这写法没问题后来压测发现高并发下这成了一个小热点性能虽然不至于崩但完全可以做得更好。2.2 双检锁并发安全与性能的平衡优化思路其实很直接既然大多数时候实例已经存在那就不需要加锁只有第一次发现instance null时才需要抢锁。这就要用到双重检查锁定了public class ConfigManager { private static volatile ConfigManager instance; private ConfigManager() {} public static ConfigManager getInstance() { if (instance null) { synchronized (ConfigManager.class) { if (instance null) { instance new ConfigManager(); } } } return instance; } }第一层判断先过滤掉大多数已初始化的情况减少无谓的锁竞争进入同步块后再判断一次保证多个线程同时通过第一层检查时也只有第一个能真正创建实例。这里有一个特别容易忽略的细节instance字段必须加volatile。原因在于new ConfigManager()并不是一个原子操作它在底层分三步走——分配内存、初始化对象、把引用赋值给变量。如果变量不加volatileJVM指令重排可能让第三步先于第二步执行另一个线程在第一次检查时发现instance已经不为null了直接拿去用结果拿到的是一个还没初始化完成的对象。这种bug极其隐蔽排查起来非常费劲我在真实项目里见过一次最后是全程看了好久才定位到是单例漏了volatile。2.3 静态内部类与枚举更优雅的答案双检锁已经是教科书级写法了但还有两个更省心的方案。静态内部类方案利用的是类加载机制只有在主动使用到内部类时类加载器才会去加载它从而天然实现了懒加载同时JVM保证类加载的线程安全性public class ConfigManager { private ConfigManager() {} private static class Holder { private static final ConfigManager INSTANCE new ConfigManager(); } public static ConfigManager getInstance() { return Holder.INSTANCE; } }没有synchronized的关键字没有volatile的顾虑代码简洁得不像话。我后来在项目中默认就是用这个方案。枚举单例更绝它从语言层面保证了实例唯一连反射攻击都能防御因为枚举类型通过反射创建新实例时是会直接抛异常的public enum ConfigManager { INSTANCE; private String configValue; public String getConfigValue() { return configValue; } public void setConfigValue(String value) { this.configValue value; } }但枚举单例有个实践上的小限制如果需要在类加载时读取外部资源初始化枚举的写法会比较别扭而且很多人看到枚举当单例用会愣一下代码可读性上稍微打折。所以我个人的选择倾向是常规业务类用静态内部类特殊场景用枚举。2.4 实战选择与注意事项在真实工程里单例模式最常见的引入方式其实不是自己写getInstance而是交给容器框架管理。比如一个Bean默认就是单例的你只管注入容器会帮你控制实例数量。这个做法比手写单例更干净因为创建和销毁的生命周期都交给容器了。但如果你还是需要手写单例牢记两条构造函数一定要私有化防止外部随意new如果类要序列化记得加readResolve()方法防止反序列化时创建第二个实例。很多初学者写单例代码看着没问题一做序列化反序列化就翻车原因就在这。3. 工厂模式把对象的创建权收编3.1 new关键字有什么问题先来看一个最常见的场景。一个订单系统支持多种支付方式每种支付方式对应一个处理器处理器负责和第三方渠道对接、扣款、返回结果。早期代码大概率长这样public void pay(Order order, String channel) { if (mobile.equals(channel)) { MobilePayProcessor processor new MobilePayProcessor(); processor.pay(order); } else if (card.equals(channel)) { CardPayProcessor processor new CardPayProcessor(); processor.pay(order); } else { throw new IllegalArgumentException(未知支付渠道); } }这段代码的问题不只是丑。第一每次新增一种支付渠道你都要跑到这里加一个else if这个类会越来越长改来改去特别容易碰坏别的分支第二业务代码直接依赖了具体处理器类如果构造方式变了比如MobilePayProcessor的构造函数从无参变成需要传一个密钥所有调用点都要跟着改。这套逻辑本质上就是把对象的创建过程和业务使用过程搅在了一起。3.2 简单工厂与工厂方法工厂模式的核心思路是把创建逻辑单独抽出来。先定义处理器接口public interface PaymentProcessor { void pay(Order order); } public class MobilePayProcessor implements PaymentProcessor { Override public void pay(Order order) { System.out.println(使用移动支付完成扣款 order.getAmount()); } } public class CardPayProcessor implements PaymentProcessor { Override public void pay(Order order) { System.out.println(使用银行卡支付完成扣款 order.getAmount()); } }然后写一个简单工厂public class PaymentProcessorFactory { public static PaymentProcessor create(String channel) { switch (channel) { case mobile: return new MobilePayProcessor(); case card: return new CardPayProcessor(); default: throw new IllegalArgumentException(未知支付渠道 channel); } } }调用方就不再需要关心具体创建细节了PaymentProcessor processor PaymentProcessorFactory.create(mobile); processor.pay(order);新增渠道时业务代码一行都不用改只需要往工厂里加一个case。不过简单工厂也有自己的边界它把所有创建逻辑塞在一个类里渠道一多这个工厂类会膨胀。这时候可以进一步演进到工厂方法模式把创建动作下放到子类public interface PaymentProcessorFactory { PaymentProcessor create(); } public class MobilePayProcessorFactory implements PaymentProcessorFactory { Override public PaymentProcessor create() { return new MobilePayProcessor(); } } public class CardPayProcessorFactory implements PaymentProcessorFactory { Override public PaymentProcessor create() { return new CardPayProcessor(); } }工厂方法模式把“创建哪种处理器”的决定权交给了调用方去选择用哪个工厂每个工厂只负责一种产品。代价是类的数量翻倍所以在实际项目中简单工厂反而更常用。不要因为某个模式更高端就强行升级适合业务复杂度的方案才是好方案。3.3 抽象工厂与容器框架抽象工厂模式比工厂方法又往后走了一步它解决的是一组配套对象的创建问题。举个例子一个后台管理系统支持浅色主题和深色主题主题包含按钮、弹窗、输入框三套组件你不可能让一个按钮是深色的、弹窗是浅色的那画面太美不敢看。抽象工厂保证一次创建出来的组件族是风格统一的。不过在绝大多数Web业务系统里抽象工厂的出现频率其实很低因为容器框架已经帮你把对象创建这层事做了。你只需要声明接口和实现类容器在启动时会扫描并实例化用的时候直接声明依赖容器会把对应实现注入进来。容器本质上就是一个超级工厂你在配置文件里定义的每个Bean就相当于在工厂里注册了一条创建规则。理解了工厂模式再看容器框架的设计思路会顺畅很多。3.4 工厂模式使用经验在动手写工厂之前先想清楚返回类型。工厂方法的返回类型一定要是接口而不是实现类否则调用方还是会瞄到具体类工厂的存在意义就丢失了。还有一点不要做万能工厂。有人喜欢把工厂写成一个巨大的工具类里面塞了十几类对象的创建方法看起来功能强大实际上每加一个方法都要改这个类职责一多迟早变成大泥球。一个工厂对应一类产品是保持清晰的底线。如果你的创建分支比较多可以考虑用Map来替代switch这样的好处是新增渠道时不用改switch语句本身代码会更接近开闭原则。后面策略模式章节我会展示这种Map 接口的玩法思路是通用的。4. 策略模式跟if-else说再见4.1 场景订单折扣计算里的if-else海啸折扣计算是业务代码里出了名的if-else重灾区。普通用户不打折会员打9折VIP打8折大促满300减50。如果全写在同一个方法里接下里的演变就是今天加一个“新人专享8折”明天加一个“老客户回馈满500减100”后天又说“会员生日双倍积分加九五折”。每次加规则都要打开这个方法在if-else链里找到合适的位置塞一段时间一长谁都不敢乱动这个老方法因为一动就可能影响别人的规则。经典的if-else写法长这样public BigDecimal calculate(BigDecimal amount, String userLevel) { if (normal.equals(userLevel)) { return amount; } else if (member.equals(userLevel)) { return amount.multiply(new BigDecimal(0.9)); } else if (vip.equals(userLevel)) { return amount.multiply(new BigDecimal(0.8)); } return amount; }这段代码读起来还行但规则一多每个分支里还加了些“一个满减叠加一个折扣”的复合逻辑这个方法的行数就会直奔一百行以上。更麻烦的是用户的等级类型直接出现在代码里一旦支付系统里的用户等级枚举改了名这里躺着中枪。4.2 策略接口与上下文类策略模式的思路是把每种折扣算法封装成一个独立的类统一实现同一个接口。先定义一个策略接口public interface DiscountStrategy { BigDecimal calculate(BigDecimal amount); }然后每种规则写一个实现类public class NoDiscountStrategy implements DiscountStrategy { Override public BigDecimal calculate(BigDecimal amount) { return amount; } } public class MemberDiscountStrategy implements DiscountStrategy { private static final BigDecimal MEMBER_DISCOUNT new BigDecimal(0.9); Override public BigDecimal calculate(BigDecimal amount) { return amount.multiply(MEMBER_DISCOUNT); } } public class VipDiscountStrategy implements DiscountStrategy { private static final BigDecimal VIP_DISCOUNT new BigDecimal(0.8); Override public BigDecimal calculate(BigDecimal amount) { return amount.multiply(VIP_DISCOUNT); } }有了策略类还需要一个上下文来持有当前选中的策略并且调用它public class OrderAmountCalculator { private final DiscountStrategy strategy; public OrderAmountCalculator(DiscountStrategy strategy) { this.strategy strategy; } public BigDecimal calculate(BigDecimal amount) { return strategy.calculate(amount); } }使用的时候只需要把正确的策略对象传进去DiscountStrategy strategy new VipDiscountStrategy(); OrderAmountCalculator calculator new OrderAmountCalculator(strategy); BigDecimal finalAmount calculator.calculate(new BigDecimal(500));这样做的好处表面上看是消除了if-else本质上其实是把“选择策略”和“执行策略”分开了。以后新增规则不需要碰已经存在的策略类只要新增一个实现类再把新类注入到可选集合里就行了。这比在老方法里插一段else if要安全得多。4.3 用Map 枚举做策略分发策略都封装好了但还有一个问题没解决下单的时候系统怎么知道该用哪个策略一种很常见的做法是用工厂模式配合Map来管理策略的获取也就是策略的注册表。先把策略类型定义成枚举public enum DiscountType { NONE, MEMBER, VIP }然后在初始化时把策略和枚举的映射关系确定下来public class DiscountStrategyRegistry { private final MapDiscountType, DiscountStrategy strategies new HashMap(); public DiscountStrategyRegistry() { strategies.put(DiscountType.NONE, new NoDiscountStrategy()); strategies.put(DiscountType.MEMBER, new MemberDiscountStrategy()); strategies.put(DiscountType.VIP, new VipDiscountStrategy()); } public DiscountStrategy getStrategy(DiscountType type) { return strategies.get(type); } }调用处就变成了DiscountStrategy strategy registry.getStrategy(DiscountType.VIP); BigDecimal finalAmount strategy.calculate(amount);这个组合在我实际项目中用得非常顺手。Map天然支持“根据Key找实现类”的语义比switch可读性更好而且新增策略类型时不需要动已有的注册代码只需要在初始化方法里多put一行这行代码本身就是一种配置。4.4 什么时候不推荐策略模式策略模式不是万能的遇到只有两三种规则而且几乎不会变的情况直接if-else反而是最直白的选择。我曾经在一个小型工具项目里强行套策略模式写了四个策略类加一个工厂类最后实际用到的只有两个策略代码量翻了一倍不只维护起来反而更费劲。还有一个常见的误区是把策略模式和状态模式搞混。策略模式解决的是“同一个任务可以换算法”调用方明确知道自己在选哪个策略状态模式解决的是“对象状态变了行为跟着变”调用方通常不感知具体状态。如果你的折扣规则不仅和用户等级有关还和订单状态、时间、场景有关那可能要考虑的就不是策略模式而是规则引擎了。把模式用错了场景比不用还麻烦。5. 观察者模式让代码学会广播5.1 需求下单后的一连串动作订单服务是学习观察者模式最经典的场景。正常一个下单接口做完核心的订单落库之后后面还跟着一长串事情给用户发下单成功短信、给用户账户加积分、给运营系统记录一笔统计日志、通知仓储系统预留库存。如果全部写在createOrder方法里代码会变成这样public void createOrder(Order order) { // 1. 核心逻辑 orderRepository.save(order); // 2. 周边逻辑 messageService.sendSms(order.getUserId()); pointsService.addPoints(order.getUserId(), order.getAmount()); statisticsService.record(order); warehouseService.reserve(order); }这里面每个Service的调用都是硬编码的。未来再加一个“下单后推送优惠券”、“下单后同步到搜索索引”的时候你就要再次打开createOrder方法往里面再塞一行调用。这个订单方法会逐渐变成一个“上帝方法”什么都知道、什么都要做改一次就提心吊胆一次。观察者模式的思路是改为主角只管自己的事其他模块通过订阅来感知事件。下单这个动作用广播代替直接调用。5.2 手写一个极简事件总线观察者模式的经典实现是定义Subject被观察者和Observer观察者但为了贴近真实工程的使用习惯我更喜欢直接用事件总线的思路来演示。先定义一个事件监听器接口public interface EventListener { void onEvent(OrderEvent event); }再定义一个简单的事件对象public class OrderEvent { private final Long orderId; private final Long userId; private final String eventType; public OrderEvent(Long orderId, Long userId, String eventType) { this.orderId orderId; this.userId userId; this.eventType eventType; } public Long getOrderId() { return orderId; } public Long getUserId() { return userId; } public String getEventType() { return eventType; } }然后写一个最简化的事件总线负责注册监听器和发布事件public class SimpleEventBus { private final ListEventListener listeners new CopyOnWriteArrayList(); public void register(EventListener listener) { listeners.add(listener); } public void publish(OrderEvent event) { for (EventListener listener : listeners) { listener.onEvent(event); } } }用CopyOnWriteArrayList是为了保证迭代时如果有线程注册新监听器不会抛出并发修改异常同时读多写少的情况下性能也够用。5.3 业务改进订单事件发布有了事件总线订单服务就可以回归本真了public class OrderService { private final SimpleEventBus eventBus; public OrderService(SimpleEventBus eventBus) { this.eventBus eventBus; } public void createOrder(Order order) { // 核心逻辑 orderRepository.save(order); // 发布事件 eventBus.publish(new OrderEvent(order.getId(), order.getUserId(), ORDER_CREATED)); } }而发短信、加积分、记录日志这些逻辑全部被拆成独立的监听器public class SmsNoticeListener implements EventListener { Override public void onEvent(OrderEvent event) { if (ORDER_CREATED.equals(event.getEventType())) { System.out.println(给用户 event.getUserId() 发送下单成功短信); } } } public class PointsListener implements EventListener { Override public void onEvent(OrderEvent event) { if (ORDER_CREATED.equals(event.getEventType())) { System.out.println(给用户 event.getUserId() 增加积分); } } }使用的时候只需要在系统初始化阶段把监听器注册到总线里。业务逻辑和方法本身解耦了以后想新增一个“下单后推送优惠券”的动作只需要写一个优惠券监听器注册一下订单服务一行代码都不用动。这就是观察者模式的核心价值让发布者不关心谁在听让订阅者不关心谁在说双方都只依赖事件这个中间层。我实际写代码时习惯让事件对象承载尽可能丰富的上下文不要把userId、orderId单独拆开传。因为监听器拿到事件后可能需要很多信息事件对象字段少了后面还得回查数据库反而绕路。5.4 观察者模式的坑观察者模式用起来很爽但坑也不少。第一个坑是异常处理。如果某个监听器抛了异常默认实现里后面的监听器全部会被拦下来。有一次我线上遇到一个问题下单后积分服务突然报错结果短信服务也没发出去用户没收到任何通知。后来改成在每个监听器内部try-catch并且给事件总线加了一层异常隔离任何一个监听器失败都不影响其他监听器。第二个坑是执行顺序。如果监听器之间有依赖关系比如必须先扣库存再发通知那注册顺序就很关键而且会变得隐晦。要么在事件设计上让各个监听器互不依赖要么就引入一个带优先级的监听器列表把顺序这个事显式表达出来。第三个坑是异步问题。同步事件总线下单接口会被所有监听器拖慢如果短信接口本身要300毫秒积分接口要200毫秒那用户下单就得等500毫秒加核心逻辑的时间。很多时候这些周边操作根本不需要同步等结果改成异步发布才是对的。但异步之后又带来了事务一致性的麻烦周边逻辑失败怎么回滚没有完美的答案要根据业务容忍度取舍。我的原则是核心流程同步处理周边通知类操作异步化并且做好失败补偿。6. 常见问题与排查技巧实录6.1 单例被反射和序列化破坏怎么办手写单例最常见的问题不是并发而是被人用反射硬生生造出第二个实例。你以为是单例结果别人调用setAccessible(true)绕过私有构造函数照样创建新对象。解决方案有三个层次最简单的是在构造函数里加判断实例已存在就抛异常这能挡住大部分反射攻击更彻底的是用枚举单例枚举天然禁止反射创建新实例如果类需要序列化再加一个readResolve()方法返回当前实例而不是反序列化出来的新对象。我在实际项目里见过因为序列化破坏单例导致的线上事故配置文件被读了两份不同的模块拿到不同版本排查了大半天才定位到是单例被破坏。6.2 策略Map初始化时的顺序问题用Map管理策略时有一个坑如果策略对象本身在构造函数里有依赖而依赖还没准备好你会在初始化阶段抛空指针。有一次我负责的一个系统在启动时加载策略注册表某个策略的构造函数需要读取数据库配置而这时候数据源还没初始化好启动直接失败。解决办法是把策略注册的时机往后挪挪到依赖就绪之后再做。还有一个更隐蔽的问题是Map的遍历顺序不固定如果你的策略之间有优先级关系用Map天然无法表达。这种情况我一般改用LinkedHashMap它保留插入顺序谁的优先级高就先put谁。6.3 观察者异常导致主流程中断我在前面已经提到过一次这里再强调一遍默认写法的同步事件总线只要一个监听器抛异常事件发布循环就会终止。这不是观察者模式的问题是我们没做好防御性编程。实战里我总结了一套处理手法监听器内部必须自行捕获异常并记录日志如果是需要保证多监听器都要执行完的场景事件总线里把每个监听器的调用包在单独的try-catch里如果有些监听器比较耗时把它们丢到线程池里异步执行如果对实时性要求不高那就干脆用消息队列代替进程内事件总线把事件发到队列里由下游消费者处理。方案选哪个取决于业务对一致性的要求。6.4 一点学习心得图纸永远代替不了工地模式学到手里最终还是要落到代码里才算数。我见过不少人把设计模式的类和接口画得有模有样一到真实项目就手忙脚乱因为真实项目里没有“按需调用”的测试用例等着你只有混乱的历史代码、模糊的业务需求和非改不可的deadline。我的建议是从小处开始练。不用为了展示模式而写一个专门的项目而是在改自己负责的代码时看到超过三层嵌套的if-else想一想能不能用策略模式重构成一张Map看到一个方法里又做核心业务又发通知又记日志想一想能不能用观察者模式剥开看到一个类被new了好多次而且构造时要做一堆依赖准备想一想是不是该引入工厂。模式不是用来炫技的它是用来减少改代码时的痛苦的。一开始套模式肯定会觉得多写了不少类但等你下次改需求时发现只需要动一个文件你就明白这些功夫值在哪里了。还有一个小建议代码里多写注释说明设计意图。比如一个类为什么要继承某个接口、为什么这里的Map就是策略注册表这些上下文注释比代码本身更宝贵。半年后回来看自己写的代码看到注释就能快速进入状态省掉大把重新理解的时间。设计模式是个需要反复练习的手艺活图纸看得再多也不如自己动手砌一面墙。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑