news 2026/10/10 19:00:07

深入Spring底层:自动装配、Bean生命周期、循环依赖与事务代理全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入Spring底层:自动装配、Bean生命周期、循环依赖与事务代理全解析

写了好几年 Spring,日常 CRUD 里用 IOC 和 AOP 也算得心应手,但有一次面试被问到“@EnableAutoConfiguration 加载的配置类到底是由谁扫描出来的”,我当时居然卡住了。后来啃了一段时间源码,又把 Bean 生命周期、循环依赖、事务代理这些点挨个捋了一遍,才发现很多平时“能跑就行”的功能,底层藏着一整套精妙设计。这篇文章就当作是上一篇《Spring 核心知识点全解析》的续篇,专门聊聊那些面试高频、排错头疼、但文档里往往一句带过的底层机制。

1. 自动装配的“自动”到底发生在哪一步:注解驱动装配链路拆解

1.1 @Import 是真正的幕后功臣

Spring Boot 的@SpringBootApplication本质上是组合注解,里面包含了@EnableAutoConfiguration,而再往下走,真正干活的是@Import。@Import能往里塞三类东西:普通配置类、ImportSelector实现类、ImportBeanDefinitionRegistrar实现类。普通配置类好理解,就是直接把@Configuration的类注册进容器;而ImportSelector更像一个“工厂”,它会在容器启动阶段执行selectImports方法,返回一批类名,Spring 再把这批类当成配置类处理。

自动配置的秘密就在AutoConfigurationImportSelector这个实现类上。它是ImportSelector的典型代表,负责读取META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件(新版本是这种独立文件,老版本用spring.factories),把里面所有自动配置类全部捞出来,再经过@Conditional系列条件注解过滤一遍,最后真正注册到容器的只是符合条件的那一小撮。

public class AutoConfigurationImportSelector implements DeferredImportSelector { // DeferredImportSelector 是 ImportSelector 的扩展 // 核心区别:等待所有普通配置类处理完后再执行,避免自动配置跟用户配置打架 }

1.2 自动配置类的加载顺序为什么不能乱

自动配置类命名都是XxxAutoConfiguration,它们之间也有依赖顺序,比如RedisAutoConfiguration可能依赖JacksonAutoConfiguration注册的某些 Bean。Spring 提供了@AutoConfigureBefore、@AutoConfigureAfter来调整相对顺序,还支持用@Order控制同一批配置类的优先级。真到排查问题时,你会发现理解了顺序,才能在“Bean 定义覆盖”这类疑难杂症里快速定位主因。

1.3 手写一个 @EnableXxx,彻底搞懂装配原理

与其死记结论,不如自己实现一次。我们模拟一个场景:做一个加密组件,通过自定义注解一键开启。

@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Import(EncryptFeatureImportSelector.class) public @interface EnableEncryptFeature { }
public class EncryptFeatureImportSelector implements ImportSelector { @Override public String[] selectImports(AnnotationMetadata importingClassMetadata) { return new String[] { "com.example.demo.config.EncryptAutoConfiguration" }; } }

然后在EncryptAutoConfiguration里通过@Bean注册加密器、密钥管理器等组件。启动类上加上@EnableEncryptFeature之后,整个加密模块的 Bean 就被自动装配进去了。这套模式和 Boot 的自动配置思路完全一致——“约定大于配置”,不需要手动@ComponentScan去扫某个包,注解本身就是装配开关。

提示:自己写 starter 时,如果条件注解没写对,很容易出现“明明打了注解但 Bean 没生效”的诡异问题。排查顺序是先看自动配置类是否被加载,再看@ConditionalOnProperty、@ConditionalOnClass是否满足。

2. Bean 从出生到销毁的全部动作:生命周期回调与常见事故现场

2.1 一个 Bean 从出生到销毁的时间线

老早之前我以为 Bean 的创建就是“new 一下放进容器”,后来看AbstractAutowireCapableBeanFactory.doCreateBean才明白,一个简单的@Component背后至少经历六个阶段:

  • BeanDefinition 解析与合并:扫描类后生成BeanDefinition,多个同名定义合并。
  • 实例化:调用构造器创建实例,此时对象已经存在,但属性还是空。
  • 属性填充:处理@Autowired、@Value、@Resource。
  • Aware 回调:依次执行BeanNameAware、BeanClassLoaderAware、BeanFactoryAware。
  • BeanPostProcessor 前置处理:postProcessBeforeInitialization。
  • 初始化回调:@PostConstruct方法、InitializingBean.afterPropertiesSet、自定义init-method。
  • BeanPostProcessor 后置处理:postProcessAfterInitialization,AOP 代理往往在这一步生成。

销毁阶段则反过来,先执行@PreDestroy,再调DisposableBean.destroy,最后走自定义destroy-method。

2.2 回调顺序为什么重要:一次典型的初始化事故

有次排查线上问题,某个“初始化要连接外部系统”的 Bean 总是在构造器里拿到NullPointerException。原因就是构造器执行时属性还没有注入,我当时接手代码直接把远端的初始化逻辑扔进了构造器。后来把初始化逻辑移到@PostConstruct方法里,一切正常。

这里有一张常用的对照表:

操作时机执行顺序
构造器实例化阶段最先执行
属性注入属性填充阶段构造器之后
@PostConstruct初始化回调属性注入之后
InitializingBean初始化回调@PostConstruct之后
自定义 init-method初始化回调最后
@PreDestroy销毁阶段最先执行销毁逻辑

排错时可以按这张表确认“当前这个 Bean 到底执行到哪一步了”。如果你在一个 Bean 的构造器里调用另一个 Bean 的方法,大概率拿到的是 null 或者代理对象,因为对方的实例化顺序可能还没轮到它的属性填充。

3. 三级缓存不是缓存:循环依赖处理机制的前世今生

3.1 三级缓存里到底缓存的什么

循环依赖是面试常客:A 依赖 B,B 又依赖 A,如果按顺序创建,必然卡死。Spring 处理这个问题的方案是三级缓存:

  • 一级缓存singletonObjects:存放完整的、已完成初始化的单例 Bean。
  • 二级缓存earlySingletonObjects:存放早期暴露的 Bean 引用,属性还没填充完,但对象已经存在。
  • 三级缓存singletonFactories:存放ObjectFactory,负责生成早期引用的工厂。

看源码时,getSingleton的查询顺序是:一级缓存 → 二级缓存 → 三级缓存。三级缓存命中后,会调用其getObject()得到一个对象放入二级缓存,再把三级缓存里对应的工厂移除。这样做的意义在于:对象实例化后,在属性填充之前就把“半成品”暴露出去,让依赖它的 Bean 先拿到引用,等最终全部初始化完再替换为成品。

注意:三级缓存里存的是工厂,不是对象本身。这层间接设计是为了在getObject()里执行 AOP 代理等逻辑,确保提前暴露出去的早期引用也能带上代理能力。

3.2 为什么构造器注入会触发 BeanCurrentlyInCreationException

Setter 注入能解决循环依赖,是因为流程是“先实例化 → 再注入”;而构造器注入在实例化阶段就必须传入依赖对象,此时目标 Bean 还没有被放到三级缓存里,根本没法提前暴露引用。所以 Spring 只能抛出BeanCurrentlyInCreationException。

有个容易误解的点:很多人觉得把@Autowired写在字段上就一定不会循环依赖问题,但实际上字段注入只是走上了“先实例化后注入”的路子,绕过了构造器的死锁;它只是让循环依赖“能转起来”,不代表设计上没问题。字段注入写在字段上,在测试和 AOP 处理上都有麻烦,这也是官方推荐构造器注入的原因之一。

3.3 循环依赖的排查与改造思路

真遇到循环依赖而不是依赖配置错误时,我的排查顺序是:

  • 看堆栈里的BeanCurrentlyInCreationException,把创建链路中的 Bean 列出来。
  • 用@Lazy打断循环。@Lazy会生成一个代理对象注入进去,真正调用时才触发目标 Bean 的创建。
  • 重构拆分逻辑:A 和 B 之间这种强耦合,多半是职责划分有问题,把一个方向的依赖调整成事件通知或中间层缓存,能一劳永逸。

实际开发里,我更倾向“从根上避免”:核心业务 Bean 一律构造器注入,字段注入只用于测试不方便的场景。这样在启动时就能暴露循环依赖,而不是让系统带伤运行。

4. @Transactional 为什么偶尔失效:代理机制与自调用链路的排查笔记

4.1 事务不生效的六大高频场景

Spring 事务是基于 AOP 代理实现的,代理没生效,事务必然失效。我在项目里总结过六个高频原因:

  • 自调用:同一个类里this.method()调另一个@Transactional方法,调用发生在原始对象上,绕过了代理。
  • 方法不是public:代理无法增强非 public 方法,Spring 对此也不抛异常,只是静默失效。
  • 类没有被 Spring 管理:手动new出来的类,代理根本不存在。
  • 异常被 catch 住没抛出:事务管理器默认只能感知 RuntimeException,业务代码把异常吞了,提交逻辑就被跳过。
  • 事务传播行为选错:本应开新事务的被外层事务吞并了。
  • 数据库引擎不支持事务:MySQL 的 MyISAM 引擎本身不支持事务,换成 InnoDB 才能生效。

4.2 传播行为选错也是一种“失效”

REQUIRED是最常用的默认传播行为,如果外层已经开启事务,内层方法直接加入外层;REQUIRES_NEW则会挂起当前事务,开一个新事务;NESTED则是嵌套事务,内层回滚不会拖垮外层已经提交的部分。很多“事务失效”案例,其实是内外层用了错误的传播组合。

看一段经典失效代码:

@Service public class OrderService { @Transactional public void createOrder(OrderDTO dto) { saveOrder(dto); this.updateStock(dto); // 自调用,绕过代理 } @Transactional(propagation = Propagation.REQUIRES_NEW) public void updateStock(OrderDTO dto) { // 你以为开了新事务,实际上在原始对象上调用,什么都没发生 } }

这段代码的问题在于this.updateStock是在原始对象上执行,而不是代理对象。想让REQUIRES_NEW生效,要么把updateStock拆到独立的 Service,要么通过AopContext.currentProxy()拿到代理对象再调用,前提是开启exposeProxy = true。

@EnableAspectJAutoProxy(exposeProxy = true) @Transactional public void createOrder(OrderDTO dto) { saveOrder(dto); ((OrderService) AopContext.currentProxy()).updateStock(dto); }

4.3 我自己的一套事务排查清单

遇到事务不生效,我按下面这份清单过一遍,基本十分钟内能定位:

  • 确认该类是否被 Spring 管理:看能否注入、能否在上下文里找到。
  • 确认方法是否public,且没有被static、final修饰。
  • 确认调用点是否经过代理:同类调用是否绕过代理。
  • 确认异常是否已抛出:catch 块有没有吞异常。
  • 确认事务管理器是否生效:数据源是否配了事务管理器、连接是否真的走事务。
  • 确认数据库引擎:MySQL 查ENGINE。

5. JDK 动态代理与 CGLIB:你写的每个配置类都可能被“换壳”

5.1 JDK 动态代理与 CGLIB 的本质区别

Spring 生成代理对象有两条路:JDK 动态代理和 CGLIB。JDK 动态代理要求目标类有接口,生成的代理类实现这些接口,只代理接口里声明的方法;CGLIB 则是直接生成目标类的子类,可以代理没有接口的类,但不能代理final方法。

从 Spring Boot 2.x 开始,默认策略改为 CGLIB,也就是spring.aop.proxy-target-class=true。这对大多数开发者是透明的,但有一个隐藏行为差异:如果依赖注入时用了接口类型,那么拿到的是接口代理;如果注入的是具体类,Spring Boot 通常也能生成 CGLIB 代理。真正踩坑的场景是,你的 Bean 实现的接口里没有某个方法,但从代理上调用该具体类方法时,JDK 动态代理会直接失败。

5.2 代理模式下最容易遇到的两个怪象

第一个怪象:强转类型异常。注入声明为接口类型,代码里却强转为具体实现类,代理对象本质上不是实现类的直接实例,转换失败很正常。解决办法是注入时用具体类类型,或者保持接口统一。

第二个怪象:同一个方法被调用多次却没有走增强逻辑。原因依然是自调用。代理对象和原始对象是两回事,只有外部注入的代理对象才带增强。很多“事务只开在外面,方法内部怎么调都不增强”的现象都是这个原因。

经验:设计 Service 时尽量面向接口,但注入时让 Spring 自动选择实现类型;如果必须用AopContext.currentProxy(),记得显式开启exposeProxy,否则运行时会拿到 null。

6. 事件机制的进阶玩法:从 @EventListener 到事务监听器

6.1 从 ApplicationListener 到 @EventListener

Spring 的事件机制很适合解耦业务链路,比如下单之后发通知、记录审计日志,都可以通过发布事件让监听器异步处理。

老写法是实现ApplicationListener接口,新写法直接注解@EventListener,Spring 会根据方法参数推断监听的事件类型:

@EventListener public void onOrderCreated(OrderCreatedEvent event) { // 处理订单创建成功后的业务 }

发布事件则注入ApplicationEventPublisher,调用publishEvent。默认情况下,监听器是同步执行的,发布者线程会阻塞到监听器执行完;如果需要异步,方法上再加@Async,前提是线程池配置合理,别把数据库连接池或 HTTP 线程池拖垮。

6.2 @TransactionalEventListener:事务边界上的监听器

实际项目里,最容易出问题的是“事务还没提交,监听器却先执行了”。比如下单后发消息,如果监听器读到库里还没有这条订单,就会发不出去或发错。Spring 为此提供了@TransactionalEventListener,可以精确控制监听器在事务提交、回滚等节点执行:

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) public void handleAfterCommit(OrderCreatedEvent event) { // 事务提交后再发消息,保证数据库数据可见 }

fallbackExecution = true的意思是:如果当前没有事务,也照常执行监听器;如果为 false,则无事务时监听器不会触发。这个参数在单元测试和某些批处理场景里很有用,我建议根据业务语义仔细选,别直接抄默认配置。

7. 定位问题的三板斧:启动日志、常见异常与务实编码习惯

7.1 异常堆栈第一眼看哪里

Spring 的启动异常往往动辄几十行,第一次看的人容易懵。我的习惯是:先看顶部的异常类型,再看堆栈里第一个业务类,最后看 Caused by 的根因。比如NoSuchBeanDefinitionException通常最后会告诉你“expected single matching bean but found 2”,这才是真正原因。UnsatisfiedDependencyException则是依赖注入失败,会在 Caused by 里露出真正缺的那个 Bean。

7.2 用日志和断点定位 Bean 装配过程

排查 Bean 装配问题时,可以在启动类上加debug=true,Spring Boot 会输出自动配置报告,哪些自动配置生效、哪些被排除一目了然。如果还看不出来,就在ConfigurableApplicationContext的getBean处打断点,顺调用栈走一遍doCreateBean,每到一个环节看对象状态,基本能定位是哪一步注入失败。

7.3 三个值得坚持的编码习惯

结合这几年踩坑的经验,我在团队里一直推行这三条习惯:

  • 配置类和业务类的边界要清楚。自动配置、条件判断应该是框架层的事,业务代码不应随意定义全局 Bean。
  • 事务和业务逻辑解耦。不要在一个巨型方法里堆多个事务边界,拆成独立方法或独立 Service,让代理控制权清晰。
  • 启动时快速失败。循环依赖、Bean 缺失这类问题,宁可启动时暴露,也不要线上跑着跑着才报错。

最后再分享一个小技巧。排查自调用问题时,除了上面说的AopContext.currentProxy(),还有个更省心的思路:把“需要被代理增强”的方法全部放到另一个 Service 类里,用构造器注入把这个 Service 拉进来。这样事务边界天然清晰,单元测试时也能直接 mock 依赖,不用去记“一定要调代理对象”这类潜规则。把这些机制理清一遍之后,再回来看那些官方文档和面试题,你会觉得每一个功能背后都有它的道理。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/10 18:50:54

QK-norm、softcap与退火技巧:小模型训练稳定性实战指南

聊到小模型训练,有个话题绕不开:QK-norm、softcap 这些被大家戏称为“保险丝”的防护手段,到底要不要加,加了会不会把模型练废。我最近在帮一个小规模预训练项目调基线,把 QK-norm、softcap、退火 QK-norm 这几个组合来…

作者头像 李华
网站建设 2026/10/10 18:50:52

上网导航源码落地实战:从技术选型到SEO收录的完整指南

简介:这是一套面向个人站长与前端开发者的上网导航源码,采用PHP编写,适合希望快速搭建干净、无广告导航站点的用户,也可作为企业内部网络入口或嵌入其他网站使用。压缩包共235个文件,约9.1MB,以84个php页面…

作者头像 李华
网站建设 2026/10/10 18:49:54

传递函数G(s)能视为闭环吗?数学等价与物理反馈的本质区别

既然你把“传递函数”“开环”“闭环”“Gs”这几个词一起丢了进来,我猜你大概率是被一个问题卡住了:书上说开环传递函数是 G(s),闭环传递函数是 G(s)/(1G(s)H(s)),那我能不能把一个单独的 G(s) 套进闭环公式里,然后宣…

作者头像 李华
网站建设 2026/10/10 18:48:54

技术迭代下的中年危机:真正值钱的是可迁移资产

“技术迭代与中年危机”这个题目,我琢磨了很久。网上聊这两个词,基本都是在贩卖焦虑:某某框架又出来了,某某语言又被淘汰了,年纪过了三十就怎么怎么样。我见过太多被这种叙事吓到的人,也见过一些真正把这道…

作者头像 李华
网站建设 2026/10/10 18:47:48

Oracle入门到精通实战指南:从SQL基础到高可用架构

很多人一听到“Oracle入门到精通”这六个字,第一反应是先打个问号:现在开源数据库满天飞,云数据库也这么成熟,还有必要下功夫学这个老牌重型数据库吗?这个问题我几乎每隔一阵子就会被人问到。我的回答一直没变&#xf…

作者头像 李华