跳到正文

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      三级:生成早期引用的 ObjectFactory

A、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 方法上的 @TransactionalCGLIB 无法拦截,等于没写
传播行为内层是否 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 方法都可能让注解“看起来没生效”。

把一门技术讲透到既懂也会用