引言 / 前言

学 Spring 的时候,最容易出现一种“会用但没想通”的状态。

比如我们知道 @Component 能把对象交给容器管理,知道 @Autowired 能注入依赖,知道 Spring Boot 很“神奇”,加一个 starter 之后很多 Bean 就自动有了。但如果继续追问:

  • Spring 为什么默认把 Bean 设计成单例?
  • prototype 为什么注入到单例中后,经常表现得“不像多例”?
  • Bean 生命周期为什么要拆成“实例化 - 属性注入 - 初始化 - 销毁”几个阶段?
  • Spring Boot 自动配置到底是怎么从依赖包里把配置类“捞”出来的?

这时如果底层链路不清晰,整个 Spring 体系就会停留在“背注解”的层面。

这篇笔记我会围绕三条主线展开:

  1. Bean 作用域到底在解决什么问题。
  2. Bean 生命周期在源码层面是如何组织的。
  3. Spring Boot 自动配置为什么能做到“约定大于配置”。

笔者认为,Spring 原理最核心的不是记住几个注解,而是建立一条完整主线:容器如何创建对象、如何管理对象、如何在合适的时候把对象装配起来,以及如何自动把外部模块接入进来。


核心概念与底层图谱

1. Spring 原理的三条主线

如果把这压缩成一句话,那么 Spring 原理本质上就是三件事:

  1. 对象归谁管:Bean 如何被容器接管。
  2. 对象怎么活:Bean 从创建到销毁经历了哪些阶段。
  3. 对象怎么自动来:Spring Boot 如何自动把配置类和 Bean 导入容器。

2. 为什么“容器”是 Spring 的核心抽象

传统 Java 开发里,对象通常由业务代码自己 new 出来。这样做的问题在于:

  • 对象创建逻辑分散,维护成本高。
  • 依赖关系嵌套严重,测试困难。
  • 生命周期完全由业务代码控制,不利于统一扩展。

Spring 的做法是把“对象的创建权、依赖装配权、生命周期管理权”统一收拢到容器里。于是业务代码只关心“我要什么”,而不再关心“它怎么创建、什么时候创建、依赖从哪来”。

这就是 IoC 的本质:不是 Spring 帮我们创建对象这么简单,而是对象控制权从业务代码反转给了容器。

3. 核心概念思维导图

Spring 原理

Bean 管理

Bean 定义

"@Component"

"@Bean"

"@Configuration"

Bean 获取

"ApplicationContext"

"BeanFactory"

依赖注入

"@Autowired"

"Setter 注入"

"构造器注入"

Bean 作用域

singleton

prototype

request

session

application

websocket

Bean 生命周期

实例化

属性赋值

Aware 回调

初始化前处理

初始化方法

初始化后处理

使用

销毁

Spring Boot 自动配置

"@SpringBootApplication"

"@EnableAutoConfiguration"

"@Import"

"AutoConfigurationImportSelector"

"AutoConfiguration.imports"

"@Conditional"

"@AutoConfigurationPackage"

4. @SpringBootApplication 为什么是总入口

自动配置的源码入口落在 @SpringBootApplication 上,这是非常关键的观察。

它不是一个普通注解,而是一个组合注解,背后至少包含三层职责:

组成注解作用本质含义
@SpringBootConfiguration标记当前类是配置类本质上就是 @Configuration
@ComponentScan扫描当前包及子包让项目自己的组件进容器
@EnableAutoConfiguration开启自动配置让依赖里的配置类有机会进容器

所以,Spring Boot 的启动类并不只是“程序入口”,它还是整个容器装配策略的入口。


深度机制 / 架构解析

1. Bean 作用域:Spring 为什么默认选择单例

1.1 什么是作用域

Bean 的作用域,本质上是在回答一个问题:

同一个 BeanDefinition,在容器里应该生成几个对象实例,以及这些实例应该在多大的上下文范围内共享。

这不是语法问题,而是对象管理策略问题。

1.2 默认单例的设计动机

Spring 默认作用域是 singleton,不是偶然,而是典型的工程性选择:

  1. 大部分 Service / Repository 天然无状态
    无状态对象共享最安全,复用成本最低。
  2. 减少对象创建开销
    对象频繁创建会增加 GC 压力,也增加启动和运行成本。
  3. 便于统一管理
    容器只维护一份实例,依赖关系更稳定。

我自己的理解是:Spring 默认单例,不是为了“省内存”这么简单,而是因为企业应用里绝大多数基础组件本来就应该是“可复用、无状态、线程安全”的。

1.3 六种作用域的本质区别

这里列出了 6 种作用域,其中后 4 种主要在 Web 环境中使用。

作用域生命周期边界是否默认适合什么对象
singleton整个 IoC 容器无状态业务组件
prototype每次获取都创建新对象临时对象、状态对象
request单次 HTTP 请求与请求强绑定的数据载体
session单个 HTTP Session会话级状态对象
applicationServletContext 级别整个 Web 应用共享对象
websocket单个 WebSocket 会话WebSocket 会话状态
1.4 prototype 的一个高频误区

有一个很值得注意的现象:

  • applicationContext.getBean("prototypeDog") 每次拿到的是新对象。
  • @Autowired private Dog prototypeDog; 注入到单例 Controller 后,这个字段在容器启动时就已经注入完成了。

这意味着:

多例 Bean 注入到单例 Bean 的字段里,并不会在每次使用时自动重新创建。

原因很简单:

  1. 单例 Bean 只创建一次。
  2. 创建单例 Bean 时,依赖注入也只发生一次。
  3. 所以那个 prototype 依赖只会在注入当下创建一次。

这也是为什么很多人“明明配了多例,却感觉还是单例”。

1.5 为什么 request/session/application 作用域需要代理

这几个注解等价于带 proxyMode@Scope(...),而且通常是 ScopedProxyMode.TARGET_CLASS

这里的底层原因非常重要。

假设一个单例 Controller 依赖了一个 request 作用域 Bean:

  • Controller 在应用启动时创建。
  • requestBean 却只有请求到来时才应该存在。

这时生命周期不一致,容器无法在启动阶段直接把“真实 request 对象”塞进去。怎么办?

Spring 的做法是:

  1. 先注入一个代理对象
  2. 每次真正调用这个代理时,再根据当前请求上下文去找对应的真实 Bean。

这就是“作用域代理”的价值。

单例 Bean 创建

注入 request/session Bean

真实对象此时是否稳定存在?

注入作用域代理 Proxy

运行期收到请求

Proxy 根据当前上下文定位真实 Bean

调用真实对象

这类代理本质上不是为了 AOP,而是为了解决“依赖注入时机”和“真实对象生命周期”不一致的问题。


2. Bean 生命周期:Spring 如何把对象从“出生”管理到“销毁”

2.1 生命周期不是概念图,而是容器扩展点总表

Bean 生命周期总结为 5 大阶段:

  1. 实例化
  2. 属性赋值
  3. 初始化
  4. 使用
  5. 销毁

但如果只背这五个词,其实还不够。更关键的是:Spring 为什么要把生命周期拆得这么细?

因为只有拆细,框架和开发者才能在不同阶段插入扩展逻辑。

比如:

  • 实例化前,可以让代理直接替代目标对象。
  • 属性注入后,可以完成依赖装配。
  • 初始化前后,可以执行增强逻辑。
  • 销毁前,可以做资源释放。
2.2 生命周期主流程

容器准备创建 Bean

实例化 createBeanInstance()

属性赋值 populateBean()

Aware 回调 invokeAwareMethods()

初始化前置处理 BeanPostProcessor#postProcessBeforeInitialization

初始化方法 invokeInitMethods()

初始化后置处理 BeanPostProcessor#postProcessAfterInitialization

Bean 进入可用状态

业务使用 Bean

容器关闭

销毁回调 @PreDestroy / destroy-method / DisposableBean

2.3 源码级主入口:doCreateBean

源码主线的入口落在:

AbstractAutowireCapableBeanFactory#doCreateBean

它最值得记住的不是具体代码,而是三个关键方法:

源码方法所属阶段作用
createBeanInstance()实例化创建对象本体
populateBean()属性赋值完成依赖注入
initializeBean()初始化执行 Aware、初始化、后置处理

这三个方法几乎就是“Bean 生命周期骨架”。

2.4 initializeBean() 为什么是理解 Spring 扩展能力的关键

沿着源码继续往下看 initializeBean(),会发现这里非常值得深入,因为它集中了 Spring 最核心的扩展机制:

  1. invokeAwareMethods(beanName, bean)
  2. applyBeanPostProcessorsBeforeInitialization(...)
  3. invokeInitMethods(...)
  4. applyBeanPostProcessorsAfterInitialization(...)

这意味着初始化阶段至少分成三层:

阶段典型能力解决的问题
Aware 回调BeanNameAwareBeanFactoryAware让 Bean 感知容器环境
初始化前后处理BeanPostProcessor给框架留增强入口
初始化方法@PostConstructinit-method给开发者执行业务初始化逻辑
2.5 为什么 Aware 是“感知”,不是“依赖注入”

很多初学者容易把 Aware@Autowired 混在一起。

两者区别很大:

对比项@AutowiredAware 接口
本质注入依赖容器回调
目标拿到某个 Bean感知 Bean 名称、工厂、上下文等运行环境
控制权容器根据依赖关系注入容器在生命周期特定阶段主动调用

所以 Aware 更像是:“Bean,如果你想知道自己身处哪个容器、自己叫什么名字、工厂是谁,可以在这里告诉你。”

2.6 BeanPostProcessor 为什么重要

如果说 @PostConstruct 是开发者给自己这个 Bean 写初始化逻辑,那么 BeanPostProcessor 更像是:

框架对“所有 Bean”进行统一加工的总入口。

它最大的价值有两个:

  1. 可以在初始化前后统一处理 Bean。
  2. 可以返回代理对象,从而替换原始 Bean。

AOP、事务、部分自动代理能力,本质上都离不开这条扩展链。

createBean() 前半段,还有一个非常重要的分支:

  • resolveBeforeInstantiation(beanName, mbdToUse)

这一步说明:Spring 甚至允许在正常实例化前,就由后处理器直接返回一个替代对象。

这也是为什么 Spring 能够灵活织入代理,而不是死板地永远先创建“原始对象”。

2.7 销毁阶段为什么不能忽视

销毁常常是最容易被忽略的阶段,但它是资源安全的最后一道防线。

典型场景包括:

  • 关闭线程池
  • 释放连接
  • 清理缓存
  • 刷新落盘数据

常见销毁方式:

方式特点
@PreDestroy注解简单直接
DisposableBean接口方式,侵入性略强
destroy-method配置方式,适合外部 Bean

一个成熟系统不仅要“能启动”,也要“能优雅退出”。从这个角度看,Bean 销毁并不是边角知识,而是稳定性知识。


3. 自动配置原理:Spring Boot 为什么能“引入依赖即生效”

3.1 自动配置到底解决了什么痛点

在没有 Spring Boot 自动配置之前,开发者如果引入第三方能力,通常需要自己做三件事:

  1. 找到对方提供的配置类。
  2. 手动导入或扫描这些配置类。
  3. 再根据需要声明 Bean。

问题在于,使用者最不了解第三方内部细节,却被迫承担装配责任。

这显然不合理。

Spring Boot 的自动配置,就是把“如何把自己接进 Spring 容器”这件事,交回给第三方依赖自己完成。

3.2 从一个典型示例看三种导入思路

这里先用一个第三方包 com.bite.autoconfig 举例,说明“为什么扫描不到外部 Bean”,然后一步步引出 Spring Boot 的设计思路。

方案一:@ComponentScan

优点是直观,缺点也明显:

  • 使用者必须知道要扫描哪个包。
  • 第三方依赖一多,启动类会堆满扫描路径。
  • 可维护性差。
方案二:@Import(具体类)

相比扫描更精确,但问题仍然存在:

  • 需要使用者知道要导入哪些类。
  • 类一多,配置依然膨胀。
方案三:@Import(ImportSelector 实现类)

这已经很接近 Spring Boot 了,因为:

  1. 使用者只导入一个入口。
  2. 入口内部由第三方决定到底要导入哪些类。

再往前走一步,就是我们经常看到的 @EnableXxx 模式。

3.3 @EnableXxx 的设计哲学

@EnableBiteConfig 这种注解形式,本质上是:

  1. 对外暴露一个语义明确的功能开关。
  2. 内部通过 @Import(...) 隐藏复杂导入细节。

这是一种非常优雅的封装:

  • 使用者不需要知道内部有哪些 Bean。
  • 第三方依赖可以自己维护导入清单。
  • 功能边界更清晰。

很多 Spring 扩展能力本质上都遵循这个思路。

3.4 @SpringBootApplication 到底做了什么

自动配置真正的入口链路如下:

@SpringBootApplication

@EnableAutoConfiguration

@ComponentScan

@SpringBootConfiguration

@Import(AutoConfigurationImportSelector.class)

@AutoConfigurationPackage

selectImports()

getAutoConfigurationEntry()

getCandidateConfigurations()

读取 AutoConfiguration.imports / spring.factories

@Conditional 条件过滤

自动配置类进入 IoC 容器

注册启动类所在包为基础自动配置包

扫描启动类所在包及其子包中的组件

这个流程要分成两半理解。

3.5 第一半:把“候选自动配置类名单”找出来

继续顺着源码往下看,关键类是:

AutoConfigurationImportSelector

核心方法链路:

  1. selectImports()
  2. getAutoConfigurationEntry()
  3. 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 在干什么

继续往下看,还会看到:

  • @AutoConfigurationPackage
  • AutoConfigurationPackages.Registrar

它的职责不是加载第三方自动配置类,而是把启动类所在包登记为自动配置基础包。

这也是为什么 Spring Boot 官方一直强调:

启动类最好放在项目根包下。

否则就会出现扫描不到自己组件的问题。

这里要特别区分两个动作:

机制负责什么
@ComponentScan扫描当前项目自己的组件
@EnableAutoConfiguration导入第三方依赖提供的自动配置类
@AutoConfigurationPackage记录启动类所在包,供自动配置按需使用
3.8 自动配置不是“魔法”,而是约定 + 导入 + 条件判断

如果把整个自动配置压缩成一句话:

Spring Boot 在启动时,通过 @EnableAutoConfiguration 导入一个选择器,这个选择器去依赖包的约定文件里读取候选配置类,再结合 @Conditional 做过滤,最后把满足条件的配置类注册进 IoC 容器。

这件事一旦想明白,Spring Boot 的“神奇感”就会消失,取而代之的是很清晰的工程结构感。


核心对比与避坑指南

1. Bean 作用域对比

维度singletonprototyperequest/session/application
创建时机容器启动或首次获取每次获取时由 Web 上下文驱动
是否共享实例在各自上下文内共享
是否适合保存状态通常不建议可以视上下文而定
常见问题线程安全注入到单例后不再动态变化生命周期依赖 Web 环境

2. 生命周期扩展点对比

扩展方式所处阶段典型用途注意点
构造器实例化创建对象本体不要依赖注入尚未完成的字段
@Autowired / Setter属性赋值装配依赖只负责依赖绑定
BeanNameAware初始化前感知容器信息属于回调,不是普通注入
@PostConstruct初始化自定义准备逻辑适合轻量初始化
BeanPostProcessor初始化前后统一增强、代理包装框架能力核心扩展点
@PreDestroy销毁前释放资源只在容器正常关闭时可控执行

3. 三种 Bean 导入思路对比

方案优点缺点是否接近 Spring Boot
@ComponentScan简单直接依赖使用者知道扫描路径
@Import(类)精确导入类多时繁琐部分接近
@Import(ImportSelector) + @EnableXxx使用友好、可封装需要框架维护导入逻辑

4. 自动配置链路中的几个高频考点

考点正确认知
为什么启动类通常放根包下因为 @ComponentScan 默认从启动类所在包开始扫描
自动配置类从哪来来自依赖 JAR 中的 AutoConfiguration.importsspring.factories
为什么不是所有自动配置都生效因为还要经过 @Conditional 条件过滤
@SpringBootApplication 是什么组合注解,不是单一能力注解

5. 实践中最容易踩的坑

5.1 把单例 Bean 当成“天然线程安全”

单例只意味着“只有一个实例”,不意味着“并发安全”。

如果单例 Bean 内保存可变成员变量,而且这些变量和请求有关,就可能引发线程安全问题。

高并发系统里,单例 Bean 最稳妥的写法是尽量保持无状态。

5.2 误以为 prototype 注入进单例后会次次创建

并不会次次创建。

如果确实要在单例中反复拿到新的多例对象,应该使用延迟获取策略,例如让容器在运行时再取,而不是在单例初始化时一次性注入完成。

5.3 不了解代理,导致看不懂 request/session Bean

很多 Web 作用域 Bean 实际注入进去的是代理对象,不理解这一点,就容易在调试时对对象类型、调用链路产生困惑。

5.4 把自动配置理解成“无脑加载”

自动配置从来不是粗暴注册所有 Bean,它的核心恰恰是“按条件生效”。

如果项目里某个配置没生效,优先排查的应该是:

  1. 对应 starter 是否引入。
  2. 条件注解是否满足。
  3. 是否被用户自定义 Bean 覆盖。
  4. 包扫描路径是否正确。
5.5 不区分“项目内组件扫描”和“第三方自动配置导入”

这是理解 Spring Boot 时非常容易混淆的一点:

  • 自己写的 Controller / Service 主要依赖 @ComponentScan
  • 第三方依赖里的配置类主要依赖 @EnableAutoConfiguration

两者都能把 Bean 放进容器,但来源机制完全不同。


💡 总结与反思

这节 Spring 博客虽然篇幅不算长,但其实已经把 Spring 容器最关键的三根骨架交代出来了:

  1. Bean 作用域解决的是“对象该共享到什么程度”的问题。
  2. Bean 生命周期解决的是“对象在容器里如何被精细管理”的问题。
  3. 自动配置机制解决的是“第三方能力如何低成本接入”的问题。

笔者认为,真正把 Spring 学明白,标志不是会不会写 @Autowired,而是看到一个 Bean 时,脑子里能立刻反推出:

  • 它是谁注册进容器的;
  • 它在什么作用域里生存;
  • 它会经历哪些生命周期钩子;
  • 它为什么会在当前项目里自动生效;
  • 它如果没生效,最可能卡在哪个条件上。

当我们能顺着这条链路去读源码、排问题、做扩展时,Spring 对我们来说就不再是“黑盒框架”,而会变成一套高度可解释、可推理、可调试的工程体系。

最后用一句话收尾:Spring 的强大,不在于它帮我们少写了多少代码,而在于它把对象管理、扩展机制和模块装配做成了一套统一且可演化的基础设施。

更多推荐