设计模式
1. 你知道那些设计模式呢?⭐
总体来说设计模式分为三大类 :
-
创造型模式 : 解决“对象的创建”问题。常用的有单例模式、工厂方法模式、抽象工厂模式、建造者模式(Builder)。
-
结构型模型 : 解决"类或对象的组合”问题。适配器模式、装饰器模式、代理模式、外观模式、桥接模式、组合模s式、享元模式。
-
行为型模式 : 解决“对象之间的通信”问题。它定义了对象在运行时如何交互,使复杂的逻辑流程变得清晰、可维护。策略模式(消除 if-else)、观察者模式、模板方法模式。
2. 简单介绍一下单例模式, 以及它的优缺点⭐
单例模式是创建型模式中最简单也最常用的一种。它的核心目标是:保证一个类在整个系统中只有一个实例,并提供一个全局访问点。
优点
-
资源节约:由于整个系统只存在一个实例,减少了频繁创建和销毁对象带来的内存消耗和 CPU 开销。
-
全局访问:提供了一个统一的访问入口,方便对全局状态或配置进行管理(如数据库连接池、读取配置文件)。
-
避免冲突:对于某些必须具有唯一性的资源(如打印机句柄、序列号生成器),可以有效防止多个实例导致的资源争用。
缺点
- 扩展困难:单例模式通常没有接口,且构造方法私有化,这导致它很难通过继承来扩展。
- 违背单一职责原则:单例类既要负责自己的业务逻辑,还要负责“实例的创建与自我管理”,职责较重。
- 对测试不友好:在单元测试中,由于单例是全局的,很难模拟出一个“干净”的初始状态,可能会导致测试用例之间产生相互影响。
- 垃圾回收风险:如果单例持有长生命周期的对象(如 Context),容易造成内存泄漏;同时,如果长时间不使用,某些框架可能会将其回收,导致状态丢失。
3. 为什么 DCL 需要用 volatile 关键字呢? ⭐
“因为 instance = new Singleton(); 并不是一个原子操作,它分为:分配空间、初始化对象、引用指向空间这三步。如果不加 volatile,由于 JVM 的指令重排,可能会先执行引用指向空间。此时另一个线程进来执行第一重 null 检查,发现引用不为空,直接拿走了一个还没初始化完成的半成品对象,从而导致程序崩溃。”
4. 单例模式的实现方式
饿汉式(简单,但费内存)
类加载时就创建实例。虽然浪费点内存,但没有锁,执行最快,天生线程安全。
public class Singleton {
// 类加载时就初始化,由于是 static,JVM 保证线程安全
private static final Singleton INSTANCE = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return INSTANCE;
}
}
懒汉式
“临阵磨枪”。只有调用时才创建。为了安全在方法上加了 synchronized 大锁,导致并发性能极差,不推荐。
public class Singleton {
private static Singleton instance;
private Singleton() {}
// 给整个方法加锁,性能太低,不推荐
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
DCL⭐⭐⭐
“按需取货+精准锁”。利用 volatile 和双重判断,既实现了延迟加载,又保证了高并发下的高性能,是面试最标准的写法。
public class Singleton {
// 1. volatile 关键字:禁止指令重排,确保多线程下变量的可见性
private static volatile Singleton instance;
// 2. 私有构造方法:防止外部通过 new 创建实例
private Singleton() {}
public static Singleton getInstance() {
// 3. 第一重检查:提高效率,如果已经创建了就不用再进入同步块
if (instance == null) {
synchronized (Singleton.class) {
// 4. 第二重检查:防止多个线程同时通过了第一重检查后重复创建
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
静态内部类方法⭐⭐
静态内部类:“优雅托付”。利用 JVM 加载机制,只有第一次调用方法时才会加载内部类并创建实例,代码最简洁,推荐在项目中使用。
public class Singleton {
private Singleton() {}
// 静态内部类只有在被 getInstance() 调用时才会被加载
private static class Holder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE;
}
}
枚举⭐⭐
天生防反射、防序列化,是《Effective Java》作者最推崇的写法。
public enum Singleton {
INSTANCE; // 本身就是单例
public void businessMethod() {
System.out.println("执行业务逻辑");
}
}
// 调用方式:Singleton.INSTANCE.businessMethod();
5. 枚举以外的其他方式如何避免序列化破坏单例模式呢?
你只需要在单例类里加一个魔术方法:readResolve()。
6. 工厂设计模式
工厂模式的核心就是 : 把“对象的创建”和“对象的使用”分离开。也就是达到了解耦的效果。
6.1 简单工厂模式
一个工厂类,根据你传的参数(如字符串 "A"),用 if-else 或 switch 返回对应的实例。它不属于 23 种设计模式,但最常用。缺点是增加新产品必须改工厂代码,不符合“开闭原则”。
public class PayFactory {
public static PayStrategy getPay(String type) {
if ("WX".equals(type)) return new WxPay();
if ("ALI".equals(type)) return new AliPay();
throw new IllegalArgumentException("未知支付类型");
}
}
6.2 工厂方法模式⭐
不直接创建产品,而是定义一个创建对象的接口,让子类决定实例化哪一个类。把实例化推迟到子类中进行。
// 抽象工厂
public interface PayFactory {
PayStrategy createPay();
}
// 具体工厂
public class WxPayFactory implements PayFactory {
public PayStrategy createPay() { return new WxPay(); }
}
优缺点:增加新产品只需加个工厂类,不改老代码;但类会成倍增加,变复杂。
6.3 抽象工厂模式 (Abstract Factory)⭐产品族
介绍:如果说工厂方法是产一个零件,抽象工厂就是产一整套流水线。比如“苹果工厂”既产 iPhone 也产 iPad,“华为工厂”既产华为手机也产华为平板。
public interface AbstractFactory {
PayStrategy createPay(); // 创建支付产品
RefundStrategy createRefund(); // 创建退款产品
}
| 模式 | 核心关注点 | 复杂度 |
|---|---|---|
| 简单工厂 | 一个工厂管所有,简单但死板 | 低 |
| 工厂方法 | 每个产品配一个工厂,灵活好扩展 | 中 |
| 抽象工厂 | 一个工厂管一整套产品线 | 高 |
6.4 总结
1、对于简单工厂和工厂方法来说,两者的使用方式实际上是一样的,如果对于产品的分类和名称是确定的,数量是相对固定的,推荐使用简单工厂模式;
2、抽象工厂用来解决相对复杂的问题,适用于一系列、大批量的对象生产。
6.5 哪些场景用到了工厂设计模式呢? 你能说一些吗?⭐
Spring 框架的 BeanFactory (最经典) ⭐️
这是典型的简单工厂。Spring 容器就是一个巨大的工厂,它负责生产、管理所有的 Bean。你不需要 new,只需要通过 getBean() 就能拿到对象。
JDBC 的 DriverManager
根据你提供的数据库 URL(如 jdbc:mysql://...),工厂会自动判断并加载对应的驱动程序。
多渠道支付系统 ⭐️
系统支持微信、支付宝、银联、Paypal。 前端传一个 payType,后端通过 PayFactory 根据类型生成对应的 PayService。
7. 代理模式
代理模式是 Spring 框架的灵魂。简单来说,就是中介。
7.1 定义
代理模式给某一个对象提供一个代理对象,并由代理对象控制对原对象的引用。
7.2 代理模式的分类
静态代理 : 手动编写代理类,和目标类实现同一个接口。每一个目标类都要配一个代理类,如果接口加了方法,代理和目标类都要改,非常麻烦,实际开发基本不用。
动态代理 (Dynamic Proxy) ⭐⭐⭐
| 特性 | JDK 动态代理 | CGLIB 代理 |
|---|---|---|
| 原理 | 利用反射机制生成一个实现接口的匿名类。 | 利用 ASM 字节码技术生成目标类的子类。 |
| 限制 | 目标类必须实现接口。 | 目标类不能是 final(因为要继承)。 |
| 性能 | 较轻量,创建对象快,执行效率略低。 | 较重,创建对象慢,但执行效率高。 |
| Spring 默认 | 如果目标实现了接口,用 JDK。 | 如果没实现接口,强行用 CGLIB。 |
7.3 代理模式的应用场景(面试必背)
-
Spring AOP:这是最典型的。你在方法上加个
@Transactional,Spring 就给你的类生成一个代理。执行前开启事务,执行后提交事务。 -
Seata 分布式事务:Seata 的 AT 模式会代理你的
DataSource(数据源)。当你的代码执行 SQL 时,代理对象会拦截它,自动生成undo_log回滚日志。 -
MyBatis:你只写了
Mapper接口,没写实现类,为什么能直接调?因为 MyBatis 在运行时用动态代理帮你生成了实现类,并去执行 SQL。 -
远程调用 (RPC):比如 Dubbo。你调用远程服务像调本地方法一样,其实是代理对象在底层帮你跑了网络请求。
7.4 为什么需要代理模式
为了在不修改原始代码的前提下,做功能增强 / 增加额外逻辑,并实现对目标对象的访问控制。
在软件设计中,我们追求“开闭原则”(对扩展开放,对修改关闭)。当你发现原有的类功能不够用,但你又不敢(或者没权限)去改它的源码时,代理模式就是你的“救命稻草”。
总结一下, 其实就是三个核心原因:
1) 功能增强(最核心理由:AOP 的基石)
2) 控制访问(中介保护): 代理类就像一个“挡箭牌”或者“中介”,它决定了调用者能不能访问目标对象,或者以什么方式访问。
3) 延迟加载(虚假繁荣): 如果一个对象非常庞大(比如加载一张超大高清图,或者初始化 一个复杂的数据库连接),初始化它需要消耗很多时间和内存。
8. 策略模式
8.1 定义⭐
策略模式定义了一系列算法,并将每一个算法封装起来,使它们可以相互替换。它让算法的变化独立于使用算法的客户。
通俗理解:你要从 A 地去 B 地,可以选择走路、骑车、开车。这三种方式就是三种“策略”。无论你 选哪种,目的地不变,但你可以根据天气(运行时条件)随时切换。
8.2 核心角色⭐
策略接口 (Strategy):定义所有支持算法的公共接口。 具体策略 (Concrete Strategy):实现具体算法的类(如:微信支付、支付宝支付)。
8.3 应用场景⭐⭐⭐
我的项目: 在我们的音频平台中, 存在 3 种不同的商品, 分别是专辑, 音频, vip, 这三种商品的结算和发货逻辑是不一样的, 如果没有策略模式, 那么相对应的 service 的实现类代码会存在很多 if-else, 但是通过策略模式, 我可以将这个结算与发货提取出一个抽象策略类 | 接口 PaymentAndXXX, 之后, 不同商品只需要实现这个接口, 然后写自己的结算和发货逻辑, 在我们的 service 中, 我们可以结合工厂模式, 根据不同的上下文返回不同的具体策略实现类, 这样就极大的精简了代码, 在之后就算有新的结算和发货逻辑, 我们也不需要修改原有代码, 只需要写一个新的具体策略即可.
8.4 策略模式的优势
引入策略模式不仅仅是为了“消除 if-else”,它在系统架构层面带来了以下三个核心加分点:
1) 彻底实现“开闭原则” (Open-Closed Principle) : 增加新商品(如“有声书”)必须修改 OrderService 的源码,这会带来巨大的回归测试压力,因为你可能在改动时无意间破坏了“VIP”或“专辑”的既有逻辑。
2) 逻辑隔离与单一职责 (Single Responsibility Principle): 传统方式:一个大 Service 承担了所有商品的结算和发货逻辑,职责太重,代码量可能达到几千行。策略模式:每种商品的业务逻辑都在各自的策略类里。
8.5 你是如何实现“零 if-else”动态获取策略的?
通过Spring 的自动注入来实现的:
9. 适配器模式
适配器模式(Adapter Pattern)通过一个中间件(适配器)将一个类的接口转换成客户期望的另一个接口,使原本不能一起工作的类能够协同工作。
经典例子:Java 集合框架中的 Arrays.asList()。
- 你手里的“设备”(Adaptee):一个普通的 Java 数组(比如
String[])。 - 你需要的“插座”(Target接口):Java 的
List接口。 - 问题:数组虽然和 List 很像,但数组没有
List接口定义的contains()、indexOf()等丰富的方法。你不能直接把String[]当作List传给一个只接收List参数的方法。
String[] names = {"Gemini", "ChatGPT", "Claude"}; // 这是一个数组
// 使用适配器转换
List<String> list = Arrays.asList(names);
// 现在你可以像操作 List 一样操作它了
list.contains("Gemini");
你会发现Arrays.asList()内部其实定义了一个适配器类(叫 ArrayList,注意这不是 java.util.ArrayList,而是 Arrays 类的一个内部私有类):
- 这个内部类实现了
List接口(满足了“插座”的要求)。 - 它内部持有那个原始数组的引用(连接了“吹风机”)。
- 当你调用
list.get(0)时,适配器内部其实是在执行return array[0]。