设计模式(二):结构型模式与框架源码应用
设计模式(二):结构型模式与框架源码应用
导语:结构型模式是面试最爱挖源码的一章——「JDK 动态代理和 CGLIB 有什么区别」「Spring AOP 用哪个」几乎是必问;而「装饰器 vs 代理」是最容易混淆的一组。本篇拆开适配器的两种实现、代理三件套(静态代理 / JDK Proxy / CGLIB)与 Spring AOP 的代理选择规则、装饰器与 Java IO、桥接与 JDBC,并系统盘点 Spring / JDK / Netty / Dubbo 中的设计模式,共 12 题。
一、适配器模式
1. 适配器模式有哪两种实现?为什么推荐对象适配器?
答:
核心意图:把一个类的接口转换成客户端期望的另一个接口,让原本因接口不兼容而无法协作的类能一起工作(像"电源转换头")。
三大角色:
| 角色 | 职责 |
|---|---|
| Target(目标接口) | 客户端期望的接口 |
| Adaptee(被适配者) | 已存在但接口不兼容的类 |
| Adapter(适配器) | 实现 Target,内部包装/继承 Adaptee,把 Target 的调用转成 Adaptee 的调用 |
两种实现方式(这是本题的考点):
① 类适配器(继承)
② 对象适配器(组合,推荐)
| 维度 | 类适配器 | 对象适配器 |
|---|---|---|
| 实现原理 | 继承(白盒复用) | 组合/关联(黑盒复用) |
| 绑定时机 | 编译期 | 运行时 |
| 能适配的子类 | 只能适配 Adaptee 本身 | Adaptee 及其所有子类 |
| 灵活性 | 低(受单继承限制,只能适配一个) | 高 |
| 能否覆写 Adaptee 行为 | 可以(覆写方法) | 需要继承 Adaptee 才能覆写 |
| 推荐度 | 一般不推荐 | ✅ 推荐 |
面试标准答:「推荐对象适配器——它用组合代替继承,耦合更低、不受 Java 单继承限制、能适配被适配者的所有子类;类适配器唯一的优势是能覆写 Adaptee 的方法,但代价是强耦合与灵活性差。」
2. JDK 和框架中哪些地方用了适配器?
答:
| 实例 | Target(期望接口) | Adaptee(被适配者) | 说明 |
|---|---|---|---|
java.io:InputStreamReader / OutputStreamWriter | Reader / Writer(字符流) | InputStream / OutputStream(字节流) | 最经典的适配器——把"一次一个字节"适配成"一次一个字符",并负责字符编码转换。注意 FileReader 内部就是持有一个 InputStreamReader |
java.util.Arrays.asList() | List | 数组 | 让数组能用 List 的 API(注意返回的是固定长度的视图,表示"适配"而非"拷贝") |
Collections.list(Enumeration) | List | Enumeration(老式迭代) | 把老 API 适配成新 API |
SLF4J 的桥接包(slf4j-log4j12、log4j-slf4j-impl) | SLF4J API | Log4j / Logback / JUL | 应用统一面向 SLF4J 编程,底层无缝切换日志实现——这是适配器在"抽象隔离"上最有价值的应用 |
Spring MVC 的 HandlerAdapter | DispatcherServlet 期望的统一调用 | 各种 Controller(@RequestMapping 方法、HttpRequestHandler、Servlet…) | DispatcherServlet 只依赖 HandlerAdapter,新增 Controller 类型只需加一个 Adapter → 完美体现 OCP |
Spring AOP 的 AdvisorAdapter | 统一的 Advice 调用 | 各种 Advice 类型(MethodBeforeAdvice/AfterReturningAdvice…) | 把不同 Advice 适配成统一的拦截器 |
Dubbo 的 ChannelHandler 适配、Netty 的 ChannelHandlerAdapter | 简化接口 | 完整接口 | 用空实现适配器(Adapter 提供默认空方法)让用户只覆写需要的方法 |
一个"骨架式适配器"技巧(加分点):JDK 与框架里常见
XxxAdapter抽象类(如 Netty 的ChannelInboundHandlerAdapter)——它实现接口并提供全部方法的空实现,子类只需覆写关心的方法。这其实是适配器思想 + 模板方法的组合,日常写得最多。
二、代理模式(重点)
3. 代理模式是什么?有哪几类代理?
答:
核心意图:为其他对象提供一种代理,以控制对该对象的访问(像明星经纪人——粉丝通过经纪人联系明星,经纪人可在前后做额外处理)。
三大角色:Subject(抽象主题,定义共同接口)、RealSubject(真实主题,业务实现)、Proxy(代理,持有真实对象引用,在调用前后加逻辑)。
四类代理(按"为什么需要代理"划分,面试常问):
| 类型 | 目的 | 典型场景 |
|---|---|---|
| 远程代理(Remote Proxy) | 隐藏"对象在另一台机器"的事实 | RPC 框架的客户端 Stub(Dubbo/gRPC)、RMI |
| 虚拟代理(Virtual Proxy) | 延迟创建开销大的对象 | 先显示占位图、后台加载高清图;Hibernate 的懒加载代理 |
| 保护代理(Protection Proxy) | 按权限控制访问 | Spring Security 的方法级权限、@PreAuthorize |
| 智能引用代理(Smart Reference) | 在访问前后做增强(AOP 的本质) | 事务、日志、缓存、监控、重试——Spring AOP 的核心 |
| (补充)写时复制 / 同步代理 | 控制并发访问 | Collections.synchronizedList()、CopyOnWriteArrayList |
一句话:代理的关键词是「控制访问」——无论是控制"能不能访问"(保护)、"什么时候访问"(虚拟)、"在哪访问"(远程),还是"访问时顺便做什么"(智能引用)。
4. 静态代理与动态代理有什么区别?
答:
// ① 静态代理:手写代理类(编译期就存在)
interface UserService { void save(String name); }
class UserServiceImpl implements UserService {
public void save(String name) { System.out.println("保存 " + name); }
}
class UserServiceProxy implements UserService { // ★ 手工编写
private final UserService target;
UserServiceProxy(UserService target) { this.target = target; }
public void save(String name) {
System.out.println("【前置】开启事务");
target.save(name);
System.out.println("【后置】提交事务");
}
}| 维度 | 静态代理 | 动态代理 |
|---|---|---|
| 代理类何时产生 | 编译期(手写) | 运行时(动态生成字节码/反射) |
| 类数量 | 每个真实类都要写一个代理类(类爆炸) | 一套通用逻辑代理任意类 |
| 维护成本 | 接口一变,代理类全要改 | 无需改动 |
| 灵活性 | 逻辑写死 | 可按需织入(拦截任意方法) |
| 代表实现 | 手写 | JDK 动态代理、CGLIB、Javassist、ByteBuddy、ASM |
| 适用 | 代理目标极少、逻辑简单 | 框架级 AOP(几乎全部场景) |
结论:静态代理的价值在"理解原理";生产中 99% 的场景用动态代理(否则每加一个 Service 就要手写一个代理类)。
5. JDK 动态代理与 CGLIB 的原理和区别是什么?
答: 这是结构型模式里的头号考点,必须讲清"底层怎么做到"。
① JDK 动态代理 —— 基于「接口 + 反射」
✅ 关键:代理类实现了与目标相同的接口 → 调用方通过接口拿到代理,完全无感
✗ 限制:目标必须实现接口(没有接口就无法生成)
② CGLIB —— 基于「继承 + 字节码生成」
✅ 关键:代理类是目标的子类 → 在子类里覆写方法即可织入逻辑
✗ 限制:无法代理final类、final/private/static方法(无法覆写)
| 维度 | JDK 动态代理 | CGLIB |
|---|---|---|
| 底层机制 | 接口实现 + InvocationHandler + 反射 | 继承生成子类 + MethodInterceptor + ASM 字节码 |
| 前提条件 | 目标必须实现接口 | 目标不能是 final 类,方法不能是 final/private/static |
| 代理类 | com.sun.proxy.$Proxy0(实现目标接口) | 目标类名$$EnhancerByCGLIB$$xxx(继承目标类) |
| 生成的类结构 | 与目标是"兄弟"(同实现一个接口) | 与目标是"父子" |
| 性能 | JDK 8 之后两者相近;老版本 CGLIB 更快(JDK Proxy 反射调用开销大) | 生成字节码稍慢,但调用通常略快(无接口间接层) |
| 是否需额外依赖 | 不需要(JDK 自带) | 早期需引入 CGLIB;Spring 5+ 已把 CGLIB repackage 到 spring-core,无需额外依赖 |
| 方法能否被增强 | 接口里声明的方法都能 | final/private/static 方法不能,final 类完全不能 |
| 构造函数 | 代理类实现接口,不调用目标构造器 | 代理类是子类,会调用父类构造器(所以目标必须有可访问的无参构造器,或配合 Objenesis 绕过) |
两个高频追问:
- 「为什么 CGLIB 能代理没有接口的类?」 → 因为它生成的是目标类的子类,并在子类里覆写方法(Java 的多态机制让"通过父类引用调用子类方法"生效)。反过来,
final类无法被继承、final方法无法被覆写,所以都不能被 CGLIB 代理; - 「JDK 代理为什么要求接口?」 → 因为代理类
$Proxy0需要与目标实现同一个接口,调用方才能"通过接口引用调用代理"。没有接口就没有"共同类型",也就无法在编译期完成类型匹配。
6. Spring AOP 用的是哪种代理?怎么选?
答: 这是最容易被答错的一题("有接口用 JDK、没接口用 CGLIB"只说对了一半)。
准确规则分两层:
为什么改成默认 CGLIB?(这是个很好的加分点)
| 原因 | 说明 |
|---|---|
| ① 避免注入困惑 | 否则会出现「注入接口类型才成功、注入实现类型就报错」 |
| ② 避免类型判断陷阱 | 否则同一个类有时是代理、有时是原对象 |
| ③ 无接口的场景越来越多 | 现代应用倾向不写接口 |
面试标准答(把两层都说到):
「要看是哪一层:Spring Framework 本身的规则是——目标有接口就用 JDK 动态代理,没接口用 CGLIB,也可以用
proxyTargetClass=true强制 CGLIB。但从 Spring Boot 2.0 开始,spring.aop.proxy-target-class默认是true,所以默认强制走 CGLIB。不过如果强行指定proxy-target-class=false,就回到"有接口用 JDK"的行为。」
由此引出的三个实战坑(面试加分):
| 坑 | 原因 | 解法 |
|---|---|---|
注入实现类时报 BeanNotOfRequiredTypeException / ClassCastException | 用 JDK 动态代理时,代理类只实现了接口,不是目标类的子类 → 用实现类类型注入会失败 | 改用接口注入;或 proxyTargetClass=true(Boot 2.x 默认已是) |
@Transactional / @Cacheable 在同类内部调用不生效 | 内部调用 this.method() 绕过了代理对象(走的是原对象) | ① 把方法拆到另一个 Bean;② 注入自己(@Lazy/AopContext.currentProxy());③ 理解"AOP 基于代理,代理只能拦外部调用" |
final 方法上的注解不生效 | CGLIB 无法覆写 final 方法 | 去掉 final;或改成接口 + JDK 代理 |
| 切面没生效(类未被代理) | 只有被 Spring 管理的 Bean 才会被代理;自建对象(new 出来的)不会被织入 | 交给容器管理 |
7. 装饰器模式是什么?为什么 Java IO 是经典案例?
答:
核心意图:在不改变原有对象结构与代码的前提下,动态地给对象添加额外功能——像"穿衣服",一层层套上不同装饰,事物本质不变。
四个角色:Component(抽象构件)、ConcreteComponent(被装饰对象)、Decorator(抽象装饰器,实现 Component 并持有 Component 引用)、ConcreteDecorator(具体装饰器,加新功能)。
// 每个装饰器都实现 InputStream → 可无限嵌套,且整体仍是 InputStream 类型
new DataInputStream(new BufferedInputStream(new FileInputStream(file)))// 装饰器骨架(JDK 的 FilterInputStream 就是这个结构)
public abstract class InputStream implements Closeable { ... } // Component
public class FileInputStream extends InputStream { ... } // ConcreteComponent
public class FilterInputStream extends InputStream { // Decorator
protected volatile InputStream in; // ★ 持有被装饰对象
protected FilterInputStream(InputStream in) { this.in = in; }
public int read() throws IOException { return in.read(); } // 默认转发
}
public class BufferedInputStream extends FilterInputStream { // ConcreteDecorator
public int read() throws IOException { /* 加缓冲逻辑 */ }
}装饰器的价值总结(面试表述):
| 价值 | 说明 |
|---|---|
| 运行时动态组合 | 而不是编译期固定的继承关系 |
| 避免子类爆炸 | n 个功能的 2ⁿ 种组合 → 只需 n 个装饰器类 |
| 符合 OCP | 新增功能 = 新增装饰器,不改原有类 |
| 不受 final 限制 | 目标类是 final 没法继承时,装饰器仍能工作(因为用的是组合) |
装饰器的代价(要主动说):
- 会产生很多小对象(包装层数多时,调试栈很深、排查麻烦);
- 被装饰对象的"身份"会变:经过装饰后,
bufferedInput instanceof FileInputStream为false—— 依赖具体类型的代码会失效(这点和 JDK 动态代理"注入实现类失败"是同一类陷阱)。
8. 装饰器与代理有什么区别?(最易混淆的一组)
答: 两者代码结构几乎一模一样(都实现同一接口、都持有被包装对象、都可以前后加逻辑),区别完全在"意图":
| 维度 | 装饰器 | 代理 |
|---|---|---|
| 核心意图 | 增强功能(加能力) | 控制访问(管访问) |
| 谁决定组合 | 客户端主动(new Buffered(new File(...))) | 通常由框架创建(调用方无感) |
| 数量 | 通常可叠加多个 | 通常一个 |
| 是否需要目标抽象 | 是(Component 接口) | 是(Subject 接口)或通过继承(CGLIB) |
| 与目标的关系 | 平级(同实现一个接口,包装而非替换) | 代表目标(调用方以为在调用目标) |
| 典型场景 | Java IO、HttpServletRequestWrapper、Servlet Filter 包装 | Spring AOP、RPC Stub、Hibernate 懒加载、Collections.unmodifiableList |
| 运行时类型 | 装饰器链上一个 instanceof 都可能是"某一层装饰器" | 代理与目标"不是同一个类型"(JDK 代理只实现接口) |
一句话区分:「装饰器是"我给你加个能力",代理是"我替他挡一层"」。判断技巧:看调用方是否知道自己拿到的是"包装后的东西"——知道且主动组装 → 装饰器;不知道、由框架注入 → 代理。
三、其余结构型模式
9. 桥接模式是什么?JDBC 为什么是典型?
答:
核心意图:把"抽象部分"与"实现部分"分离,使两者可以独立变化——用组合关系代替继承关系,从而把"两个维度的变化"从乘法变成加法。
问题:两个维度都会变化时,用继承会「类爆炸」
JDBC 为什么是典型(面试高频):
换数据库只换驱动 jar,业务代码一行不改 —— 抽象与实现独立变化,这才是"桥"的价值。
| 维度 | 桥接模式带来的价值 |
|---|---|
| 抽象与实现解除绑定 | 应用依赖 java.sql 抽象;具体实现由驱动提供,运行时通过 SPI 加载 |
| 两个维度独立扩展 | 新增数据库厂商 = 新增一个驱动(不影响 JDBC API);新增 JDBC API = 不影响已有驱动(兼容演变) |
| 符合 OCP 与 DIP | 名副其实的"面向接口编程" |
桥接 vs 适配器(容易混):
| 桥接 | 适配器 | |
|---|---|---|
| 目的 | 让抽象与实现能"独立变化"(设计阶段) | 让"已经存在"的不兼容接口能协作(补救阶段) |
| 时机 | 事前设计 | 事后适配 |
| 关键词 | 分离两个变化维度 | 转换接口 |
10. 外观、享元、组合模式速览
答: 这三个是"知道即可、但问到要能答"的补充项。
| 模式 | 核心意图 | 关键点 | 典型实例 |
|---|---|---|---|
| 外观(Facade) | 为复杂子系统提供一个统一的高层接口,让客户端只与外观交互 | 不禁止直接访问子系统(只是"提供便利入口");常配合分层架构中的门面服务 | SLF4J 门面、Spring 的 JdbcTemplate、Controller → FacadeService、电脑"一键启动"封装 CPU/内存/硬盘 |
| 享元(Flyweight) | 共享细粒度对象,减少内存占用 | 关键是把对象拆成"内部状态(可共享)"与"外部状态(不可共享、由外部传入)" | Integer.valueOf(-128~127) 缓存、String 常量池、线程池/连接池(共享的是"对象"而不是创建)、Netty 的 ByteBuf 内存池、围棋棋子的颜色(内部)+ 位置(外部) |
| 组合(Composite) | 把对象组织成树形结构,让客户端一致地处理"单个对象"和"组合对象" | 关键是一个接口同时代表叶子与容器 | 文件系统的 File/Directory、HashMap 的元素可以是 Map、组织架构树、菜单树、Node 既能是叶子也能有子节点 |
享元模式的一个高频追问:「
Integer a = 127; Integer b = 127; a == b为什么是 true,而 128 就是 false?」→ 因为Integer.valueOf()内部有IntegerCache(默认缓存 -128~127),这就是享元模式——共享"常用小整数对象",避免频繁创建。用new Integer(127)则强制新建(JDK 9 起该构造器已废弃)。
四、框架源码中的设计模式(综合)
11. Spring 和 JDK 分别用了哪些设计模式?
答: 这两个是最高频的"框架举例"题,建议按"模式 → 具体类"成对回答。
Spring:
| 模式 | 具体体现 |
|---|---|
| 工厂 + 单例 | BeanFactory / ApplicationContext 负责创建 Bean;Bean 默认作用域是 singleton(容器内唯一) |
| 抽象工厂 | ApplicationContext 可视为"产品族"工厂(Bean + 资源 + 消息 + 事件) |
| 代理 | AOP:JdkDynamicAopProxy / CglibAopProxy;@Transactional、@Cacheable、@Async 都基于代理 |
| 模板方法 + 回调 | JdbcTemplate、RedisTemplate、RestTemplate —— 用 Callback 代替"必须继承子类",既复用又灵活 |
| 观察者 / 事件驱动 | ApplicationEvent(事件)+ ApplicationListener(监听器)+ ApplicationEventPublisher |
| 适配器 | Spring MVC 的 HandlerAdapter、AOP 的 AdvisorAdapter |
| 装饰器 | HttpServletRequestWrapper / ServerHttpRequestDecorator;多数据源动态切换的 AbstractRoutingDataSource |
| 建造者 | BeanDefinitionBuilder、UriComponentsBuilder |
| 责任链 | HandlerExecutionChain(HandlerInterceptor 链)、Spring Security 的 FilterChainProxy(一长串 Filter) |
| 策略 | Resource 的不同实现(ClassPathResource/FileSystemResource)、InstantiationStrategy |
JDK:
| 模式 | 具体体现 |
|---|---|
| 装饰器 | java.io 的 FilterInputStream/FilterOutputStream 家族(BufferedInputStream、DataInputStream);Collections.unmodifiableList()、Collections.synchronizedList() |
| 适配器 | InputStreamReader / OutputStreamWriter(字节流↔字符流)、Arrays.asList()、Collections.list() |
| 工厂方法 | Collection.iterator()(ArrayList/LinkedList 各自返回迭代器)、Calendar.getInstance()、NumberFormat.getInstance() |
| 静态工厂 | Files.newInputStream()、Paths.get()、LocalDate.of()、List.of() |
| 建造者 | StringBuilder / StringBuffer、Stream.Builder、Calendar.Builder |
| 策略 | Comparator(Collections.sort() / Arrays.sort() 接收不同比较器) |
| 桥接 | java.sql 标准 API 与厂商驱动(DriverManager 桥接) |
| 命令 | Runnable(把任务封装成对象)+ Thread/ExecutorService 作为执行者 |
| 迭代器 | 所有集合的 iterator()(hasNext()/next()),遍历与实现解耦 |
| 观察者 | java.util.Observer(已废弃)、NIO 的 WatchService/Watchable(文件目录监听) |
| 享元 | Integer.valueOf() 的 IntegerCache、String 常量池、Boolean.valueOf() |
| 单例 | Runtime.getRuntime()、Collections.EMPTY_LIST、System 类(静态工具) |
| 空对象(Null Object) | Collections.emptyList() 返回一个"什么都不做的 List",避免调用方判空 |
面试技巧:不要报字典。挑 2~3 个说透即可(例如「Spring 最核心的是工厂 + 单例(IoC 容器)、代理(AOP)和模板方法 + 回调(各种 Template)这三组」),然后主动跟一句源码类名,比列 10 个模式名有效得多。
12. Netty 和 Dubbo 用了哪些设计模式?
答: 这两个是"能在面试里讲源码"的加分项,尤其 Netty 的责任链(Pipeline) 几乎必问。
Netty:
| 模式 | 具体体现 |
|---|---|
| 责任链(最核心) | ChannelPipeline + ChannelHandler:入站事件(channelRead)从 head 到 tail 依次传递,出站事件(write)反向传递;ChannelHandlerContext 的 fireXXX() 控制传递方向 |
| 观察者 / 回调 | ChannelFuture + addListener(GenericFutureListener):异步 IO 完成时回调 operationComplete() |
| 建造者 | ServerBootstrap / Bootstrap:group().channel().childHandler().option() 链式配置后 bind()/connect() |
| 工厂方法 | ReflectiveChannelFactory:按传入的 Class 反射创建不同类型 Channel(NioServerSocketChannel/NioSocketChannel) |
| 单例 | DefaultSelectStrategy / MqttEncoder 等的 INSTANCE(static final,类加载即创建,多线程共享) |
| 策略 | EventExecutorChooser:根据线程池大小是否为 2 的幂,选择 PowerOfTwoEventExecutorChooser(用 & 代替 %取模)或 GenericEventExecutorChooser —— 一个"性能优化型策略"的好例子 |
| 装饰器 | WrappedByteBuf:UnreleasableByteBuf(禁释放)、SimpleLeakAwareByteBuf(泄漏检测)、SlicedByteBuf |
| 适配器 | ChannelInboundHandlerAdapter / ChannelOutboundHandlerAdapter(空实现适配器,子类只覆写关心的回调) |
| 享元 | PooledByteBufAllocator 内存池(复用 ByteBuf,减少 GC) |
Dubbo:
| 模式 | 具体体现 |
|---|---|
| 单例 + 工厂 | ExtensionLoader:用 ConcurrentMap 缓存各扩展点的加载器(同一扩展点只一个);getExtension(name) 按名字创建扩展(Dubbo SPI 的核心) |
| 代理 | 消费端拿到的是本地代理对象(JavassistProxyFactory/JdkProxyFactory 创建),方法调用被拦截并封装成 RPC 请求 → 远程调用对使用者透明 |
| 工厂方法 | ProxyFactory.getProxy():由 JavassistProxyFactory / JdkProxyFactory 子类决定具体代理创建逻辑 |
| 模板方法 | Protocol / Registry 的抽象基类:定义 export() / refer() 的流程骨架,把易变的 doExport() / doRefer() 交子类实现 |
| 装饰器 | ProtocolFilterWrapper(把 Filter 链包成 Invoker)、QosProtocolWrapper、MockClusterInvoker(失败走 mock)—— Dubbo 大量用"Wrapper"做功能叠加 |
| 责任链 | ProtocolFilterWrapper.buildInvokerChain():把多个 Filter(监控、日志、限流、认证、异常)串成 Invoker 链 |
| 策略 | LoadBalance:RandomLoadBalance / RoundRobinLoadBalance / LeastActiveLoadBalance / ConsistentHashLoadBalance,按配置动态选择 |
| 适配器 | 各 Transporter / Codec 适配不同协议(dubbo、tri、rest)与序列化方式 |
收尾一句(面试很好用):「这些框架的设计模式不是"为了用模式而用",而是各自在解决自己的核心问题——Netty 用责任链解决"网络事件的分层处理",用建造者解决"复杂的启动配置";Dubbo 用代理解决"远程调用透明化",用责任链解决"过滤器链的横切逻辑",用策略解决"负载均衡可插拔"。看源码时顺着"它在解决什么问题"去理解模式,比死记类名有用。」
下一篇:《设计模式(三)》进入行为型模式与综合设计——观察者/策略/状态/责任链/模板方法的原理与项目落地、策略与状态的区别、状态机框架选型、其余行为型速览,以及三组高频综合题(支付系统的模式组合、订单系统里策略/状态/责任链如何配合、如何判断该用哪个模式)与"项目里用了什么设计模式"的高分答法。
