写了好几年 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 依赖,不用去记“一定要调代理对象”这类潜规则。把这些机制理清一遍之后,再回来看那些官方文档和面试题,你会觉得每一个功能背后都有它的道理。