做Java开发的朋友,几乎天天跟Spring打交道。但很多人背了不少面试题,真正到了排查问题的时候,还是搞不懂Bean到底是怎么被创建出来的。今天我就把Spring的装配顺序完整地拆一遍:配置 → 扫描 → 注入 → 初始化。这不是照本宣科,而是结合我自己实际调试项目时踩过的坑,让你不光能理解这条链路,还能在处理启动报错、依赖失效、循环依赖这些问题时,直接按这条链路定位原因。
这条装配顺序,本质上就是Spring容器从无到有、从拿到配置到产出完整对象的过程。搞懂它,你就能回答清楚“Spring启动时到底干了什么”这个高频追问,也能解释为什么有些注解放在某些位置就是不生效,为什么构造器注入比字段注入更推荐,为什么三级缓存能解决循环依赖。全文我会用我自己项目的真实场景来讲,尽量少讲空泛概念,多讲“你写下的每一行配置和注解,最后都变成了哪一步操作”。
1. 装配顺序的整体逻辑与启动入口
1.1 一条完整的主线:配置先于扫描,注入先于初始化
Spring的装配顺序,不是四件事毫无关系的并列,而是环环相扣的流水线。我用一个生活化的类比:开一家餐厅。配置就是“制定菜单和采购计划”,扫描就是“盘点仓库里有哪些食材”,注入就是“按每道菜的配方把食材配齐”,初始化就是“把配好的菜下锅炒熟端出来”。每一步都依赖前面一步的产出结果。
落在代码层面,这条主线的关键节点如下:
- 配置:读取配置类、XML、properties/YAML,产出一个或多个BeanDefintion(Bean的定义信息,包括类名、作用域、依赖关系、初始化方法等)。
- 扫描:根据配置指定的路径,把带有@Component、@Service、@Repository、@Controller等注解的类找出来,注册成BeanDefinition。
- 注入:根据BeanDefinition里的依赖关系,创建Bean实例并填充属性,也就是把各个Bean之间的引用关系建立起来。
- 初始化:在属性填充完毕后,调用各种初始化回调(@PostConstruct、InitializingBean等),让Bean进入可用状态。
这个顺序是刚性的,Spring不会乱序执行。比如一个Bean的初始化方法里需要用到另一个Bean的属性,如果注入阶段没完成,属性就是null,初始化一调用必然空指针。所以Spring的设计铁律就是“先注入、后初始化”。
1.2 启动入口:从refresh()看容器启动的全貌
实际观察这个顺序,最直接的方式是看Spring容器的refresh()方法。不管是ApplicationContext还是Spring Boot的启动过程,最终都会走到AbstractApplicationContext.refresh()。这个方法里,Spring刻意安排好了步骤,我摘出跟装配顺序直接相关的几个关键步骤:
public void refresh() throws BeansException, IllegalStateException { // 1. 配置阶段:准备BeanFactory,读取配置 obtainFreshBeanFactory(); // 2. 扫描阶段:调用BeanFactoryPostProcessor,执行扫描逻辑 invokeBeanFactoryPostProcessors(beanFactory); // 3. 注册BeanPostProcessor(为注入和初始化做准备) registerBeanPostProcessors(beanFactory); // 4. 初始化所有非懒加载的单例Bean finishBeanFactoryInitialization(beanFactory); }finishBeanFactoryInitialization里就是逐条执行“创建Bean → 注入 → 初始化”的过程:
protected void finishBeanFactoryInitialization(ConfigurableListableBeanFactory beanFactory) { // 预实例化所有非懒加载的单例 beanFactory.preInstantiateSingletons(); }而preInstantiateSingletons里,遍历所有BeanDefinition,调用getBean()。getBean()才是真正执行“创建→注入→初始化”的入口。
你看,refresh()本身就是一条“配置 → 扫描 → 注入 → 初始化”的主线。理解这个顺序,再看源码就不会迷路。很多人在排查启动慢、启动报错时,第一反应去查自己写的业务代码,其实问题往往就藏在配置和扫描这一步。
2. 配置阶段:从XML到注解的演化与核心细节
2.1 配置的三个来源:XML、注解类、Java配置类
我把配置阶段拆开讲,因为这是很多新手最容易忽略的一环。Spring的配置来源一共有三种:
- XML配置(比如applicationContext.xml):用 标签定义Bean,用 标签定义注入。
- 注解配置:配置类上加@Configuration,方法上加@Bean,配合@ComponentScan指定扫描路径。
- 配置属性:用@ConfigurationProperties或@Value读取application.yml/properties里的值。
这三种配置的核心目的一样:生成BeanDefinition。只不过XML时代靠人工写全每一个Bean,注解时代靠扫描器自动收集。我强烈建议新项目直接用Java配置类,因为你的代码会被IDE管起来,重命名、查引用都方便,XML里写错一个类名,往往要启动到一半才报错,排查成本高得多。
2.2 @Configuration与@Bean的底层机制
配置阶段有一个很多人不理解的点:@Configuration类是被CGLIB代理过的,不是原始类。这意味着你在配置类里调另一个@Bean方法,Spring会拦截这个调用,走BeanFactory去拿那个Bean,而不是真的执行一遍方法体。
举个例子:
@Configuration public class AppConfig { @Bean public A a() { return new A(b()); } @Bean public B b() { return new B(); } }如果配置类不被代理,那a()里的b()会new出一个新的B对象,这个B和容器里那个单例B不是同一个。但Spring用CGLIB改了字节码,让b()这个方法变成容器查找,所以不管你调多少次b(),拿到的都是同一个单例。
这正是配置阶段保持单例一致性的关键。如果你把@Configuration误写成@Component,就会丢掉CGLIB代理,进而可能出现“同一个类的两个不同实例”这种诡异问题。我自己就遇到过,当时排查了半天,最后发现同事把@Configuration写成了@Configuration(proxyBeanMethods=false),结果每次调用@Bean方法都新建对象,单例失效了。Spring Boot 2.2默认把proxyBeanMethods设为true,但你可以显式关闭,一旦关闭就要小心这种陷阱。
2.3 配置优先级与覆盖机制
配置阶段还决定了一个重要问题:当同一个Bean名出现多个定义时,谁生效?Spring的规则是“前者优先,但后者的某些属性可能覆盖前者”。更准确地说,Spring按顺序注册BeanDefinition,如果后注册的同名Bean会覆盖前一个,但具体行为受allowBeanDefinitionOverriding影响,默认在ApplicationContext里是允许覆盖的,不过Spring Boot默认关闭了,以防配置冲突。
这也引出一个实操建议:如果你继承了一个旧项目的配置类,又在自己的配置类里定义了同名的@Bean,一定想清楚谁会被加载。我一般用一个简单策略:把通用配置放在一个包,把业务覆盖配置放在另一个包,用@ComponentScan的includeFilters和excludeFilters精确控制扫描范围,避免同名Bean互相干掉。
3. 扫描阶段:类路径扫描与组件注册
3.1 @ComponentScan的匹配逻辑
配置做完,就轮到扫描。扫描的本质是:根据配置指定的basePackage,遍历类路径下的所有class文件,用ASM(或反射)读出注解信息,把符合条件的类注册成BeanDefinition。这里的核心关注点是“哪些类会被扫到”。
@ComponengScan几个常用属性:
- basePackages:扫描的基础包,不配的话默认从配置类所在包开始扫。
- includeFilters:过滤出符合条件的类,允许额外包含。
- excludeFilters:排除符合条件的类。
- lazyInit:是否懒加载。
Spring Boot里,@SpringBootApplication自带@ComponentScan,扫描范围就是主启动类所在包及其子包。这也是很多新人遇到“我建的Bean不在主包下面,注入不了”的原因。不是Spring懒,是根本没去那扫。
我自己的习惯是把启动类放在“项目根包”下,比如com.example.project。所有业务子包都放在这个根包下,这样扫描路径天然覆盖整个项目。如果确实要处理外部依赖里的组件类,用includeFilters配上自定义注解,比一层一层去写basePackages更干净。
3.2 BeanDefinition的注册与后处理
扫描完成后,Spring不会立刻创建对象,而是把每个类的元数据存成BeanDefinition,放入BeanFactory的一个Map里。这时候还没发生“注入”,只是登记信息。如果你需要修改Bean定义,可以趁这个时机用BeanFactoryPostProcessor或BeanDefinitionRegistryPostProcessor。
举个例子,假设你想给所有标记了@Special的类偷偷加一个属性,可以让BeanDefinitionRegistryPostProcessor在扫描后遍历BeanDefinition,调用setPropertyValues()塞进去。这个操作必须在“注入阶段”之前做,否则属性都开始填充了,你还能改定义那就乱套了。
3.3 扫描阶段的常见坑:重复注册、漏扫、包名错误
扫描阶段我最常踩的坑有三个:
- 重复注册:同一个包被扫到两次,导致同名Bean冲突。常见于Spring Boot引入多个自动配置模块时,同一个@Repository被扫了N遍。解决方式是用excludeFilters或者调整包路径。
- 漏扫:启动类所在包不是根包,导致有些组件没被扫到。最直观的报错就是“Consider defining a bean...”。
- 包名错误:手滑把包名写错,扫描器不会报错,因为路径不存在它就当没扫到,但你的Bean就是注入不进来。
排查扫描问题,最简单的方法是在启动日志里打开debug:spring.main.log-startup-info=true,然后观察日志里“Scanning for components in...”这段输出,一眼就能看出扫描了哪些包。
4. 注入阶段:依赖注入的实现细节与选型
4.1 三种注入方式的对比
扫描完成,BeanDefinition都注册好之后,就开始真正创建对象和注入依赖。注入方式有三种,我在这里做一个对比表,方便你看懂差异:
| 注入方式 | 写法示例 | 优点 | 缺点 | 我的推荐度 |
|---|---|---|---|---|
| 构造器注入 | public A(B b) { this.b = b; } | 不可变,易于测试,强制依赖前置 | 依赖过多时构造器参数太难看 | 最推荐 |
| Setter注入 | @Autowired setB(B b) | 允许重新配置 | 对象可能处于未初始化状态 | 一般 |
| 字段注入 | @Autowired private B b; | 代码简洁 | 难测试,导致循环依赖隐蔽 | 不推荐 |
构造器注入有一个特性:它可以避免循环依赖。因为如果A构造时需要B,B构造时需要A,Spring能在初始化阶段就发现循环依赖并直接报错,不会等你运行到一半才发现。但字段注入则会借助三级缓存去“强行”解决,导致循环依赖被隐藏。
4.2 自动注入的解析流程:@Autowired是怎么找到Bean的
@Autowired的解析逻辑,是基于类型查找的。具体流程是:
- Spring拿到被标记的字段/构造器/方法参数的类型。
- 在BeanFactory里查找所有匹配该类型的Bean候选。
- 如果只有一个候选,直接注入。
- 如果有多个候选,再看@Qualifier或@Primary是否指定了唯一候选。
- 如果还没有,且字段名恰好匹配一个Bean名,也可以命中(相当于byName兜底)。
- 一个都找不到就报“NoSuchBeanDefinitionException”。
理解这个流程后,你就可以解释一个经典问题:为什么接口有两个实现类时,@Autowired会报错?因为第3步只有一个候选这个条件不满足,而你又没指定@Qualifier。
我建议编码时直接用构造器注入+@Qualifier,把依赖关系写得明明白白。不要偷懒用字段注入,你写的时候是爽了,后面每次Mock测试都头疼。
4.3 三级缓存与循环依赖:Spring的“救命”机制
注入阶段绕不开循环依赖问题。A依赖B,B依赖A,怎么办?Spring用三级缓存解决,但只对“单例+字段/Setter注入”生效,构造器注入不管。
三级缓存是三个Map:
- singletonObjects:一级缓存,存放完全初始化好的单例。
- earlySingletonObjects:二级缓存,存放提前暴露的、尚未完成全部属性注入但已经创建了对象引用的单例。
- singletonFactories:三级缓存,存放ObjectFactory,这个工厂能产出早期引用。
整个流程简化如下:
- 创建A的实例,放入三级缓存(ObjectFactory)。
- A的属性注入需要B,于是去创建B。
- B的创建过程中,需要A,于是从三级缓存拿到A的早期引用(相当于拿到一个“还没完全搞定但引用地址已经确定”的A)。
- B完成创建,A拿到B的引用继续完成自己。
这套机制能解决字段注入和Setter注入的循环依赖,但它不是设计初衷,只是一种补偿机制。我更推荐的做法是,从设计上避免循环依赖,比如拆分成更细的组件,或者用事件解耦。
5. 初始化阶段:Bean的生命周期钩子
5.1 初始化回调的执行顺序
注入完成,所有属性都填充好了,但对象还没“正式上线”。初始化阶段会依次执行下面这些回调,顺序很严格:
- BeanNameAware、BeanClassLoaderAware、BeanFactoryAware等Aware接口回调。
- BeanPostProcessor的postProcessBeforeInitialization。
- @PostConstruct注解的方法。
- InitializingBean的afterPropertiesSet方法。
- 自定义init-method。
- BeanPostProcessor的postProcessAfterInitialization。
注意@PostConstruct排在InitializingBean之前,这个顺序有时候很关键。如果你在@PostConstruct里想用某个被AOP代理的Bean,可能拿到的是原始对象而不是代理对象,因为AOP代理的创建通常发生在postProcessAfterInitialization阶段。
5.2 初始化的实际业务场景
初始化阶段最常见的用法是:校验配置、预热连接、加载缓存。我建议把这类逻辑放在@PostConstruct里,因为它最直观,而且支持你在测试时轻松绕过初始化。
比如,一个邮件服务模块,要在启动时检查SMTP配置是否完整:
@Component public class EmailSender { private MailProperties props; public EmailSender(MailProperties props) { this.props = props; } @PostConstruct public void validateConfig() { if (props.getHost() == null || props.getPort() == 0) { throw new IllegalStateException("邮件服务配置缺失"); } // 预热连接池 this.connection = connect(); } }这里如果配置不对,启动直接失败,而不是等运行时发邮件才发现。很多人不写这种校验,结果部署到线上才发现配置漏了,白白背锅。
5.3 初始化的故障排查
初始化阶段出问题,特征很明显:启动日志打印一堆Bean创建记录,然后突然抛异常。最常见的原因包括:
- @PostConstruct方法里抛了异常,导致Bean创建失败。
- 初始化方法依赖了尚未初始化的其他Bean,但那个Bean是懒加载,此时还没创建。
- 循环依赖在初始化阶段暴露,比如A初始化时调用了B的业务方法,但B此时才创建到一半。
排查时,打开debug日志看“Creating bean”和“Finished creating bean”的字样,就能定位是哪个Bean在哪个阶段失败。如果日志里只有“Creating bean”没有“Finished”,说明这个Bean就死在了中间某个环节。
6. 常见问题与排查技巧实录
6.1 “Consider defining a bean”类报错怎么破
这是我帮同事排查次数最多的一类报错。报这个错,本质上是注入阶段找不到匹配的Bean。按我的经验,按以下顺序排查:
- 确认类上有没有@Component及其派生注解。有时候你把@Configuration当成@Component用,但没加@Bean,自然会漏。
- 确认扫描路径有没有包住目标类的位置。启动类所在的包是不是根包。
- 确认Bean名是否冲突。如果两个类都叫同一个名字(默认是类名首字母小写),后注册的会覆盖前一个,导致注入时拿到不期望的类。
- 确认是否自定义了有没有显式的@Qualifier或@Primary。
6.2 循环依赖的排查与修复
循环依赖报错信息里会直接列出循环链:A → B → A。最简单粗暴的修复方式是“破坏链条”:把A对B的依赖改成弱依赖,例如通过事件、回调或从ApplicationContext里延迟获取。
如果非要保留依赖,也可以用@Lazy注解标注其中一个依赖,让Spring创建个代理,等真正使用时再解析。实测下来,@Lazy能立刻让启动不报错,但我只把它当临时方案,长期还是建议重构。
6.3 Bean初始化顺序混乱的实用对策
有时候,你的两个Bean互相之间没有直接依赖,但要求A必须在B之前初始化。这时候最土的办法是在A的@PostConstruct里调用B的方法,导致B被提前创建;但更优雅的方式是用@DependsOn注解,显式声明A依赖B的初始化。
@Component @DependsOn("b") public class A { ... } @Component public class B { ... }这样Spring会保证B先完成配置、扫描、注入、初始化,然后再轮到A。这个注解我几乎只用在“基础设施类依赖”场景,比如A依赖一个缓存预热类B,B没把缓存装好,A起来也没有意义。
我在实际项目中总结过一个排查启动慢的通用路径:先看配置阶段有没有多余的BeanDefinition,再看扫描阶段有没有扫出不期望的类,再看注入阶段有没有隐藏的循环依赖,最后看初始化阶段有没有人为阻塞逻辑。这条路径每一步都能在日志里找到痕迹,只要你耐住性子,从refresh()一步步往下看,就没有定位不了的问题。
最后再分享一个小技巧,无论你用的是XML配置还是纯注解,建议在所有关键类里打上“创建顺序”的日志。比如在构造器里打印类名和时间戳,在@PostConstruct里再打一次。看似不起眼,但排查各类“微妙依赖”时,这种日志能帮你在三分钟内还原出真实的装配顺序,胜过看十遍源码。