主题
Spring IoC 容器原理
一句话:IoC 容器把“对象自己 new 依赖”换成“容器读 BeanDefinition 后统一实例化和注入”,换来可替换、可测试和统一生命周期;代价是启动期多一层解析、运行期多一层代理边界。
适用版本:Spring Framework 6.x / Spring Boot 3.x。示例不依赖 Boot,可直接用 AnnotationConfigApplicationContext 运行。
为什么需要
手写 new 的代码有三个绕不开的问题:
- 依赖写死:
OrderService里new UserRepository(),换实现、加装饰、做测试都要改源码。 - 生命周期分散:数据库连接、线程池、缓存的创建和关闭散落在各处,容易泄漏。
- 横切逻辑重复:事务、日志、权限要改动每个方法;没有统一入口就无法批量织入。
IoC 的做法是把“对象怎么创建、依赖谁”抽成BeanDefinition(Bean 定义元数据),容器负责实例化、注入、初始化、销毁。业务代码只声明“我需要什么”,不负责“它从哪来”。
核心机制
从 BeanDefinition 到可用 Bean
代理是在 postProcessAfterInitialization 由 AnnotationAwareAspectJAutoProxyCreator 生成的,所以初始化方法里调用自身的其他 @Async / @Transactional 方法不会走代理。
关键点:
- BeanFactoryPostProcessor(BFPP) 操作的是“定义”,此时 Bean 还没创建;
${}占位符替换、@ComponentScan解析、自动配置类解析都发生在这个阶段。 - BeanPostProcessor(BPP) 操作的是“实例”,每个 Bean 初始化前后都会经过;
@Autowired、@PostConstruct、AOP/事务代理都在这一层完成。 postProcessAfterInitialization是代理生成点:@Transactional、@Async的代理在这里包住原始对象,所以调用必须经过代理才会生效。- 销毁顺序:
@PreDestroy→DisposableBean.destroy()→destroy-method。原型 Bean 的销毁不由容器管理。
三级缓存:只解决一种循环依赖
singletonObjects 一级:完全初始化好的单例
earlySingletonObjects 二级:提前暴露的早期引用(已实例化、未填充完)
singletonFactories 三级:生成早期引用的 ObjectFactoryA、B 互相依赖时的流程:
三级缓存存的不是对象本身而是 ObjectFactory,这一层间接正是为了让 AOP 代理在「确实被循环依赖引用到」时才提前生成;没有这层,单例池里会出现两个实例。
前提必须说清楚:三级缓存只解决单例 + setter/字段注入的循环依赖。
- 构造器注入无法解决:实例化 A 之前就必须先有 B,而 B 又需要 A,容器没有“未完成的 A 实例”可以提前暴露,启动直接抛
BeanCurrentlyInCreationException。 - 原型(prototype)循环依赖无法解决:没有单例池可放早期引用。
- 为什么要三级而不是两级:三级缓存放的是工厂,容器可以在“第一次被其他 Bean 请求”时决定返回原始对象还是提前生成 AOP 代理;如果只有二级缓存直接放实例,B 可能拿到原始对象,而最终单例池里的是代理,导致同一个 Bean 出现两种形态。
getEarlyBeanReference()就是让AbstractAutoProxyCreator在这里提前生成代理的钩子。
作用域与注入边界
| 作用域 | 行为 | 容器是否管理销毁 |
|---|---|---|
| Singleton(默认) | 容器内一个实例 | 是 |
| Prototype | 每次获取新建 | 否 |
| Request / Session | 每个请求/会话一个 | 由 Web 作用域管理 |
单例注入原型时,注入动作只发生一次,之后拿到的都是同一个原型实例。需要每次取新对象时用 ObjectProvider<T>、@Lookup 或 @Scope(proxyMode = ScopedProxyMode.TARGET_CLASS),不要靠字段注入。
实战场景
一、Boot 升级后启动失败:BeanCurrentlyInCreationException
现象:一个跑了两年的订单服务,从 Boot 2.3 升到 3.2 后在预发环境启动即退出,从进程拉起到退出约 40 秒;本地 IDE 用同一份代码却能起。线上无法发布,回滚分支后恢复。
排查:按“先看阶段,再看环,再看差异”的顺序定位。
| 定位层 | 取证据的动作 | 判定 |
|---|---|---|
| 失败阶段 | 日志搜 APPLICATION FAILED TO START,看栈里有没有 BeanCurrentlyInCreation | 在容器启动阶段,与数据库、配置中心无关 |
| 环上的 Bean | 栈尾 The dependencies of some of the beans in the bean cycle form a closed loop 直接列出环 | 环已给出,不需要猜 |
| 注入方式 | 看环上 Bean 是构造器注入还是字段注入 | 构造器注入必然失败;字段注入在 Boot 2.6+ 默认也被拒绝 |
| 环境差异 | 对比预发与本地是否同一 profile、有没有自定义 BeanFactoryPostProcessor 改过作用域 | 预发多一个 @Configuration 生效,就会多出一条边 |
根因常常不是“容器有 bug”,而是升级把默认值从 allow-circular-references=true 改成 false,历史上靠字段注入掩盖的双向依赖被显式暴露。这台机器上环的长度是 3(Order ↔ Stock ↔ Promo)。
对症做法:按成本从低到高,选第一个能落地的。
java
// 方案 A:拆掉一条边,把双向依赖交给第三个协调者(首选)
class PromoService { private final OrderQuery orderQuery; } // 只读接口,不反向持有 OrderService
// 方案 B:把构造器注入改成字段注入,仅作为升级过渡,必须挂 issue
@Autowired @Lazy private StockService stock; // 注入代理,打破实例化期的环
// 方案 C:延迟取用,依赖关系保留但不触发创建
ObjectProvider<StockService> stockProvider; // 使用时 stockProvider.getObject()不要在 Boot 3.x 全局打开 spring.main.allow-circular-references=true。它让服务起来了,但环还在,下一次改动随时可能把它变成构造器注入而再次失败。
复盘要点:升级前跑一次启动检查(见 优雅上下线与启动检查),把“能否启动”变成 CI 门禁;环的长度和成员应该写进改动说明,而不是只写“已修复启动报错”。
二、事务“看起来没生效”:代理边界造成的数据不一致
现象:退款接口扣款成功、库存未回滚,对账发现单日出险 12 笔,涉及金额需人工冲正;开发本地单步调试时回滚正常。
排查:这类问题的判定证据只有一个,代理有没有被走到。
| 检查项 | 取证据 | 结论 |
|---|---|---|
| 注入的是不是代理 | 启动时打印 refundService.getClass().getName(),含 $$SpringCGLIB$$ 才是代理 | 是代理 → 继续往下查 |
| 调用是否经过代理 | 看 @Transactional 方法是否被同类 this.other() 调用 | 自调用不经过代理,注解失效 |
| 方法可见性与修饰符 | private / final / static 方法上的 @Transactional | CGLIB 无法拦截,等于没写 |
| 传播行为 | 内层是否 REQUIRES_NEW、是否被 try-catch 吞掉异常 | 异常没穿出代理,不回滚 |
| 异常类型 | 抛出的是受检异常还是 RuntimeException | 默认只回滚非受检异常,需 rollbackFor |
这个案例的根因是第三条边:RefundFacade.refund() 里调用同类的 deductStock(),deductStock 上的 @Transactional 从未生效;本地调试正常是因为单步断点让开发者误判了执行路径。
对症做法:
java
// 做法 1:把需要事务的方法移到另一个 Bean(推荐,语义最清楚)
@Service class StockTx { @Transactional(rollbackFor = Exception.class) void deduct(...) {} }
// 做法 2:注入自身,走容器给出的代理
@Lazy @Resource private RefundFacade self; // self.deductStock() 经过代理
// 做法 3:显式暴露代理,只在无法拆类时用
@EnableAspectJAutoProxy(exposeProxy = true)
((RefundFacade) AopContext.currentProxy()).deductStock(order);拆类之外还要补一条:事务边界内的异常不允许在方法内被 catch 后转成返回值,否则代理看不到异常。需要“吞异常但保留回滚”时,用 TransactionTemplate 手写边界,比注解更可读。
复盘要点:代理边界是 IoC 的结构性代价,不是偶发 bug。团队应把“同类自调用”列入 review 检查项;对关键资金链路,用集成测试断言“抛异常后库里的余额没变”,而不是只测正常路径。
三、单例 Bean 里的可变字段:跨请求串数据
现象:多租户报表平台上线后,运营反馈“A 租户看到了 B 租户的数据字典”,一天出现约 3~5 次,只在 QPS 高的时段复现;单用户自测无法复现。
排查:
| 定位层 | 取证据的动作 | 判定 |
|---|---|---|
| 是否共享实例 | @ControllerAdvice 里打印 System.identityHashCode(dictService) | 全程同一个 hash → 单例被共享 |
| 共享的是什么 | 查该类成员变量里有没有 Map、SimpleDateFormat、当前租户 ID | 有 → 状态泄漏点 |
| 泄漏路径 | 用 Arthas vmtool --getInstances 拿实例,看字段当前值 | 字段值与发起请求的租户不符 → 确认串数据 |
| 触发条件 | 关联并发数与日志时间戳 | 高并发下交替覆盖,低并发难复现 |
根因是把“当前请求的租户”存在了单例 Bean 的字段上。它属于 IoC 的作用域约束:Singleton 实例在容器内只有一个,成员变量被所有线程共享。
对症做法:
java
// 1. 请求级数据下沉到方法参数,或放进不可变上下文对象
public Report render(ReportQuery q, TenantContext ctx) // ctx 由入口构造,只读
// 2. 必须用 ThreadLocal 时,成对写清理
private static final ThreadLocal<TenantContext> CTX = new ThreadLocal<>();
try { CTX.set(ctx); ... } finally { CTX.remove(); } // remove 不是 clear,务必用 remove
// 3. 真需要请求级实例时用 Web 作用域代理
@RequestScope @Bean TenantContext tenantContext() { ... } // 注入处拿到的是代理,按请求解析异步线程、线程池、@Async、消息消费者会换线程,ThreadLocal 到那里必然丢失;需要跨线程传递时用显式参数或 InheritableThreadLocal 之外的上下文传播方案,不要靠继承。
复盘要点:单例无状态是可验证的约束。给 Bean 加一条静态检查(成员变量只允许注入的协作对象与 final 常量),比培训“记得清理 ThreadLocal”更可靠;串数据类问题要用两个租户并发的压测用例固化下来。
最小上手
下面这段代码可以直接运行(Spring Framework 6.x,JDK 17),用来验证“字段注入能解、构造器注入不能解”:
java
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.*;
public class CircularDemo {
@Configuration
static class FieldConfig {
@Bean A a() { return new A(); }
@Bean B b() { return new B(); }
static class A { @Autowired B b; }
static class B { @Autowired A a; }
}
@Configuration
static class CtorConfig {
@Bean A a(B b) { return new A(b); }
@Bean B b(A a) { return new B(a); }
static class A { A(B b) {} }
static class B { B(A a) {} }
}
public static void main(String[] args) {
try (var ok = new AnnotationConfigApplicationContext(FieldConfig.class)) {
System.out.println("field injection OK: " + ok.getBean(FieldConfig.A.class).b);
}
try (var fail = new AnnotationConfigApplicationContext(CtorConfig.class)) {
System.out.println(fail.getBean(CtorConfig.A.class));
} catch (Exception e) {
System.out.println("constructor injection failed: " + e.getClass().getSimpleName());
}
}
}预期输出:第一段打印 field injection OK: ...;第二段打印 constructor injection failed: BeanCurrentlyInCreationException(异常可能被包装,类名以实际为准)。
注意:这段用 Framework 6 的默认配置(允许循环引用);Boot 3.x 的 spring.main.allow-circular-references 默认 false,字段注入也会启动失败,需要在测试配置里显式打开才能复现上半段。
生产动作
spring.main.allow-circular-references=false(Boot 2.6+,默认 false):不要在 3.x 里全局打开它来“修复”启动失败。构造器环应拆依赖、改 setter/字段注入、加@Lazy作为接缝,而不是关掉检查。spring.main.lazy-initialization=false(默认 false):打开后循环依赖可能被推迟到第一次调用才暴露,排障更难;它不是循环依赖的解决方案。@Lazy的使用边界:在其中一个注入点加@Lazy会注入代理,打破实例化期的环;但这只是把问题推迟到运行期,设计上仍应消除双向依赖。- 单例无状态:单例 Bean 的成员变量会被所有线程共享;需要请求级数据时用方法参数、
ThreadLocal(必须清理)或 Web 作用域代理。 - 启动失败排查:看到
BeanCurrentlyInCreationException时,异常栈里的Requested bean is currently in creation会给出环上的 Bean 名;不要先怀疑数据库或配置中心。 - Bean 没注入:先看扫描范围(
@SpringBootApplication默认只扫启动类所在包及子包),再看是否被条件注解排除,最后看是否存在同类型多候选(用@Qualifier或@Primary)。
边界
- IoC 不是 Service Locator:业务代码应通过构造器声明依赖,不要在方法里
context.getBean(),否则依赖关系不可见、测试也难替换。 - 字段注入能解循环依赖,但不等于好设计:它让对象在构造完成后仍处于“半初始化”状态;优先构造器注入,把环当成设计信号。
- 代理只对经过容器的调用生效:同类
this.method()自调用不经过代理,@Transactional、@Async、自定义 AOP 都会失效;需要自调用时拆类,或显式暴露代理(@EnableAspectJAutoProxy(exposeProxy = true)+AopContext.currentProxy())。 final类/方法不能被 CGLIB 代理:Spring Boot 2.x+ 默认使用 CGLIB;对final方法加事务/异步注解不会生效。- BFPP/BPP 不是业务扩展点:它们影响容器启动和所有 Bean,写错会拖慢启动或造成难以定位的初始化顺序问题;业务扩展优先用事件、
ApplicationRunner或普通策略接口。
讲清楚
IoC 容器解决的不是“怎么 new 对象”,而是“对象之间的依赖由谁负责”。它先把配置解析成 BeanDefinition,再由 BFPP 修改定义,然后实例化、注入、执行 Aware 和初始化回调,最后在 BPP 的
postProcessAfterInitialization里生成事务/AOP 代理并放入单例池。三级缓存常被误解成“能解决所有循环依赖”。它只能处理单例的 setter/字段注入:构造器注入在实例化阶段就需要对方,容器没有早期实例可以暴露,所以启动会失败。字段注入虽然能跑,但说明两个对象耦合过深,应该拆依赖或引入第三个协调者。
生产上最该记住的是代理边界:只有经过容器代理的调用才会触发事务和切面,同类自调用、
final方法、非 public 方法都可能让注解“看起来没生效”。