news 2026/9/19 22:12:24

Spring5核心容器深度解析:Bean装配、生命周期与事务失效排查

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring5核心容器深度解析:Bean装配、生命周期与事务失效排查

1. 从"会用"到"用对":Spring5 核心容器到底在管什么

很多人学 Spring 到第五篇的时候,心里其实有个坎:前面几篇把 IoC、DI、Bean 生命周期、AOP 都过了一遍,代码也能跑起来,但一旦遇到真实项目里的复杂场景——比如同一个接口有多个实现、循环依赖报错、事务莫名其妙不生效——就完全不知道从哪下手。这个坎的本质,不是 API 记得不牢,而是没有真正理解 Spring5 核心容器在"管"什么。

Spring5 的核心容器,说白了就干三件事:创建对象、装配依赖、管理生命周期。听起来简单,但魔鬼全在细节里。ApplicationContext启动的那一刻,它会读取配置元数据(XML、注解、Java Config 都算),然后把这些描述转换成一个个BeanDefinition,再根据BeanDefinition去实例化、填充属性、执行初始化回调,最后放进单例池等着被用。你写的每一行@Component@Bean@Autowired,最终都会变成这条流水线上的一个指令。

为什么第五篇要专门讲这个?因为前面几篇你是在"用"Spring,从这一篇开始,你要开始"用对"Spring。用对的前提,是你得知道容器在背后替你做了哪些决策,以及这些决策在什么情况下会出问题。比如为什么构造器注入和字段注入在循环依赖场景下表现完全不同?为什么@PostConstruct有时候不执行?为什么@Transactional加在 private 方法上就失效了?这些问题的答案,全都藏在容器的启动流程和 Bean 的创建流程里。

这篇文章我会按照真实项目里踩坑的顺序来组织:先讲 Bean 定义和装配里最容易出错的几个点,再讲生命周期回调的实际执行顺序和常见误解,然后是 AOP 代理在 Spring5 里的行为变化,最后落到事务管理这个重灾区。每一块我都会给出可复现的代码、排查思路和我自己在项目里踩过的坑。适合已经能跑通 Spring 基础 Demo、但在复杂场景下经常懵的开发者。

2. Bean 定义与依赖装配:那些"看起来能跑"的配置

2.1 同类型多实现时,Spring 到底选哪个

这是新手最容易翻车的地方。假设你有一个PayService接口,有两个实现:AliPayServiceWeChatPayService。你在某个OrderService里写了@Autowired private PayService payService;,启动直接报NoUniqueBeanDefinitionException。很多人第一反应是"我明明只想要一个,为什么它不知道选哪个"——因为 Spring 的装配逻辑是按类型找候选,候选多于一个时再按名称匹配,而你的字段名payService跟两个实现类的 Bean 名称都不完全一致,所以匹配失败。

解决办法有三种,我按推荐程度排序:

第一种是用@Primary标注默认实现。在AliPayService上加@Primary,Spring 在多个候选中会优先选它。这种方式适合"确实有一个主实现"的场景,改动最小。

第二种是用@Qualifier精确指定。@Autowired @Qualifier("weChatPayService") private PayService payService;。这种方式适合调用方明确知道自己要哪个实现的场景,语义最清晰。

第三种是注入Map<String, PayService>List<PayService>。Spring 会把所有该类型的 Bean 按名称作为 key 注入进来,你在代码里根据业务条件动态选择。这种方式适合策略模式场景,比如根据支付渠道动态路由。

提示:如果你的项目里大量出现@Qualifier,说明接口抽象可能有问题,值得回头审视一下是不是该拆接口了。

我自己的经验是,字段名尽量跟 Bean 名称保持一致,这样即使有多个实现,Spring 也能靠名称匹配兜底。但不要依赖这个"兜底",显式声明永远比隐式匹配可靠。

2.2 构造器注入 vs 字段注入:不只是风格问题

网上关于这两种注入方式的争论很多,但大部分文章只讲"构造器注入利于测试""字段注入写起来方便",没讲到点子上。真正的区别在于循环依赖的处理方式对象不可变性的保证

Spring5 默认允许单例 Bean 的循环依赖,但只支持字段注入和 setter 注入的循环依赖,构造器注入的循环依赖会直接报错。原因在于 Spring 解决循环依赖靠的是"提前暴露半成品对象"(三级缓存机制),而构造器注入要求对象在实例化阶段就拿到所有依赖,此时半成品还没法创建,所以直接死锁。

看一个典型场景:

@Service public class AService { @Autowired private BService bService; } @Service public class BService { @Autowired private AService aService; }

这段代码能正常启动,因为 Spring 在创建 AService 时,先实例化(此时 bService 为 null),然后把 AService 的半成品放进三级缓存,再去创建 BService,BService 需要 AService 时从缓存里拿到半成品,注入完成后 BService 创建完毕,再回头给 AService 注入 bService。

但如果改成构造器注入:

@Service public class AService { private final BService bService; public AService(BService bService) { this.bService = bService; } }

启动直接报BeanCurrentlyInCreationException。这不是 Bug,是设计使然。

那实际项目里怎么选?我的建议是:默认用构造器注入,遇到循环依赖时先反思设计,而不是急着换注入方式。循环依赖本身就是设计问题的信号,两个 Service 互相依赖,往往意味着职责边界没划清。如果实在拆不开,再用@Lazy打破循环,或者退回到 setter 注入。

2.3 @Bean 方法之间的调用陷阱

用 Java Config 的时候,很多人会写出这样的代码:

@Configuration public class AppConfig { @Bean public ServiceA serviceA() { return new ServiceA(serviceB()); } @Bean public ServiceB serviceB() { return new ServiceB(); } }

这里serviceA()里直接调了serviceB(),看起来像是普通方法调用,会创建两个 ServiceB 实例。但实际上 Spring 通过 CGLIB 代理了@Configuration类,serviceB()的调用会被拦截,返回的是容器里的单例。这个机制叫配置类的代理增强

但如果你把@Configuration换成@Component,代理就不生效了,serviceB()会真的被调用两次,创建两个实例。这是一个非常隐蔽的坑,因为代码看起来一模一样,行为却完全不同。

注意:@Configuration@Component在配置类场景下不等价,前者会启用 CGLIB 代理保证单例语义,后者不会。

我踩过一次这个坑:一个配置类用了@Component,里面两个@Bean方法互相调用,结果某个 Bean 被创建了两次,导致里面的AtomicInteger计数器行为异常,排查了大半天才定位到。从那以后,配置类我一律用@Configuration

3. Bean 生命周期回调:执行顺序比你想的复杂

3.1 初始化回调的完整执行链路

Bean 从创建到可用,中间会经过一系列回调。很多人只知道@PostConstructafterPropertiesSet,但完整的顺序是这样的:

  1. 构造器执行(实例化)
  2. 属性填充(依赖注入)
  3. BeanNameAware.setBeanName()
  4. BeanFactoryAware.setBeanFactory()
  5. ApplicationContextAware.setApplicationContext()
  6. BeanPostProcessor.postProcessBeforeInitialization()
  7. @PostConstruct标注的方法
  8. InitializingBean.afterPropertiesSet()
  9. @Bean(initMethod = "...")指定的方法
  10. BeanPostProcessor.postProcessAfterInitialization()
  11. Bean 可用

销毁阶段的顺序是反过来的:@PreDestroyDisposableBean.destroy()@Bean(destroyMethod = "...")

这个顺序为什么重要?因为如果你在@PostConstruct里依赖某个BeanPostProcessor处理过的代理对象,可能会拿到原始对象而不是代理。比如 AOP 代理是在postProcessAfterInitialization阶段生成的,@PostConstruct执行时代理还没生成,此时this指向的是原始对象。

3.2 @PostConstruct 不执行的几种情况

@PostConstruct是 JSR-250 规范里的注解,Spring 支持它,但有几个场景会导致它不执行:

场景一:Bean 不是由 Spring 管理的。你自己new出来的对象,Spring 不会管它的生命周期,@PostConstruct自然不生效。这个听起来很基础,但实际项目里经常有人在一个工具类里new了一个 Service,然后奇怪为什么初始化逻辑没跑。

场景二:Bean 的作用域是 prototype 且没有被正确获取。prototype 作用域的 Bean 每次获取都会创建新实例,生命周期回调会执行,但如果你获取后没有使用,销毁回调不会执行。

场景三:类被 AOP 代理且注解加在了父类方法上。Spring5 对 CGLIB 代理的处理有变化,如果@PostConstruct加在父类的 private 方法上,代理子类无法继承该方法,回调就不会触发。

场景四:使用了@Bean方式声明且返回类型是接口。这种情况下 Spring 无法确定具体类型,某些回调可能被跳过。

排查这类问题的通用思路是:在@PostConstruct方法里打日志,如果日志没输出,先确认 Bean 是不是 Spring 管理的(看启动日志里有没有该 Bean 的创建记录),再确认注解位置是否正确。

3.3 循环依赖下生命周期回调的诡异表现

回到循环依赖的场景。当 AService 和 BService 互相依赖时,Spring 会提前暴露 AService 的半成品。此时如果 AService 的@PostConstruct方法里用到了 bService,而 bService 还没完全初始化,就可能拿到一个"半成品"的 BService。

更隐蔽的是,如果 AService 被 AOP 代理,提前暴露的是原始对象,而最终注入到 BService 里的是代理对象,两者不是同一个引用。这会导致aService == bService.getAService()返回 false,如果你在代码里用==比较对象,就会出问题。

提示:永远不要用==比较 Spring 管理的 Bean,用equals或者业务 ID 比较。

我在一个项目里遇到过这个问题:一个缓存组件在@PostConstruct里注册自己到另一个管理器,结果因为循环依赖,注册进去的是原始对象,后续通过管理器拿到的却是代理对象,导致缓存失效逻辑不生效。解决办法是把注册逻辑从@PostConstruct挪到ApplicationReadyEvent监听里,等所有 Bean 都初始化完成后再执行。

4. Spring5 的 AOP 代理:JDK 动态代理与 CGLIB 的取舍

4.1 Spring5 默认代理策略的变化

Spring Boot 2.x 开始,spring.aop.proxy-target-class默认为true,也就是说默认使用 CGLIB 代理,而不是 JDK 动态代理。这个变化影响很大,因为 CGLIB 代理和 JDK 代理在行为上有本质区别。

JDK 动态代理基于接口,生成的代理类实现了目标接口,所以只能代理接口中定义的方法。CGLIB 基于继承,生成的代理类继承目标类,可以代理所有非 final 的 public 和 protected 方法。

Spring5 之所以改成默认 CGLIB,是因为很多项目里 Service 没有接口,或者接口方法不全,用 JDK 代理会导致部分方法无法被增强。CGLIB 覆盖面更广,但代价是:final 类和 final 方法无法被代理

4.2 代理失效的四种典型场景

场景一:方法不是 public。Spring AOP 只能增强 public 方法,private、protected、包级方法都不会被代理。这是@Transactional加在 private 方法上失效的根本原因。

场景二:方法被 final 修饰。CGLIB 通过继承实现代理,final 方法无法被重写,所以不会被增强。

场景三:同类内部方法调用。这是最经典的坑:

@Service public class OrderService { public void createOrder() { this.updateStock(); // 内部调用,不走代理 } @Transactional public void updateStock() { ... } }

this.updateStock()调用的是原始对象的方法,不是代理对象的方法,所以事务不生效。解决办法是注入自己(@Autowired private OrderService self;)然后self.updateStock(),或者用AopContext.currentProxy()

场景四:Bean 没有被 Spring 管理。跟@PostConstruct一样,自己new出来的对象没有代理。

4.3 如何验证代理是否生效

排查 AOP 问题,第一步永远是确认代理有没有生成。最简单的方法是在启动日志里搜索Bean 'xxx' is not eligible for getting processed by all BeanPostProcessors,或者直接在代码里打印aopProxy.getClass().getName(),如果类名里包含$$EnhancerBySpringCGLIB$Proxy,说明代理生效了。

另一个方法是注入ApplicationContext,然后context.getBean("orderService").getClass(),看返回的类是不是代理类。

我自己的习惯是在单元测试里加一个断言,检查关键 Service 是不是被代理了。这样一旦代理策略变化或者注解位置写错,测试会第一时间报错,而不是等到线上事务不生效才发现。

5. 事务管理:@Transactional 失效的排查链路

5.1 事务失效的完整排查顺序

@Transactional失效是 Spring 项目里最高频的问题之一。我总结了一个排查顺序,按这个顺序走,基本能覆盖 90% 的场景:

排查项检查内容常见错误
1. 方法可见性是否为 publicprivate/protected 方法不生效
2. 调用方式是否同类内部调用this.xxx() 不走代理
3. 异常类型抛出的是否 RuntimeException受检异常默认不回滚
4. 异常处理是否被 catch 吞掉catch 后不抛出,事务不回滚
5. 传播行为propagation 设置是否正确REQUIRES_NEW 嵌套场景
6. 数据源是否配置了事务管理器多数据源场景容易漏
7. 代理模式是否被 AOP 代理final 类/方法不生效

5.2 受检异常不回滚:一个反直觉的默认行为

Spring 的@Transactional默认只在遇到RuntimeExceptionError时回滚,受检异常(Exception及其非运行时子类)不会触发回滚。这个设计的原因是:受检异常通常表示"可预期的业务异常",Spring 认为这类异常应该由调用方处理,而不是自动回滚。

但实际项目里,很多业务异常是受检异常,如果不显式配置rollbackFor = Exception.class,就会出现"异常抛出了但数据没回滚"的问题。

@Transactional(rollbackFor = Exception.class) public void transfer() throws BusinessException { ... }

我的建议是:所有@Transactional都显式写上rollbackFor = Exception.class,不要依赖默认行为。多写几个字符,省掉无数排查时间。

5.3 事务嵌套与传播行为的实际表现

REQUIREDREQUIRES_NEWNESTED这三个传播行为最容易搞混。用一个场景说明:方法 A 开启事务,调用方法 B。

  • REQUIRED(默认):B 加入 A 的事务,A 回滚 B 也回滚,B 回滚会导致整个事务标记为 rollback-only。
  • REQUIRES_NEW:B 开启新事务,挂起 A 的事务。B 回滚不影响 A,A 回滚不影响 B(前提是 B 已经提交)。
  • NESTED:B 在 A 的事务里开一个保存点,B 回滚只回滚到保存点,A 可以继续。

这里有个大坑:REQUIRED场景下,如果 B 抛异常被 A catch 了,A 继续执行,最后提交时会报UnexpectedRollbackException,因为 B 已经把事务标记为 rollback-only 了。很多人以为 catch 了异常就没事,结果提交时报错,一脸懵。

注意:REQUIRED传播下,内层方法抛异常即使被外层 catch,事务仍然会被标记为回滚。

解决办法是:如果确实需要"内层失败不影响外层",用REQUIRES_NEW;如果只是想让内层独立回滚,用NESTED。但要注意NESTED需要数据库支持保存点,且只对DataSourceTransactionManager有效。

5.4 多数据源下的事务管理器选择

多数据源场景下,@Transactional默认使用Primary的事务管理器。如果你操作的是非主数据源,事务不会生效。解决办法是在@Transactional上指定transactionManager

@Transactional(transactionManager = "secondaryTransactionManager", rollbackFor = Exception.class)

或者在配置类里用@Primary标注主事务管理器,其他数据源显式指定。我见过最隐蔽的坑是:两个数据源共用一个DataSourceTransactionManager,结果事务只对其中一个数据源生效,另一个数据源的操作完全不在事务里。排查这种问题,只能靠日志和断点,确认事务管理器的dataSource属性指向是否正确。

6. 我在实际项目里踩过的三个坑

6.1 @Async 和 @Transactional 同时使用时的顺序问题

一个方法同时标注@Async@Transactional,你以为它会"异步执行且带事务",但实际上事务可能不生效。原因是@Async的代理和@Transactional的代理是两层,执行顺序取决于代理的嵌套顺序。如果@Async代理在外层,方法会在新线程里执行,而事务的ThreadLocal绑定是在原线程上的,新线程拿不到事务上下文。

解决办法是:把@Transactional方法单独抽出来,@Async方法调用它。或者调整代理顺序,但这依赖 Spring 内部实现,不推荐。

6.2 Bean 名称冲突导致的覆盖

Spring 默认允许 Bean 名称冲突,后定义的会覆盖先定义的。这个行为在大型项目里非常危险,因为两个不同模块可能定义了同名的 Bean,启动时不报错,运行时行为却取决于加载顺序。

我建议在配置文件里加上:

spring: main: allow-bean-definition-overriding: false

这样一旦有名称冲突,启动直接报错,逼你显式处理。虽然会暴露一些历史遗留问题,但长远看是值得的。

6.3 循环依赖 + AOP 导致的类型转换异常

前面提到过,循环依赖时提前暴露的是原始对象,而最终注入的是代理对象。如果某个地方做了强制类型转换,比如(OrderService) bean,而 bean 是代理对象,代理对象是原始类的子类,转换本身没问题。但如果原始类实现了接口,代理是 JDK 动态代理,代理对象只实现了接口,不是原始类的子类,强转就会报ClassCastException

这个坑的根源是 JDK 代理和 CGLIB 代理的混用。解决办法是统一代理策略,或者避免在循环依赖场景下做类型转换。Spring Boot 2.x 默认 CGLIB 后,这个问题少了很多,但如果你手动配置了proxy-target-class: false,还是可能遇到。

7. 一些让 Spring5 用起来更顺手的实践建议

关于 Bean 的作用域,我建议默认都用单例,prototype 只在确实需要"每次获取新实例"的场景用。因为 prototype 的 Bean 销毁不受 Spring 管理,容易造成资源泄漏。如果确实需要类似 prototype 的行为,可以考虑用ObjectProvider延迟获取,或者用@Scope("prototype")配合@Lookup

关于条件装配,@ConditionalOnProperty@ConditionalOnMissingBean这些注解在写 Starter 或者多环境配置时非常有用。我习惯把环境相关的 Bean 用@Profile隔离,把可选依赖用@ConditionalOnClass控制,这样同一个代码库能适配不同部署环境。

关于启动优化,Spring5 的启动速度在大型项目里是个痛点。我常用的手段是:延迟初始化(spring.main.lazy-initialization=true)、减少不必要的@ComponentScan范围、用@Import精确导入配置类而不是全包扫描。延迟初始化要注意,它会把启动时的错误推迟到第一次使用时,所以生产环境慎用,或者配合健康检查使用。

关于调试,我强烈建议在开发环境打开 Spring 的 debug 日志,logging.level.org.springframework=DEBUG,这样 Bean 的创建、依赖注入、AOP 代理生成都有日志可查。虽然日志量大,但排查问题时能省很多时间。生产环境记得关掉,不然日志会爆炸。

最后说一个我个人的习惯:每个项目我都会写一个SpringContextTest,在测试里注入ApplicationContext,然后断言关键 Bean 的存在性、类型、代理状态。这个测试跑起来很快,但能在重构或者升级 Spring 版本时第一时间发现装配问题。踩过几次坑之后,我觉得这点投入非常值。

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

谷歌浏览器截全屏长图全攻略:从开发者工具到手机端

谷歌浏览器如何截全屏长图&#xff08;非常实用&#xff0c;手机和电脑都适用&#xff09;平时截图截到想摔鼠标的经历&#xff0c;大家应该都有过。尤其是打开一个网页想完整保存整个页面内容&#xff0c;或者做资料整理、给同事反馈问题时&#xff0c;怎么截都只能截到当前屏…

作者头像 李华
网站建设 2026/9/19 22:09:45

Unity AssetBundle崩溃排查:LoadAsset_Internal并非内存溢出元凶

1. 崩溃日志里那个被冤枉的"内存溢出"如果你在Unity项目里排查过崩溃问题&#xff0c;大概率见过这样的场景&#xff1a;玩家反馈游戏突然闪退&#xff0c;你拿到日志一看&#xff0c;满屏都是Out of Memory或者Could not allocate memory&#xff0c;第一反应就是&q…

作者头像 李华
网站建设 2026/9/19 22:08:32

TCN时序建模实现轴承磨损趋势预测与RUL回归

简介&#xff1a;本资源是一份面向工业智能运维工程师、设备预测性维护算法研发人员及高校相关方向研究者的深度技术方案&#xff0c;系统解决轴承磨损趋势难以精准建模、维护时机决策缺乏数据支撑的行业痛点。全文374页&#xff0c;覆盖50个工程化章节&#xff0c;从多源传感器…

作者头像 李华
网站建设 2026/9/19 22:07:33

N_m3u8DL-RE 报 mux failed?三步定位 mkvmerge 语言标签问题并修复

N_m3u8DL-RE 报 mux failed&#xff1f;三步定位 mkvmerge 语言标签问题并修复 【免费下载链接】N_m3u8DL-RE Cross-Platform, modern and powerful stream downloader for MPD/M3U8/ISM. English/简体中文/繁體中文. 项目地址: https://gitcode.com/GitHub_Trending/nm3/N_…

作者头像 李华