并发系列聊的是 Java 语言层的功力,从这篇开始进入框架层。Spring Boot 再花哨,底座其实就两根柱子:IoC 容器管所有对象的生老病死,AOP 代理在不改一行业务代码的前提下给对象”加戏”。Bean 生命周期、循环依赖、事务失效这些高频面试题,全部长在这两根柱子上。这篇把它们一次讲透。
一、IoC:把”造对象”的权力交出去
1.1 没有 IoC 的世界:new 出来的泥潭
先看一段”手工作坊”式代码:
1 | public class OrderService { |
看起来没毛病,麻烦在后头:
RedisClient换个地址、换个实现类,OrderService得跟着改;UserService构造器加个参数,所有 new 过它的地方全部重写;- 想单独测
OrderService?对不起,先连上 Redis、连上数据库,否则连对象都造不出来。
这就是强耦合:每个类都要认识自己的全部下游,还要认识下游的下游。依赖链条越拉越长,改一处,崩一片。
1.2 控制反转,到底反转了什么
答案是:对象创建的控制权。
- 传统写法:我要用
UserService,所以我去 new——主动权在我手里; - IoC 写法:我只声明”我需要一个
UserService“,容器负责创建、装配、递到我手上——主动权交出去了。
打个比方:以前你想吃饭,得自己买菜、做饭、洗碗;现在你只管下单,平台把成品送上门。菜从哪来、谁做的,你不用管,也管不着。
1 |
|
两个常被混用的词在这就算分清了:IoC 是思想(创建权反转),DI(依赖注入)是实现手段(容器把依赖塞进你的字段/构造器)。顺带一提,”别打电话给我们,我们会打给你”(Don’t call us, we’ll call you)说的就是它——框架调你,而不是你调框架。
1.3 容器怎么知道要造谁
两条路,殊途同归——都是往容器里注册 Bean 定义:
1 | // 路线一:组件扫描 + 类上注解(业务代码最常用) |
@SpringBootApplication 默认从主类所在包开始向下扫描,扫到注解就注册,启动时统一实例化。这也是为什么主类要放在根包——放偏了,扫不到,容器里就是空的。
1.4 三种注入方式:为什么官方推荐构造器
1 | // ① 字段注入:最省事,也最被嫌弃 |
构造器注入好在哪:
- 字段能标
final,注入后不可变,线程安全白拿(呼应并发系列讲的”共享可变状态是万恶之源”); - 依赖不齐根本造不出对象,问题在启动期就爆,而不是拖到线上某个深夜 NPE;
- 单元测试直接
new OrderService(mockUserService),不需要反射工具硬塞字段; - 循环依赖当场暴露——两个类构造器互相要对方,启动直接报错,逼你把设计掰正。
1.5 Bean 生命周期:从容器的视角看一个对象
面试高频题。与其背八股,不如跑一遍代码:
1 |
|
启动后输出:
1 | 1. 构造器:对象造出来了,字段还都是 null |
(Spring Boot 3 起注意 @PostConstruct/@PreDestroy 的包从 javax.annotation 搬到了 jakarta.annotation。)
把这套流程展开,就是完整的八步生命周期:
盯住第 ⑥ 步:初始化后的”后置处理”(BeanPostProcessor)会在 Bean 就绪前做一次”最后一道加工”——如果这个 Bean 命中了任何切面,容器放进单例池的就不是原始对象,而是它的代理。AOP 就是从这里偷偷进来的,第三节细说。
1.6 循环依赖与三级缓存
A 要 B,B 要 A,谁也没法先造完自己:
1 |
|
Spring Boot 2.6 起启动直接报错:
1 | The dependencies of some of the beans in the application context form a cycle: |
但setter/字段注入的循环依赖,老版本 Spring 其实能自动解——靠的就是著名的三级缓存:
精髓在第三级的”对象工厂”:它像一个承诺书——“A 现在还是半成品,但如果有人急着要,我能现场提前生成一个带代理的早期引用给你”。只有真的发生循环依赖,这个工厂才会兑现;一切顺利的话,代理照旧等到初始化之后再生成,设计语义不被破坏。只用两级缓存做不到”按需提前”,这就是第三级存在的理由。
两个必须知道的边界:
- 构造器注入的循环依赖无解:实例化阶段就需要对方,而三级缓存在实例化之后才介入。Spring 只能报错(建议的解法永远是重构,比如把互相调用的部分抽成第三个类);
- Spring Boot 2.6+ 默认禁止循环依赖,除非显式配置
spring.main.allow-circular-reference=true——官方态度很明确:能跑不代表设计对。
1.7 顺带说作用域
容器里的 Bean 默认都是单例(singleton):整个容器一个实例,所有人共用。改成 @Scope("prototype") 则每次获取都造新的,另有 request/session 等 Web 作用域。
默认单例 + 有状态字段 = 并发事故预定。单例 Bean 里不要放可变的成员变量,这个坑在第四节还会点名。
二、AOP:把重复的”边角活”抽出去
2.1 横切关注点:每个方法都要裹一层壳?
看一段熟悉得不能再熟悉的代码:
1 | public Order create(OrderRequest req) { |
日志、计时、事务、权限校验——这些横切关注点(cross-cutting concerns)跟业务逻辑半毛钱关系没有,却散落在每个方法里。AOP(面向切面编程)的思路:把”壳”收拢到一处,业务方法只写业务。
2.2 五个概念,用地铁安检对号入座
| 术语 | 地铁安检里对应 | 在代码里是 |
|---|---|---|
| 连接点 JoinPoint | 每一个进站的人 | 程序里所有可以被拦截的点(Spring 里就是方法执行) |
| 切点 Pointcut | “只查背包大的”筛选规则 | 表达式:从连接点里挑出真正要管的那批方法 |
| 通知 Advice | 安检的具体动作 | 在选中位置执行的代码(方法前?方法后?环绕?) |
| 切面 Aspect | 一整套安检规程 | 切点 + 通知的组合,一个类 |
| 织入 Weaving | 把安检门装到入口处 | 把切面应用到目标对象、生成代理的过程 |
2.3 实战:一个接口耗时统计切面
先加依赖(Spring Boot 里 AOP 已经自动配好,只差这一个小吐槽:很多老项目就是漏了这个 starter,切面死活不生效):
1 | <dependency> |
然后写切面:
1 |
|
调用任意 Service 方法,日志里多了一行:
1 | INFO OrderService.create(..) 耗时 42ms |
业务代码一行没动,所有方法自动有了耗时统计。五种通知全家福:
1 | // 方法前 |
同一切面内的执行顺序(Spring 5.2.7+):@Around 前半段 → @Before → 目标方法 → @After → @AfterReturning;抛异常时尾部变成 @After → @AfterThrowing。5.2.7 之前 @After 排在 @AfterReturning 之后,老项目升级时留意。
2.4 代理是怎么生成的:JDK vs CGLIB
Spring AOP 的本质是运行时动态代理:容器发现 OrderService 命中切面,就不把原始对象给你,而是给一个”包装过”的代理对象——方法调用先经过增强逻辑,再转发给原始对象。Java 里造代理有两条路:
验证一下你拿到的到底是什么:
1 |
|
输出:
1 | class com.frank.shop.OrderService$$SpringCGLIB$$0 |
$$SpringCGLIB$$0——你的”OrderService”其实是个运行时生成的 CGLIB 子类。Spring Boot 2.x 起默认 proxy-target-class=true,不管有没有接口一律走 CGLIB;纯 Spring(非 Boot)才默认”有接口用 JDK 代理,没接口用 CGLIB”。
2.5 最大的坑:自调用失效
代理再神,也有个命门。看代码:
1 |
|
为什么?容器里注入给别人的是代理对象,但对象内部 this 永远指向原始对象自己。外部调用先过代理(增强生效);方法内部的 this.xxx() 压根不经过代理——抄了近路,也躲掉了所有增强。
这就是面试常问的 @Transactional 失效场景之一。三种解法:
1 | // 解法一:注入"自己"(拿到的其实是代理) |
三、合体时刻:AOP 长在 IoC 的哪一环
把两根柱子接起来,整个故事就闭环了:
- IoC 负责”造”:扫描、实例化、属性填充、初始化,产出一个个 Bean(1.5 节的八步);
- 第 ⑥ 步”初始化后处理”是 AOP 的加工车间:
BeanPostProcessor检查这个 Bean 是否命中任何切面,命中就生成代理,把代理放回容器; - 之后所有人
@Autowired拿到的、getBean()拿到的,都是代理。
所以那句绕口的总结可以这么记:IoC 造好 Bean,AOP 在出库前把 Bean 换成”加强版”,你拿到的从来不是裸对象。 事务、缓存注解(@Transactional、@Cacheable)、异步注解(@Async)全是同一套机制——没有代理,这些注解全是摆设。
四、常见坑清单
- 循环依赖默认报错(Boot 2.6+)。别急着开
allow-circular-reference=true,先想想设计是不是该改。 - @Transactional 失效全家桶:自调用(见 2.5);方法不是
public;异常被 try-catch 吃掉没抛出;默认只回滚RuntimeException和Error——受检异常要@Transactional(rollbackFor = Exception.class)。 - private / final / static 方法无法被增强:CGLIB 靠生成子类覆写方法实现,覆写不了这三个。切面”不生效”,先看方法签名。
- new 出来的对象没有增强:
new OrderService()是你自己造的,跟容器、代理都没关系。 - 切点切太宽:
execution(* com..*.*(..))这种表达式会让全站方法进拦截链,性能和排障成本都翻倍。切点收紧,多切面用@Order控制顺序(值越小优先级越高)。 - 单例 Bean 放可变状态:默认 singleton 意味着并发共享,成员变量当缓存用就是数据竞争。复习可翻并发系列第 04 篇。
五、总结
一张图记住两根柱子:
- IoC 回答”对象从哪来”——别自己 new,声明依赖,容器装配。代价是你要懂它的生命周期:构造 → 填充 → 初始化 →(必要时)换成代理 → 销毁;
- AOP 回答”对象怎么被增强”——切点挑方法,通知定时机,容器在出库前把原始对象换成代理。代价是你要懂代理的边界:this、final、private 都绕得开它。
下一篇回到并发系列,补上欠下的《线程池》——正好也回答一个问题:Spring 里 @Async 的线程池,到底该怎么配才不翻车。
系列阅读: