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接口,有两个实现:AliPayService和WeChatPayService。你在某个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 从创建到可用,中间会经过一系列回调。很多人只知道@PostConstruct和afterPropertiesSet,但完整的顺序是这样的:
- 构造器执行(实例化)
- 属性填充(依赖注入)
BeanNameAware.setBeanName()BeanFactoryAware.setBeanFactory()ApplicationContextAware.setApplicationContext()BeanPostProcessor.postProcessBeforeInitialization()@PostConstruct标注的方法InitializingBean.afterPropertiesSet()@Bean(initMethod = "...")指定的方法BeanPostProcessor.postProcessAfterInitialization()- Bean 可用
销毁阶段的顺序是反过来的:@PreDestroy→DisposableBean.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. 方法可见性 | 是否为 public | private/protected 方法不生效 |
| 2. 调用方式 | 是否同类内部调用 | this.xxx() 不走代理 |
| 3. 异常类型 | 抛出的是否 RuntimeException | 受检异常默认不回滚 |
| 4. 异常处理 | 是否被 catch 吞掉 | catch 后不抛出,事务不回滚 |
| 5. 传播行为 | propagation 设置是否正确 | REQUIRES_NEW 嵌套场景 |
| 6. 数据源 | 是否配置了事务管理器 | 多数据源场景容易漏 |
| 7. 代理模式 | 是否被 AOP 代理 | final 类/方法不生效 |
5.2 受检异常不回滚:一个反直觉的默认行为
Spring 的@Transactional默认只在遇到RuntimeException和Error时回滚,受检异常(Exception及其非运行时子类)不会触发回滚。这个设计的原因是:受检异常通常表示"可预期的业务异常",Spring 认为这类异常应该由调用方处理,而不是自动回滚。
但实际项目里,很多业务异常是受检异常,如果不显式配置rollbackFor = Exception.class,就会出现"异常抛出了但数据没回滚"的问题。
@Transactional(rollbackFor = Exception.class) public void transfer() throws BusinessException { ... }我的建议是:所有@Transactional都显式写上rollbackFor = Exception.class,不要依赖默认行为。多写几个字符,省掉无数排查时间。
5.3 事务嵌套与传播行为的实际表现
REQUIRED、REQUIRES_NEW、NESTED这三个传播行为最容易搞混。用一个场景说明:方法 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 版本时第一时间发现装配问题。踩过几次坑之后,我觉得这点投入非常值。