先说个我前阵子遇到的事。项目里有个@Component,在构造器里依赖了另一个@Service的实例,启动时一切正常,但某个上游回调接口一进来,那个@Service居然是null。我盯着代码看了半天,逻辑没错,注解也加了,最后翻Spring的启动日志才定位到:这个@Component被一个@Bean方法提前触发了初始化,整个装配顺序被打乱。那之后我痛下决心,把Spring的整个装配链路从头到尾捋了一遍。
Spring的装配顺序,本质上是四个阶段的串联:配置 → 扫描 → 注入 → 初始化。你看着就八个字,但里面藏着的细节,决定了你写出来的Bean到底能不能按预期工作。配置阶段决定了“有哪些配置”、扫描阶段决定了“有哪些候选Bean”、注入阶段决定了“依赖怎么建立”、初始化阶段决定了“对象生命周期的回调顺序”。这四条线,只要有一条理解偏差,排查线上问题的时间就会以小时为单位起步。这篇文章不是给你背概念,是把我实际踩过的坑、梳理过的链路,一次讲清楚。
如果你是一个正在用Spring Boot但没系统看过IoC容器的Java开发者,或者是个写了两年CRUD但一遇到启动报错就靠猜的“老兵”,这篇文章能帮你把整个装配机制串起来。看完之后,你再看到BeanCreationException、循环依赖报错、@Value为null这类问题,就能从“瞎试”变成“定位”。
1. 装配顺序全景图:先看整体,再看细节
1.1 一次启动触发的一连串事件
很多人用Spring Boot,只知道写个@SpringBootApplication,点运行,然后容器就“魔法般”地把所有Bean准备好了。但这背后,ApplicationContext启动时经历了一长串事件。我用白话把主线列出来:
- 读取配置元数据:包括XML、注解、
application.yml,把这些信息转成容器的“说明书”。 - 注册Bean定义:把每一个候选类封装成
BeanDefinition,登记到容器里。注意,这时还没有创建真实对象,只是“登记在案”。 - 实例化前处理:执行
BeanFactoryPostProcessor,对Bean定义本身做修改、补充。 - 实例化Bean:根据Bean定义,通过构造器创建对象实例。
- 属性填充:把依赖的Bean注入进来,也就是大家常说的自动装配。
- 初始化:执行Aware回调、
@PostConstruct、InitializingBean等初始化逻辑。 - 就绪:完成所有单例Bean的准备,容器启动成功。
这一连串事件里,配置阶段其实涵盖了前两步的“读取配置元数据 + 注册候选信息”,扫描是“注册候选信息”的具体手段,注入是“属性填充”的动作,初始化则对应生命周期的收尾动作。我们平时说的“装配顺序”,就是指这几个阶段彼此咬合的先后关系。
1.2 四个阶段分别解决什么问题
四个阶段各有各的职责,理解职责比死记先后更重要:
- 配置阶段解决的是“容器从哪拿信息”:你写的
@Configuration类、@Bean方法、import或者yml里的配置项,最终都要转化为容器能理解的数据结构。这个阶段不创建Bean,只准备“原料清单”。 - 扫描阶段解决的是“哪些类应该成为Bean”:你的
@Component、@Service、@Repository、@Controller,需要靠ClassPath扫描被识别出来。扫描结果会生成一批BeanDefinition,相当于给每一个候选Bean建了一张“待办卡片”。 - 注入阶段解决的是“Bean之间的依赖怎么建立”:有了候选Bean,还要把它们的依赖关系串起来。
@Autowired、构造器注入、@Resource,都是在这个阶段把对象引用绑定到目标Bean上。 - 初始化阶段解决的是“Bean创建完了要做哪些后置动作”:连接池预热、缓存初始化、注册回调钩子……这些动作依靠BeanPostProcessor和生命周期回调接口完成。
一句话总结:配置是“剧本”,扫描是“选角”,注入是“建立人物关系”,初始化是“角色正式上台前的准备工作”。任何一个环节出错,整台戏都演不下去。
1.3 装配顺序出错,会出现什么典型症状
装配顺序混乱,线上症状多得很,但最典型的就是下面几类:
- 构造器循环依赖:A依赖B,B依赖A,两者正好都在构造器里注入,启动直接报
BeanCurrentlyInCreationException。 @Value注入为null:配置信息没在正确时机加载,或者Bean被过早初始化,配置还没合并完就创建了对象。@PostConstruct不执行:实例化后容器没走到初始化回调这一环,常见于手动new对象逃过了Spring的生命周期管理。- Bean被提前初始化:一个
@Bean方法在容器启动早期就被触发,连带了一批依赖Bean提前创建,导致后面一些后置处理器还没来得及注册。
看到这些症状,你要做的第一件事,不是改代码,而是先确认出问题的是哪个阶段。所以我强烈建议大家学会看启动日志,特别是DEBUG级别的DefaultListableBeanFactory、ConfigurationClassPostProcessor日志。日志顺序就是真实的装配顺序,比任何文档都靠谱。
2. 配置:装配的起点是元数据
2.1 配置类是如何被识别的
Spring Boot里,装配流程的第一个关键入口是ConfigurationClassPostProcessor。这个类实现了BeanDefinitionRegistryPostProcessor,在容器刷新早期就会被调用。它干的事,就是把所有标注了@Configuration的类找出来,解析里面的@Bean方法、@Import注解、@ComponentScan注解等。
这里有个容易忽略的点:@Configuration有两种模式,full模式和lite模式。full模式默认开启,即proxyBeanMethods = true,Spring会为配置类生成CGLIB代理,保证@Bean方法之间的调用走的是代理对象,从而保证单例语义。lite模式则是proxyBeanMethods = false,不会生成代理,@Bean方法之间直接调用时,每次都会创建新对象。
我实际项目里遇到过,一个配置类里的两个@Bean方法,A方法内部调用了B方法,想着省事直接return b();。结果用lite模式跑了几个星期,某天压测时发现A关联的对象出现了大量重复实例,数据库连接池一度被打满。排查半天,才发现是proxyBeanMethods被关了。一句话心得:没有特殊理由,别关full模式,否则你对单例Bean的直觉会在最不该出错的时候坑你。
2.2 BeanDefinition是什么,为什么它这么重要
配置阶段的产物,不只是几个Bean对象,而是大量的BeanDefinition。你可以把它理解成一张“食材清单”,上面标了:这个Bean的类全名是什么、作用域是单例还是原型、初始化方法是哪个、是否需要懒加载、有哪些依赖……容器后面所有的动作,都是照着这张清单来执行的。
用代码来看,一个BeanDefinition至少包含这些关键信息:
// 伪代码,展示BeanDefinition关心的核心字段 beanDefinition.setBeanClassName("com.example.OrderService"); beanDefinition.setScope(ConfigurableBeanFactory.SCOPE_SINGLETON); beanDefinition.setLazyInit(false); beanDefinition.setInitMethodName("init"); beanDefinition.setDependsOn(new String[]{"dataSource"});这些字段里面,scope和lazyInit对装配顺序有直接影响。比如懒加载Bean,容器启动时不会实例化,直到第一次调用getBean()才触发注入和初始化。这意味着,一个懒加载Bean的问题,可能在启动时完全“隐身”,直到运行期才爆炸。我个人排查启动快慢问题时,也经常用懒加载验证“到底哪个Bean拖慢了启动”,但生产环境极少开全局懒加载,因为它会把问题苗头推迟暴露。
2.3 配置阶段最容易踩的坑
配置阶段的坑,很多不是技术原理上的,而是信息加载顺序的问题。举三个我亲自踩过的:
第一个坑:把配置写在application.yml里,但在@Configuration类中用@Value读取,结果读出来是null。原因是某个工具类在配置类之前就被手动实例化和注入。解决思路是确保所有读取配置的行为都发生在Bean生命周期内,不要让工具类“绕过容器”提前加载。
第二个坑:@Configuration类本身也被当成普通Bean扫描了一遍,导致重复解析。正常情况下@SpringBootApplication的扫描范围覆盖主配置类,但如果你另外手动加了一个@ComponentScan,指向了同一个包,配置类可能被解析两次。被代理的配置类还容易引发BeanDefinition重复注册,启动期直接异常。规避方案是统一用@SpringBootApplication默认扫描,少玩花活。
第三个坑:忘了@Import的时机。@Import引入的类,如果是ImportBeanDefinitionRegistrar或ImportSelector,它们的执行顺序是有讲究的。ImportSelector返回的类名会当作新的配置类继续解析,这种“间接引入”比直接@Component扫描更难排查。我在多模块项目里就遇到过一个情况:某个公共模块通过@Import注册了一堆Bean,但私有模块里扫描顺序靠前,提前用了这些Bean,结果初始化一半被打断。最后在配置类上明确加@DependsOn才压住顺序。
3. 扫描:从“类”到“Bean候选”
3.1 ComponentScan到底扫了什么
很多教程告诉你,@ComponentScan扫的是包路径,把@Component、@Service这类注解标出来的类注册成Bean。但实际扫描的逻辑比这句话要细得多。Spring的ClassPathBeanDefinitionScanner在doScan方法里,会通过PathMatchingResourcePatternResolver去classpath下找匹配的.class文件。然后借助ASM直接读取类的元数据,不会把类加载进JVM。
这带来一个重要结果:扫描阶段不会触发类的静态初始化块,也不会执行类中引用的外部依赖。也就是说,一个类虽然被扫到了,但此时它只表现为一串元数据。只有等到后面对应的BeanDefinition被实例化时,JVM才会真正加载这个类。这个设计抽离了“注册”和“加载”,也让很多“扫描到但暂时不会实例化”的场景不会互相干扰。
我用一段简化代码还原扫描的“决策链条”:
// 简化描述:ClassPathScanningCandidateComponentProvider // 1. 扫描出所有.class资源 // 2. 读取每个资源的ASM元数据 // 3. 判断是否匹配includeFilters,且不匹配excludeFilters // 4. 若匹配,生成一个ScannedGenericBeanDefinition // 5. 最后交给ClassPathBeanDefinitionScanner.doScan注册进容器这一步里,includeFilters默认注册的是@Component的注解过滤器。@Service、@Repository、@Controller之所以能被扫到,是因为它们自身都加了@Component元注解。所以如果你自定义了一个注解,想让扫描器识别,要么让它带上@Component,要么手动注册一个TypeFilter。
3.2 扫描到的类是怎么被筛选的
筛选规则比我预想的要严格。除了注解匹配,还有几个过滤器在起作用:
excludeFilters:比如@Configuration类会被ConfigurationClassPostProcessor单独处理,所以ClassPathBeanDefinitionScanner默认的includeFilters里反而不包含Configuration的处理器,避免重复注册。- 接口和抽象类:没有
@Component元注解的抽象类即使被扫到,也会被过滤掉;接口同理。 - 匿名类和内部类:部分内部类由于字节码结构的原因,默认不会被当作独立候选类。
- 外部类嵌套过滤:如果内部类是
static的,在某些Spring版本下可以扫到,但依赖实际类加载时的解析情况。
这些细节在平时开发中很少碰到,但一旦你写通用组件、做二次封装,就会发现“为什么我自己写的那个抽象基类没有被当成Bean?”。原因就是我们上面说的:它不是候选类,得靠@Component显式标注或自己注册Bean定义。
另一个影响扫描结果的关键点是“名字”。默认情况下,Spring会把类名首字母小写,比如OrderController变成orderController注册进容器。你可以用@Component("mySpecialName")来指定名称,但命名一致性在排查问题时非常重要。两个模块里的同名Bean,名称相同但实际类不同,在注入时可能彼此覆盖,产生非常隐蔽的bug。
3.3 扫描顺序对后续装配的影响
扫描顺序会直接影响BeanDefinition的注册顺序,进而影响默认的初始化顺序。Spring在注册BeanDefinition时,会维护一个LinkedHashMap,保证注册顺序即迭代顺序。如果两个Bean之间存在@DependsOn关系,那扫描在前还是先后其实不关键;但如果它们之间没有任何依赖声明,只是隐式地依赖了“某个Bean先就绪”,那扫描顺序就成了决定因素。
我举个例子。假如有ConfigInitializer和BizService,前者负责从数据库拉配置到内存缓存,后者构造时读了这个缓存。两者没有显式依赖关系,但一旦BizService先初始化,就会读到空缓存。这种情况下,把@DependsOn("configInitializer")加到BizService上,或者调整扫描包顺序,就能解决。
需要特别注意:扫描顺序是包顺序、类名顺序的综合结果,不是代码书写顺序。比如com.foo.a包里的类永远比com.foo.b包里的类先被扫描到。想精确控制,要么显式写@DependsOn,要么把逻辑放到@PostConstruct或ApplicationRunner里做二次校验。依赖“扫描顺序天然正确”是最脆弱的做法,我见过太多团队因为新加了一个类就导致启动顺序翻转。
4. 注入:真正建立对象之间的依赖
4.1 依赖注入的三种方式与优先级
注入是装配顺序里最容易被误解的阶段。很多人以为@Autowired是在Bean实例化完成的瞬间注入的,其实时机要细分。Spring创建Bean的完整路径大致是:
实例化(构造器) -> 属性填充(populateBean) -> 初始化(initializeBean)- 构造器注入:在实例化阶段就发生。Spring拿到
BeanDefinition后,会确认使用哪个构造器,并把构造器参数对应的依赖Bean解析出来传入。所以构造器注入的依赖,必须在当前Bean实例化前就已经创建好。 - 字段注入和Setter注入:在属性填充阶段发生。此时构造器已经执行完,
BeanPostProcessor的postProcessProperties方法会把@Autowired标注的字段或方法解析并设置进去。 @Resource注入:本质走的是CommonAnnotationBeanPostProcessor,在属性填充阶段也会执行,但它按“Bean名称”优先匹配,逻辑和@Autowired按类型匹配不同。
三者优先级没必要比个高低,但要清楚各自的“可失败阶段”:构造器注入失败,报错信息通常会带着BeanCurrentlyInCreationException或者UnsatisfiedDependencyException;字段注入失败则多是NoSuchBeanDefinitionException。看报错阶段,就能反推是哪个装配环节出了问题。
我的建议是:核心依赖用构造器注入,可选依赖用Setter或@Autowired(required = false)。为什么?因为构造器注入能让依赖关系在对象创建时就被强制固定,后续没法篡改,这也是Spring官方文档比较推荐的方式。它还能绕开“字段为null”的各种诡异情况,让代码的可测试性更好。这个习惯坚持下来之后,排查问题的面会小很多。
4.2 getBean:注入实际发生的时机
Spring里有一个容易被忽略的事实:真正的Bean对象创建并不发生在扫描阶段,也不发生在注册阶段,而是发生在“被需要的时候”。这个“被需要”的时机,通常就是getBean调用。
容器启动过程中,会依次对所有非懒加载的单例Bean调用getBean。getBean的内部会走doGetBean,如果发现实例还没创建,就触发createBean,而createBean又会触发我们上面说的实例化、属性填充、初始化三件套。这个过程中如果属性填充需要其它Bean,会递归调用getBean去创建依赖Bean。
所以,“注入”不是一个独立的全局动作,而是一个沿着依赖链递归展开的过程。你看到的@Autowired字段被赋值,其实发生在某些深度递归的getBean调用栈里。理解了这一点,你就明白了为什么会出现“A创建 -> 需要B -> B创建 -> B需要C -> C创建 -> ...”(深度遍历)这样的日志序列。如果你想看真实的递归过程,在断点上打getBean调用栈是最直观的方式。
举一个更具体的场景。容器启动时,SingletonBeanRegistry的preInstantiateSingletons方法会遍历所有单例BeanName,逐个调用getBean。这个遍历顺序,和BeanDefinition注册顺序一致。假如第一个Bean是A,A的构造器依赖B,那么B即使排在注册列表最后一位,也会在A的创建过程中先被创建。所以“注册顺序”和“实际创建顺序”并不完全等价。
4.3 循环依赖与三级缓存
循环依赖是注入阶段最重量级的话题。大多数人都听过Spring的三级缓存,但不知道它在“注入顺序”中处于什么位置。我先说结论:三级缓存是为了解决“字段/Setter注入场景下的循环依赖”而设计的,它不是为了解决构造器循环依赖。
三级缓存对应的结构,在DefaultSingletonBeanRegistry里有三个Map:
// 一级缓存,存放完整的单例对象 Map<String, Object> singletonObjects; // 二级缓存,存放提前暴露的早期对象(尚未完成属性填充和初始化) Map<String, Object> earlySingletonObjects; // 三级缓存,存放ObjectFactory,用于生成早期对象 Map<String, ObjectFactory<?>> singletonFactories;当A创建时,A实例化完成后、属性填充之前,Spring就把它放入三级缓存,暴露一个ObjectFactory。B创建时发现需要A,就从三级缓存拿到A的工厂,生成一个快速引用(早期对象),注入给B。B完成后,A继续完成自己的属性填充和初始化,最终进入一级缓存。这里最精髓的机制是:过早暴露的不是成品,但能保证“同一个Bean只有一个实例”。
但这里有个大坑:如果B在注入A的早期引用后,立即调用了A的某个方法,而该方法依赖A尚未初始化的字段,那就会拿到null或默认值。这就是很多人把循环依赖解决了,但业务还是出错的原因。我在一个消息推送模块里踩过类似的坑:A和B相互引用,启动正常,但某个回调线程调用B时,B里持有的A引用还没完成后续初始化,状态不完整。最后我们把A和B的强循环拆开,改成事件驱动才彻底解决。
所以我的经验是:三级缓存能兜底,但别把它当设计常规。新代码尽量避免循环依赖,特别是构造器循环依赖,那在启动期就会报错,理论也没法解决。要是实在避不开,尽量让循环依赖发生在字段注入上,并确保循环引用期间不会调用依赖对方的关键方法。
5. 初始化:Bean生命周期的最后一公里
5.1 初始化顺序为什么重要
Bean完成实例化和属性填充之后,还没法直接用,得经过初始化阶段。初始化阶段的执行顺序,决定了对象的“装备”是否齐全。Spring在AbstractAutowireCapableBeanFactory.initializeBean方法中,把初始化流程分成了几大步:
- 先执行
invokeAwareMethods,回调BeanNameAware、BeanClassLoaderAware、BeanFactoryAware。 - 接着执行所有
BeanPostProcessor#postProcessBeforeInitialization。 - 然后执行
invokeInitMethods,包括InitializingBean#afterPropertiesSet和自定义的init-method。 - 最后执行所有
BeanPostProcessor#postProcessAfterInitialization。
这个顺序信息量很大。@PostConstruct注解为什么在afterPropertiesSet之前执行?因为@PostConstruct的处理器(InitDestroyAnnotationBeanPostProcessor)是作为一个BeanPostProcessor挂在postProcessBeforeInitialization环节的。而InitializingBean的afterPropertiesSet才在后面的invokeInitMethods里执行。所以执行顺序是:@PostConstruct->InitializingBean->init-method。
从我写业务代码的经验看,90%的初始化工作放到@PostConstruct就够了。它是Java标准支持的,不强制绑定Spring接口,而且比XML里的init-method更直观。但如果你是做框架封装的、写通用组件的,BeanPostProcessor可能是你的主战场,因为它可以在Bean初始化前后统一做代理、埋点、扩展,自由度最高。
5.2 Aware回调、BeanPostProcessor与初始化方法
在实际项目中,你经常会看到一些组件实现了ApplicationContextAware,然后通过静态变量保存上下文。这类回调在初始化阶段执行,时机是BeanNameAware等Aware接口之后、BeanPostProcessor之前。它们的作用是让Bean感知到容器环境。但这里有两个容易踩的坑:
第一个坑:静态字段保存上下文。很多人写工具类时,喜欢用ApplicationContextAware拿到Spring容器存到static变量里,之后任何地方都能用。这确实方便,但也意味着这个工具类脱离了Spring的生命周期管理。一旦容器被关闭,静态引用还在,就变成一个持有已关闭上下文的“僵尸工具”。我在项目里见到过因为这种写法导致的连接泄漏,排查起来极其痛苦。建议保存上下文后,显式做空值检查和容器状态判断。
第二个坑:BeanPostProcessor自身的初始化顺序。如果你写了多个BeanPostProcessor,它们内部也可能依赖其他Bean,而BeanPostProcessor创建得非常早,此时很多普通Bean还没注册。一旦BeanPostProcessor依赖了一个普通Bean,经常会触发该Bean的“过早初始化”,打乱整个装配顺序。做法是:BeanPostProcessor里不要注入那些后续才创建的业务Bean,或者通过@Lazy延迟获取。
再补一个知识点:ApplicationRunner和CommandLineRunner虽然名字带Runner,但它们不算Bean初始化阶段,而是容器完全启动后的“扩展点”。如果你想在容器装配完成后,统一执行一些全局的任务(比如预热缓存、检查配置完整性、打印启动信息),用ApplicationRunner比用@PostConstruct更稳,因为那时候所有单例Bean基本都创建完了。
5.3 常见生命周期坑位
生命周期这块,最常见的坑就是“时序依赖”和“回调失效”。
时序依赖的例子:A的@PostConstruct里调用了B的某个方法,但B的@PostConstruct还没执行,B内部的状态没准备好。A拿到的是“半成品B”。这种问题不会直接报错,只会表现为功能偶发不正常。要解决,要么拆分逻辑,让A调用B的行为延后;要么用@DependsOn("b")显式指定A依赖B,让B先初始化。
回调失效的例子:你在@Configuration里用普通Java方法创建了一个Bean,然后又在这个方法内部手动调用了该Bean的某个初始化方法。如果Spring不知道这个方法的存在(没有注册为initMethod或@PostConstruct),那初始化逻辑不会按容器规则执行。如果这个Bean被某个BeanPostProcessor处理了,甚至可能拿到的是代理对象,你原本的初始化逻辑直接没被调用。
还有一个压箱底的坑:初始化阶段抛异常,会把所有Bean的初始化和依赖关系全部打乱。一个Bean的@PostConstruct失败,会触发容器把已经创建的单例对象回滚销毁。如果这时候你开启了其他线程异步操作,可能拿到的是已经废弃的Bean引用。处理方式是:初始化逻辑里尽量先做自检,再做外部操作;如果确实要异步,把异常处理放在异步任务内部消化掉,而不是让它冒泡影响构造过程。
6. 实战排查:根据症状反推装配顺序
6.1 问题速查表
排查Spring装配问题,最忌讳一上来就乱加日志。我的习惯是,先按症状定位阶段,再深入对应机制。这里整理一份速查表,每一行都是我工作里反复用过的排查逻辑:
| 症状 | 可能阶段 | 直接排查动作 |
|---|---|---|
启动报BeanCurrentlyInCreationException | 注入阶段/循环依赖 | 看报错堆栈中哪些Bean正在创建,检查是否存在构造器循环 |
启动报NoSuchBeanDefinitionException | 扫描阶段 | 确认扫描包路径是否覆盖目标类,检查类上是否有@Component元注解 |
字段为null但注解写了@Autowired | 注入阶段 | 确认对象是否由Spring实例化,检查是否被手动new了 |
@Value读到null | 配置阶段 | 检查配置加载时机,确认Bean是否被提前初始化 |
@PostConstruct没执行 | 初始化阶段 | 确认Bean是否被BeanPostProcessor代理,检查代理对象是否绕过生命周期 |
| 启动成功但功能偶发异常 | 初始化顺序 | 用@DependsOn显式声明依赖,或者把逻辑放到ApplicationRunner |
| Bean被创建了两次 | 配置阶段/扫描阶段 | 排查@Bean方法和@ComponentScan是否重复注册,检查配置类的代理模式 |
这张表不是万能的,但能把排查范围从“全项目”缩小到“某一阶段”。剩下的就是用日志和断点去验证。
6.2 一个完整的排查案例
分享一个真实案例,就是文章开头提到的那个问题。服务启动正常,但某个上游接口回调时,一个@Component依赖的@Service是null。先看代码,@Service没问题,@Autowired也在,逻辑也对。看启动日志时,我启用了DEBUG级别的org.springframework.beans日志,发现@Service在那个@Component初始化之前根本没被创建过。
继续追为什么@Component会那么早创建。原来有一个@Configuration类里定义了一个@Bean方法initProcessor(),它内部通过new XxxComponent(...)的方式,手动实例化了一个该@Component类型的对象,而且还注入了那个@Service的null。这里的问题不是Spring的注入顺序,而是有人绕过了Spring的注入机制,用new创建了本该由容器管理的对象。容器里另一个真正的@Component反而没被用到,所以看起来是“装配顺序”出问题,本质是“对象来源不一致”。
解决方案很简单:在@Bean方法里不要手动new,改成把依赖作为方法参数传入,让容器注入。这样对象的创建、注入、初始化全交给容器控制,装配顺序自然就正常了。这个案例再次证明一件事:大多数“装配顺序”问题,都不是Spring顺序问题,而是你的代码有没有“按顺序走到Spring的规则里”。
另外再分享一个我从排查中总结出来的技巧:如果系统里确实有很多复杂的装配依赖,强烈建议在启动阶段打印一份Bean依赖图谱。别用复杂的工具,就在ApplicationRunner里遍历ApplicationContext.getBeanDefinitionNames(),结合BeanFactory提供的getBeanDefinition()和getDependenciesForBean(),打出一个简单的“谁依赖谁”清单。这份清单在排查启动顺序问题时,比任何类的UML图都管用。
6.3 把装配顺序变成排查肌肉记忆
最后说个感悟:真正要记住的,不是“配置、扫描、注入、初始化”这八个字,而是每一个阶段对应哪些可观测现象、哪些报错特征。看到BeanCurrentlyInCreationException,条件反射想到循环依赖;看到NoSuchBeanDefinitionException,马上确认扫描范围;看到@Value为null,先怀疑配置加载时机;看到@PostConstruct没跑,检查是否被外界手动实例化。
这套反射式的排查思路,是靠一次次线上故障喂出来的。如果你现在还没有这种感觉,建议养一个习惯:每次遇到Spring装配相关的bug,别急着改代码,先翻日志,把日志里Bean创建顺序手动写下来,然后对照本文的四阶段链路去标注“哪一步比预期提前/延后了”。坚持十来个案例,你就再也不需要靠猜了。
Spring的装配顺序就像一个流水线,每一道工序都在为下一步做准备。你把流水线的整体逻辑长在脑子里,再来写代码,就会不自觉地避开那些“绕过容器”的写法,很多诡异问题也能在写出来的那一刻就发现苗头。这个回报,值得你花一个下午把整个流程梳理一遍。