从 Bean 到自动装配,我终于把 Spring 容器“启动时到底做了什么”看懂了
引言 / 前言
学 Spring 的时候,最容易出现一种“会用但没想通”的状态。
比如我们知道 @Component 能把对象交给容器管理,知道 @Autowired 能注入依赖,知道 Spring Boot 很“神奇”,加一个 starter 之后很多 Bean 就自动有了。但如果继续追问:
- Spring 为什么默认把 Bean 设计成单例?
prototype为什么注入到单例中后,经常表现得“不像多例”?- Bean 生命周期为什么要拆成“实例化 - 属性注入 - 初始化 - 销毁”几个阶段?
- Spring Boot 自动配置到底是怎么从依赖包里把配置类“捞”出来的?
这时如果底层链路不清晰,整个 Spring 体系就会停留在“背注解”的层面。
这篇笔记我会围绕三条主线展开:
- Bean 作用域到底在解决什么问题。
- Bean 生命周期在源码层面是如何组织的。
- Spring Boot 自动配置为什么能做到“约定大于配置”。
笔者认为,Spring 原理最核心的不是记住几个注解,而是建立一条完整主线:容器如何创建对象、如何管理对象、如何在合适的时候把对象装配起来,以及如何自动把外部模块接入进来。
核心概念与底层图谱
1. Spring 原理的三条主线
如果把这压缩成一句话,那么 Spring 原理本质上就是三件事:
- 对象归谁管:Bean 如何被容器接管。
- 对象怎么活:Bean 从创建到销毁经历了哪些阶段。
- 对象怎么自动来:Spring Boot 如何自动把配置类和 Bean 导入容器。
2. 为什么“容器”是 Spring 的核心抽象
传统 Java 开发里,对象通常由业务代码自己 new 出来。这样做的问题在于:
- 对象创建逻辑分散,维护成本高。
- 依赖关系嵌套严重,测试困难。
- 生命周期完全由业务代码控制,不利于统一扩展。
Spring 的做法是把“对象的创建权、依赖装配权、生命周期管理权”统一收拢到容器里。于是业务代码只关心“我要什么”,而不再关心“它怎么创建、什么时候创建、依赖从哪来”。
这就是 IoC 的本质:不是 Spring 帮我们创建对象这么简单,而是对象控制权从业务代码反转给了容器。
3. 核心概念思维导图
4. @SpringBootApplication 为什么是总入口
自动配置的源码入口落在 @SpringBootApplication 上,这是非常关键的观察。
它不是一个普通注解,而是一个组合注解,背后至少包含三层职责:
| 组成注解 | 作用 | 本质含义 |
|---|---|---|
@SpringBootConfiguration | 标记当前类是配置类 | 本质上就是 @Configuration |
@ComponentScan | 扫描当前包及子包 | 让项目自己的组件进容器 |
@EnableAutoConfiguration | 开启自动配置 | 让依赖里的配置类有机会进容器 |
所以,Spring Boot 的启动类并不只是“程序入口”,它还是整个容器装配策略的入口。
深度机制 / 架构解析
1. Bean 作用域:Spring 为什么默认选择单例
1.1 什么是作用域
Bean 的作用域,本质上是在回答一个问题:
同一个 BeanDefinition,在容器里应该生成几个对象实例,以及这些实例应该在多大的上下文范围内共享。
这不是语法问题,而是对象管理策略问题。
1.2 默认单例的设计动机
Spring 默认作用域是 singleton,不是偶然,而是典型的工程性选择:
- 大部分 Service / Repository 天然无状态
无状态对象共享最安全,复用成本最低。 - 减少对象创建开销
对象频繁创建会增加 GC 压力,也增加启动和运行成本。 - 便于统一管理
容器只维护一份实例,依赖关系更稳定。
我自己的理解是:Spring 默认单例,不是为了“省内存”这么简单,而是因为企业应用里绝大多数基础组件本来就应该是“可复用、无状态、线程安全”的。
1.3 六种作用域的本质区别
这里列出了 6 种作用域,其中后 4 种主要在 Web 环境中使用。
| 作用域 | 生命周期边界 | 是否默认 | 适合什么对象 |
|---|---|---|---|
singleton | 整个 IoC 容器 | 是 | 无状态业务组件 |
prototype | 每次获取都创建新对象 | 否 | 临时对象、状态对象 |
request | 单次 HTTP 请求 | 否 | 与请求强绑定的数据载体 |
session | 单个 HTTP Session | 否 | 会话级状态对象 |
application | ServletContext 级别 | 否 | 整个 Web 应用共享对象 |
websocket | 单个 WebSocket 会话 | 否 | WebSocket 会话状态 |
1.4 prototype 的一个高频误区
有一个很值得注意的现象:
applicationContext.getBean("prototypeDog")每次拿到的是新对象。- 但
@Autowired private Dog prototypeDog;注入到单例 Controller 后,这个字段在容器启动时就已经注入完成了。
这意味着:
多例 Bean 注入到单例 Bean 的字段里,并不会在每次使用时自动重新创建。
原因很简单:
- 单例 Bean 只创建一次。
- 创建单例 Bean 时,依赖注入也只发生一次。
- 所以那个
prototype依赖只会在注入当下创建一次。
这也是为什么很多人“明明配了多例,却感觉还是单例”。
1.5 为什么 request/session/application 作用域需要代理
这几个注解等价于带 proxyMode 的 @Scope(...),而且通常是 ScopedProxyMode.TARGET_CLASS。
这里的底层原因非常重要。
假设一个单例 Controller 依赖了一个 request 作用域 Bean:
- Controller 在应用启动时创建。
requestBean却只有请求到来时才应该存在。
这时生命周期不一致,容器无法在启动阶段直接把“真实 request 对象”塞进去。怎么办?
Spring 的做法是:
- 先注入一个代理对象。
- 每次真正调用这个代理时,再根据当前请求上下文去找对应的真实 Bean。
这就是“作用域代理”的价值。
这类代理本质上不是为了 AOP,而是为了解决“依赖注入时机”和“真实对象生命周期”不一致的问题。
2. Bean 生命周期:Spring 如何把对象从“出生”管理到“销毁”
2.1 生命周期不是概念图,而是容器扩展点总表
Bean 生命周期总结为 5 大阶段:
- 实例化
- 属性赋值
- 初始化
- 使用
- 销毁
但如果只背这五个词,其实还不够。更关键的是:Spring 为什么要把生命周期拆得这么细?
因为只有拆细,框架和开发者才能在不同阶段插入扩展逻辑。
比如:
- 实例化前,可以让代理直接替代目标对象。
- 属性注入后,可以完成依赖装配。
- 初始化前后,可以执行增强逻辑。
- 销毁前,可以做资源释放。
2.2 生命周期主流程
2.3 源码级主入口:doCreateBean
源码主线的入口落在:
AbstractAutowireCapableBeanFactory#doCreateBean
它最值得记住的不是具体代码,而是三个关键方法:
| 源码方法 | 所属阶段 | 作用 |
|---|---|---|
createBeanInstance() | 实例化 | 创建对象本体 |
populateBean() | 属性赋值 | 完成依赖注入 |
initializeBean() | 初始化 | 执行 Aware、初始化、后置处理 |
这三个方法几乎就是“Bean 生命周期骨架”。
2.4 initializeBean() 为什么是理解 Spring 扩展能力的关键
沿着源码继续往下看 initializeBean(),会发现这里非常值得深入,因为它集中了 Spring 最核心的扩展机制:
invokeAwareMethods(beanName, bean)applyBeanPostProcessorsBeforeInitialization(...)invokeInitMethods(...)applyBeanPostProcessorsAfterInitialization(...)
这意味着初始化阶段至少分成三层:
| 阶段 | 典型能力 | 解决的问题 |
|---|---|---|
| Aware 回调 | BeanNameAware、BeanFactoryAware | 让 Bean 感知容器环境 |
| 初始化前后处理 | BeanPostProcessor | 给框架留增强入口 |
| 初始化方法 | @PostConstruct、init-method | 给开发者执行业务初始化逻辑 |
2.5 为什么 Aware 是“感知”,不是“依赖注入”
很多初学者容易把 Aware 和 @Autowired 混在一起。
两者区别很大:
| 对比项 | @Autowired | Aware 接口 |
|---|---|---|
| 本质 | 注入依赖 | 容器回调 |
| 目标 | 拿到某个 Bean | 感知 Bean 名称、工厂、上下文等运行环境 |
| 控制权 | 容器根据依赖关系注入 | 容器在生命周期特定阶段主动调用 |
所以 Aware 更像是:“Bean,如果你想知道自己身处哪个容器、自己叫什么名字、工厂是谁,可以在这里告诉你。”
2.6 BeanPostProcessor 为什么重要
如果说 @PostConstruct 是开发者给自己这个 Bean 写初始化逻辑,那么 BeanPostProcessor 更像是:
框架对“所有 Bean”进行统一加工的总入口。
它最大的价值有两个:
- 可以在初始化前后统一处理 Bean。
- 可以返回代理对象,从而替换原始 Bean。
AOP、事务、部分自动代理能力,本质上都离不开这条扩展链。
在 createBean() 前半段,还有一个非常重要的分支:
resolveBeforeInstantiation(beanName, mbdToUse)
这一步说明:Spring 甚至允许在正常实例化前,就由后处理器直接返回一个替代对象。
这也是为什么 Spring 能够灵活织入代理,而不是死板地永远先创建“原始对象”。
2.7 销毁阶段为什么不能忽视
销毁常常是最容易被忽略的阶段,但它是资源安全的最后一道防线。
典型场景包括:
- 关闭线程池
- 释放连接
- 清理缓存
- 刷新落盘数据
常见销毁方式:
| 方式 | 特点 |
|---|---|
@PreDestroy | 注解简单直接 |
DisposableBean | 接口方式,侵入性略强 |
destroy-method | 配置方式,适合外部 Bean |
一个成熟系统不仅要“能启动”,也要“能优雅退出”。从这个角度看,Bean 销毁并不是边角知识,而是稳定性知识。
3. 自动配置原理:Spring Boot 为什么能“引入依赖即生效”
3.1 自动配置到底解决了什么痛点
在没有 Spring Boot 自动配置之前,开发者如果引入第三方能力,通常需要自己做三件事:
- 找到对方提供的配置类。
- 手动导入或扫描这些配置类。
- 再根据需要声明 Bean。
问题在于,使用者最不了解第三方内部细节,却被迫承担装配责任。
这显然不合理。
Spring Boot 的自动配置,就是把“如何把自己接进 Spring 容器”这件事,交回给第三方依赖自己完成。
3.2 从一个典型示例看三种导入思路
这里先用一个第三方包 com.bite.autoconfig 举例,说明“为什么扫描不到外部 Bean”,然后一步步引出 Spring Boot 的设计思路。
方案一:@ComponentScan
优点是直观,缺点也明显:
- 使用者必须知道要扫描哪个包。
- 第三方依赖一多,启动类会堆满扫描路径。
- 可维护性差。
方案二:@Import(具体类)
相比扫描更精确,但问题仍然存在:
- 需要使用者知道要导入哪些类。
- 类一多,配置依然膨胀。
方案三:@Import(ImportSelector 实现类)
这已经很接近 Spring Boot 了,因为:
- 使用者只导入一个入口。
- 入口内部由第三方决定到底要导入哪些类。
再往前走一步,就是我们经常看到的 @EnableXxx 模式。
3.3 @EnableXxx 的设计哲学
@EnableBiteConfig 这种注解形式,本质上是:
- 对外暴露一个语义明确的功能开关。
- 内部通过
@Import(...)隐藏复杂导入细节。
这是一种非常优雅的封装:
- 使用者不需要知道内部有哪些 Bean。
- 第三方依赖可以自己维护导入清单。
- 功能边界更清晰。
很多 Spring 扩展能力本质上都遵循这个思路。
3.4 @SpringBootApplication 到底做了什么
自动配置真正的入口链路如下:
这个流程要分成两半理解。
3.5 第一半:把“候选自动配置类名单”找出来
继续顺着源码往下看,关键类是:
AutoConfigurationImportSelector
核心方法链路:
selectImports()getAutoConfigurationEntry()getCandidateConfigurations()
其中最关键的是第三步,它会读取依赖包中的配置清单。
这里可以明确看到两类来源:
| 配置来源 | 作用 |
|---|---|
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports | 新版 Spring Boot 自动配置清单 |
META-INF/spring.factories | 传统约定式配置清单 |
这一步的本质是:
Spring Boot 启动时不会“猜”有哪些自动配置类,而是从各个依赖 JAR 约定好的元数据文件中读取候选名单。
3.6 第二半:不是全加载,而是“条件成立才加载”
这里也特别值得注意。
自动配置的强大,不是因为它“什么都自动配”,而是因为它:
先收集候选配置,再通过条件注解精确筛选。
最常见的控制手段就是 @Conditional 及其衍生注解,例如:
- 类路径上是否存在某个类
- 容器中是否已经有某个 Bean
- 配置文件中某个属性是否开启
- 当前是否处于某种 Web 环境
所以自动配置的真实语义不是:
- “一引依赖就强行创建所有 Bean”
而是:
- “一引依赖就提供一组可能生效的配置,满足条件时才启用”
这套设计非常克制,也非常工程化。
3.7 @AutoConfigurationPackage 在干什么
继续往下看,还会看到:
@AutoConfigurationPackageAutoConfigurationPackages.Registrar
它的职责不是加载第三方自动配置类,而是把启动类所在包登记为自动配置基础包。
这也是为什么 Spring Boot 官方一直强调:
启动类最好放在项目根包下。
否则就会出现扫描不到自己组件的问题。
这里要特别区分两个动作:
| 机制 | 负责什么 |
|---|---|
@ComponentScan | 扫描当前项目自己的组件 |
@EnableAutoConfiguration | 导入第三方依赖提供的自动配置类 |
@AutoConfigurationPackage | 记录启动类所在包,供自动配置按需使用 |
3.8 自动配置不是“魔法”,而是约定 + 导入 + 条件判断
如果把整个自动配置压缩成一句话:
Spring Boot 在启动时,通过
@EnableAutoConfiguration导入一个选择器,这个选择器去依赖包的约定文件里读取候选配置类,再结合@Conditional做过滤,最后把满足条件的配置类注册进 IoC 容器。
这件事一旦想明白,Spring Boot 的“神奇感”就会消失,取而代之的是很清晰的工程结构感。
核心对比与避坑指南
1. Bean 作用域对比
| 维度 | singleton | prototype | request/session/application |
|---|---|---|---|
| 创建时机 | 容器启动或首次获取 | 每次获取时 | 由 Web 上下文驱动 |
| 是否共享实例 | 是 | 否 | 在各自上下文内共享 |
| 是否适合保存状态 | 通常不建议 | 可以 | 视上下文而定 |
| 常见问题 | 线程安全 | 注入到单例后不再动态变化 | 生命周期依赖 Web 环境 |
2. 生命周期扩展点对比
| 扩展方式 | 所处阶段 | 典型用途 | 注意点 |
|---|---|---|---|
| 构造器 | 实例化 | 创建对象本体 | 不要依赖注入尚未完成的字段 |
@Autowired / Setter | 属性赋值 | 装配依赖 | 只负责依赖绑定 |
BeanNameAware 等 | 初始化前 | 感知容器信息 | 属于回调,不是普通注入 |
@PostConstruct | 初始化 | 自定义准备逻辑 | 适合轻量初始化 |
BeanPostProcessor | 初始化前后 | 统一增强、代理包装 | 框架能力核心扩展点 |
@PreDestroy | 销毁前 | 释放资源 | 只在容器正常关闭时可控执行 |
3. 三种 Bean 导入思路对比
| 方案 | 优点 | 缺点 | 是否接近 Spring Boot |
|---|---|---|---|
@ComponentScan | 简单直接 | 依赖使用者知道扫描路径 | 否 |
@Import(类) | 精确导入 | 类多时繁琐 | 部分接近 |
@Import(ImportSelector) + @EnableXxx | 使用友好、可封装 | 需要框架维护导入逻辑 | 是 |
4. 自动配置链路中的几个高频考点
| 考点 | 正确认知 |
|---|---|
| 为什么启动类通常放根包下 | 因为 @ComponentScan 默认从启动类所在包开始扫描 |
| 自动配置类从哪来 | 来自依赖 JAR 中的 AutoConfiguration.imports 或 spring.factories |
| 为什么不是所有自动配置都生效 | 因为还要经过 @Conditional 条件过滤 |
@SpringBootApplication 是什么 | 组合注解,不是单一能力注解 |
5. 实践中最容易踩的坑
5.1 把单例 Bean 当成“天然线程安全”
单例只意味着“只有一个实例”,不意味着“并发安全”。
如果单例 Bean 内保存可变成员变量,而且这些变量和请求有关,就可能引发线程安全问题。
高并发系统里,单例 Bean 最稳妥的写法是尽量保持无状态。
5.2 误以为 prototype 注入进单例后会次次创建
并不会次次创建。
如果确实要在单例中反复拿到新的多例对象,应该使用延迟获取策略,例如让容器在运行时再取,而不是在单例初始化时一次性注入完成。
5.3 不了解代理,导致看不懂 request/session Bean
很多 Web 作用域 Bean 实际注入进去的是代理对象,不理解这一点,就容易在调试时对对象类型、调用链路产生困惑。
5.4 把自动配置理解成“无脑加载”
自动配置从来不是粗暴注册所有 Bean,它的核心恰恰是“按条件生效”。
如果项目里某个配置没生效,优先排查的应该是:
- 对应 starter 是否引入。
- 条件注解是否满足。
- 是否被用户自定义 Bean 覆盖。
- 包扫描路径是否正确。
5.5 不区分“项目内组件扫描”和“第三方自动配置导入”
这是理解 Spring Boot 时非常容易混淆的一点:
- 自己写的
Controller/Service主要依赖@ComponentScan - 第三方依赖里的配置类主要依赖
@EnableAutoConfiguration
两者都能把 Bean 放进容器,但来源机制完全不同。
💡 总结与反思
这节 Spring 博客虽然篇幅不算长,但其实已经把 Spring 容器最关键的三根骨架交代出来了:
- Bean 作用域解决的是“对象该共享到什么程度”的问题。
- Bean 生命周期解决的是“对象在容器里如何被精细管理”的问题。
- 自动配置机制解决的是“第三方能力如何低成本接入”的问题。
笔者认为,真正把 Spring 学明白,标志不是会不会写 @Autowired,而是看到一个 Bean 时,脑子里能立刻反推出:
- 它是谁注册进容器的;
- 它在什么作用域里生存;
- 它会经历哪些生命周期钩子;
- 它为什么会在当前项目里自动生效;
- 它如果没生效,最可能卡在哪个条件上。
当我们能顺着这条链路去读源码、排问题、做扩展时,Spring 对我们来说就不再是“黑盒框架”,而会变成一套高度可解释、可推理、可调试的工程体系。
最后用一句话收尾:Spring 的强大,不在于它帮我们少写了多少代码,而在于它把对象管理、扩展机制和模块装配做成了一套统一且可演化的基础设施。
更多推荐


所有评论(0)