ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

深入理解Spring Bean:原理、装配与生命周期

深入理解Spring Bean:原理、装配与生命周期

1. 为什么Spring要在IoC容器里托管对象

接触Spring的第一课,几乎都会从Bean开始。但很多人对Bean的理解停留在“Spring帮我创建了一个对象”这种表面层次。实际上,Bean机制解决的是整个项目在构建过程中对象创建与依赖协调的全局问题,理解它,才算真正迈过了Spring的门槛。

先看一个最直观的例子。一个典型的业务请求从Controller到Service再到DAO,中间会经过好几个对象。在没有Spring的时代,你需要在每个使用方内部手动new出依赖对象,比如在UserService里直接new一个UserDaoImpl,在UserController里又new一个UserService。这样写出来的代码不是不能用,而是后续每次调整依赖关系都要在代码里到处找地方改,改错了就是运行期才暴露的NullPointerException。更麻烦的是对象之间的创建顺序一旦错了,整个初始化过程就会崩。

Spring的做法则是把这个创建过程完全收编到自己的容器里。容器负责做三件事:实例化对象、给对象填充依赖属性、在合适的时机销毁对象。你只需要告诉容器“我需要什么”,容器就从它维护的一张Bean注册表里找到对应的定义,然后走一遍完整的生命周期流程,把可用的对象交付到你手上。这个过程在行业里叫IoC(控制反转),对象不再主动找依赖,而是被动等容器注入,控制权完全反过来了。

这里有一个容易被忽略的关键点:Spring管理的Bean默认都是单例的。这是一个非常重要的设计决策。单例意味着整个容器生命周期内同一个Bean定义只创建一个实例,这个实例被所有需要它的地方共享。这跟你自己在类里用private static final写单例的模式完全不同——Spring的单例是容器级别的,它的创建时机、销毁时机、线程安全问题全部由容器统一管理,而不是你自己用双重检查锁去控制。所以如果你面试被问到Spring单例和手写单例的区别,核心就答容器生命周期管理,而不是单纯的对象数量。

第二件需要明确的事情是,Spring的Bean机制并不等于JavaBean。JavaBean强调的是符合特定规范(无参构造、getter/setter、可序列化)的普通类,Spring的Bean则是容器中一个被管理组件的统称。所有被Spring容器托管的对象——不管是Controller、Service、Mapper还是配置类、数据源对象,在容器视角里都是Bean。这个概念不要混。

2. Bean的定义与装配方式详解

2.1 XML时代还是注解时代

Spring最早的装配方式是以XML文件为核心。程序员在applicationContext.xml里写一堆<bean>标签,每个标签对应一个类的全限定名,property标签负责填充属性。这种方式在如今的项目里已经很少见了,但历史遗留项目里仍然大量存在,所以看懂XML配置依然是处理老项目的基本功。

<bean id="userDao" class="com.example.dao.impl.UserDaoImpl"/> <bean id="userService" class="com.example.service.impl.UserService"> <property name="userDao" ref="userDao"/> </bean>

XML方式的优点是集中管理、改配置不用改代码、部署时替换配置文件即可。缺点也明显:配置量随类数量线性膨胀,一个大型项目动辄几千行XML,维护成本极高,而且写错类名要等到启动时才会报错。

后来Spring引入了注解装配,彻底改变了这个局面。现在的开发模式基本都是@ComponentScan扫描包路径 +@Component系列注解标注 +@Autowired自动注入三件套。注解方式最大的价值是把Bean的类型信息从配置文件挪到了源码本身,IDE可以通过编译期检查直接暴露类名拼写错误,Java类型安全检查也提前到了编译阶段。启动时如果容器找不到对应Bean,报错信息里会指出哪个类、哪个字段缺依赖。

Spring Boot出现之后,Bean装配又进化了一层。@SpringBootApplication组合注解本身就包含了@ComponentScan、@Configuration和@EnableAutoConfiguration,开发者几乎不用手写扫描配置,框架把“约定优于配置”发挥到了极致。但对面试来讲,理解XML到注解,再到Spring Boot自动配置的演进逻辑,才能说真正吃透装配方式这层知识。

2.2 五种Bean装配方式对比

实际操作中常用的Bean装配方式有五种,我直接列个表对比,大家按场景选择。

方式核心API/注解适用场景注意事项
XML声明式<bean>标签老项目维护、框架集成需要外部化配置的场景类路径写错不会启动时报错,需仔细核对
注解扫描式@Component@Service@Repository@Controller绝大多数业务项目要注意扫描包路径范围,扫太大影响启动速度
Java配置类式@Configuration+@Bean需要编码创建对象、第三方库Bean装配@Bean方法名默认就是Bean名称,注意命名风格
条件装配@ConditionalOnXxx按环境、依赖、配置动态决定是否创建Spring Boot自动配置大量使用,不要在生产乱加自定义条件
自动配置@EnableAutoConfigurationSpring Boot项目缺省能力底层通过spring.factories和AutoConfiguration.Import汇总配置类

注解扫描方式里有一个隐藏细节,@Component、@Service、@Repository、@Controller这四种注解功能上对容器来说是等价的,都能被扫描到并注册为Bean。区分它们是为了语义化——@Repository用于数据层,@Service用于业务层,@Controller用于控制层。Spring还专门为它们附加了一些增强功能,比如@Repository的异常转换、@Controller的事务支持等,但这些增强都依赖于组件扫描路径开启了对应能力,不是用了注解就一定生效。

2.3 @Bean方法与配置类的正确打开方式

配置类方式是让Spring容器理解你的Java代码。在@Configuration标注的类里写@Bean方法,方法体里可以自己控制对象的创建过程。例如你在项目里集成Redis连接池,第三方连接池类的构造方法特别多,参数又复杂,直接交给Spring扫描不现实,这时写一个配置类最合适。

@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); return template; } }

这里要提醒一件事:@Bean方法上如果带了参数(这里是RedisConnectionFactory),Spring会自动从容器里找该类型的Bean注入进来。很多初学者会把参数写成对象然后自己手动new,那就破坏了容器管理依赖关系。正确写法永远是让Spring通过参数解析完成注入,而不是在方法内部new依赖。

还有一个坑要注意:同一个接口有多个实现类时,@Autowired按类型注入会抛出NoUniqueBeanDefinitionException。解决办法有三种:优先把其中一个实现标记为@Primary;或者给Bean起不同的名字并用@Qualifier("beanName")指定;再者可以用@Resource按名字注入。我个人的习惯是能用@Qualifier就不用@Primary,因为@Primary全局生效,在后期排查时不直观,而@Qualifier的作用范围最明确。

3. 深入Bean的生命周期与作用域

3.1 Bean的完整生命周期路线图

Bean的生命周期是Spring里最难啃又最核心的部分。理解了生命周期,才能真正看懂为什么容器能帮我们管理各种资源、为什么事务注解能生效,以及为什么某些初始化逻辑怎么执行都不对。

Bean从被容器创建到最终销毁大致经过十个阶段,我用直白的话整理一遍:

  1. 容器解析Bean定义,放到BeanFactory的注册表里,这时还没有实例化。
  2. 阶段Bean实例化。通过无参构造(或者工厂方法)创建出一个原始对象,此时属性都是默认值。
  3. 属性填充。容器把当前Bean所依赖的其他Bean注入进来,包括@Autowired标注的字段、构造器参数、配置属性。
  4. 执行Aware接口回调。Bean如果实现了BeanNameAware、BeanFactoryAware等接口,容器会把对应信息传入。
  5. 调用BeanPostProcessor的postProcessBeforeInitialization。这个阶段允许对Bean做任意前置处理,比如填充代理对象。
  6. 执行InitializingBean.afterPropertiesSet / @PostConstruct标注方法。业务自定义的初始化逻辑写在这里。
  7. 调用BeanPostProcessor的postProcessAfterInitialization。Spring的AOP就是在这里通过动态代理包装目标对象。
  8. Bean准备就绪,放入容器,可以被外部引用了。
  9. 容器关闭时,如果Bean有@PreDestroy方法或实现了DisposableBean,先执行销毁前处理。
  10. 对象变为垃圾,等待GC回收。

这里最关键的是第5步到第7步。BeanPostProcessor是Spring的扩展魔法棒,几乎所有框架级功能都插在这个环节里。比如Spring AOP的自动代理创建器就是一个BeanPostProcessor,它在postProcessAfterInitialization阶段判断当前Bean需不需要代理,需要就生成代理对象替换掉原对象。这也是为什么你在业务代码里this调用另一个方法时事务会失效——因为this指向的是原始对象而不是代理对象。

3.2 作用域决定Bean的存活方式

Spring Bean的作用域有singleton、prototype、request、session、application几种,但真正会在日常代码里遇到的主要是前两个。

singleton是默认作用域。整个容器里只有一个实例,所有引用方共享这个对象。这个设计在无状态业务场景下非常合适——Service类里通常没有可变成员变量,多线程访问只读取外部传入参数,天然安全。但如果某个单例Bean里有可变的业务状态,比如放了一个计数器成员变量,在多线程环境下就会出现并发安全隐患。解决思路不是把作用域改成prototype,而是把可变状态改为无状态设计,或者用外部存储记录状态。

prototype表示每次从容器获取Bean时都会创建一个全新对象。这个作用域适合有状态的自定义组件,但在业务代码中使用频率并不高,因为Spring容器默认不会管理prototype Bean的完整生命周期。具体说就是:容器只负责创建它并执行初始化回调,但不会在容器关闭时帮你执行@PreDestroy销毁方法。BeanPostProcessor仍然会在创建阶段生效,但DisposableBean的回调不再覆盖。如果项目里必须用prototype并且有资源释放诉求,就需要自己想办法销毁,或者借助其它机制完成资源清理。

request和session作用域只在Web环境中存在,分别表示一个HTTP请求/一个HTTP会话内单例。这两个作用域有个著名的坑:如果你在一个singleton的Service里注入了request作用域的Bean,注入进来的实际是一个代理对象(由Spring自动生成的代理壳),每次通过代理对象访问时才真正拿到当前线程对应的请求级实例。如果你直接注入的是普通的request Bean而不是作用域代理,启动时不会报错,但运行时会抛ObjectFactory相关的异常,或者取到的永远是最初那个实例,排查起来极其痛苦。

3.3 懒加载与初始化策略

Spring Bean默认在容器启动阶段就完成实例化。启动过程中扫描到的所有Bean都会立即走一遍生命周期。这种方式的好处是问题暴露早——某个Bean缺依赖、类路径写错、初始化方法抛异常,启动时直接失败,而不是等到用户请求时才爆。

但如果项目启动时Bean数量特别多,启动时间很可观。这时可以用@Lazy注解把部分非关键路径的Bean设置为懒加载——第一次被使用时才初始化。不过我要提醒一句:懒加载会掩盖一部分初始化问题,把出错时间从启动阶段推迟到首次调用时,排查问题的时限会变得不可控。除非启动耗时实在无法忍受,否则我建议保持默认的启动时初始化策略,有问题早点炸出来反而省事。

Spring Boot 3.x之后还有一个特性需要注意,就是引入@Lazy到自动配置里的行为发生了变化,很多自动配置类在默认情况下就启用了懒加载,这点升级版本时要多留意启动日志里的Bean初始化记录。

4. 依赖注入的三种方式与循环依赖问题

4.1 三种注入方式的取舍

依赖注入是Bean机制里最常用的功能,注入方式有三种,网上争论也比较多,我这里给出实际项目的选择思路。

构造器注入就是在类上生成一个带参构造器,Spring在创建Bean时通过构造器参数自动匹配容器中的依赖。这种方式强制依赖不可变,字段可以声明为final,而且代码在编译期就能暴露缺失依赖的问题,写起来总结起来就是后顾之忧少。Spring官方文档推荐的做法就是构造器注入。

Setter注入通过类中的setter方法来提供依赖,字段不能为final,本来是为了后期动态变更依赖设计的。但在实际项目里,你很少需要运行时动态替换依赖,而且Setter注入还平白多出一个可变更的入口,一旦被误用可能引发状态不一致,所以不推荐在业务代码中使用。

字段注入是现在日常代码中出现频率最高的方式,直接给字段加@Autowired就完事。它的优点是代码短。缺点在于对调用方不透明——依赖到底是哪里来的、什么时候赋值的都无法在编译期看到。另一个更隐蔽的问题是字段注入很难在单元测试中模拟,因为你必须在测试代码里通过反射去设这个字段的值,或者用Spring上下文启动测试。构造器注入就不存在这个问题,直接new对象传参即可。

我的建议很明确:新写的业务代码一律用构造器注入。至于为什么很多生产中代码都是字段注入,只因为历史代码多、改造成本高,明白两者的差异,新项目别再往坑里跳就好。

4.2 循环依赖是怎么产生的

循环依赖指的是两个或多个Bean互相持有对方引用。比如A依赖B,B又依赖A。Spring容器在创建A时会发现需要B,于是转而创建B,创建B时又发现需要A,于是进入闭环。

Spring对循环依赖的处理能力是有限度的。对于默认的单例作用域 + 字段注入(或者Setter注入)的情况,Spring通过三级缓存机制可以在大多数场景下完成循环依赖的解析。它的原理是:创建A时先实例化出一个半成品对象(此时属性还没有填充),把这个半成品放入三级缓存,A的依赖B需要创建时,B再依赖A时就能从缓存里取到这个“还没完全构造好”的A对象,先完成引用,最后再继续填充B的其他属性,等B创建完成后,再回头填充A的依赖。

但构造器注入的循环依赖Spring是无法解决的。因为构造器注入要求先创建出完整对象才能注入依赖,而创建对象本身就在第一步需要依赖,这导致半成品对象根本不存在,三级缓存没有东西可以兜底。启动时直接报BeanCurrentlyInCreationException,明确告诉你是哪个Bean正在创建中,别再尝试构造器循环。

此外,prototype作用域的循环依赖也解决不了,因为每次获取都创建新对象,缓存机制对prototype根本不适用。还有一个是@Async注解修饰的方法内部互相调用的循环依赖,由于会生成新代理,代理的嵌套关系也会造成解析失败。实际项目里遇到循环依赖,正确的方案不是想方设法绕开,而是重构代码——把A和B互相依赖的逻辑抽到独立的C中,让A和B都依赖C,彻底破开环。刚刚有经验的技术人员都会说:循环依赖是代码设计的坏味道。

5. 实战案例:模拟一个从零装配Bean的完整过程

这一节我拿一个模拟项目X来演示,目标是把一个外部第三方短信发送器整合进Spring容器,走一遍完整的Bean装配、属性绑定、初始化和使用流程。

5.1 案例需求拆解

模拟项目X是一个基于Spring Boot的后端服务,需要对接一个第三方短信发送器的客户端SDK。这个SDK的初始化需要三个参数:API地址、密钥、连接池大小。SDK本身不是Spring组件,是一个普通类,我们只能通过@Bean方式手动装配。为了让配置可以随时调整,参数需要从application.yml中读取。

5.2 代码实现过程

第一步,配置类声明Bean。关键点是让参数全部外置到配置文件,而不是硬编码在代码里。通过Spring的@Value注解或者@ConfigurationProperties注解都能完成属性绑定。我更推荐@ConfigurationProperties,因为它能把一组同名配置一次性封装成一个属性对象,类型安全、结构清晰。做法如下。

@ConfigurationProperties(prefix = "sms") public class SmsProperties { private String apiUrl; private String apiKey; private int poolSize = 5; // getter/setter 略 }

然后在配置类上加上@EnableConfigurationProperties(SmsProperties.class),让它成为一个可供注入的Bean。接着写@Bean方法创建短信发送器对象。这个方法传入SmsProperties作为参数,Spring自动完成属性对象的注入。

@Configuration @EnableConfigurationProperties(SmsProperties.class) public class SmsAutoConfiguration { @Bean public SmsSender smsSender(SmsProperties properties) { SmsSender sender = new SmsSender(properties.getApiUrl(), properties.getApiKey()); sender.setPoolSize(properties.getPoolSize()); sender.init(); return sender; } }

第二步,业务代码里注入使用。因为短信发送器全局应该只有一个实例且不可变,直接在需要的地方构造器注入。同时定义一个接口、一个默认实现,方便后期扩展更多发送渠道,依赖倒置到接口层面。

public interface SmsSender { boolean send(String phone, String content); } @Service public class OrderNotifyService { private final SmsSender smsSender; public OrderNotifyService(SmsSender smsSender) { this.smsSender = smsSender; } public void notifyOrderShipped(String phone, String content) { smsSender.send(phone, content); } }

第三步,启动容器。Spring Boot启动时会自动扫描所有配置类,执行@Bean方法,把外部SDK对象纳入容器。OrderNotifyService创建时通过构造器参数自动匹配到smsSender这个Bean,完成注入。整个过程里OrderNotifyService完全不知道短信发送器的实现细节,也不负责创建和关闭它。我们把短信发送器的生命周期托付给了容器,换来的是业务代码的彻底解耦。

5.3 这个案例带来的启发

这个模拟案例虽然简单,却能说明四个重要结论:第三方类想进入Spring容器,靠扫描注解不行,必须通过@Bean方法主动创建;参数外置到配置文件是为了部署时无痛修改,不重新编译;构造器注入让依赖关系在类定义层面就一目了然,测试时也能很轻松地传入伪发送器;容器管理生命周期后,我们再也不需要关心短信发送器的销毁时机。

6. 常见问题速查与排查技巧实录

下面这些是我在实际开发中反复遇到的Bean相关问题,整理成速查表,按频率从高到低排列。大家遇到类似报错时直接对着找答案。

异常/现象根本原因解决方案
NoSuchBeanDefinitionException容器里根本没有这个Bean定义检查包扫描路径是否覆盖了该Bean所在包;检查@Bean方法是否被正确执行;确认类型是否匹配
NoUniqueBeanDefinitionException接口存在多个实现类用@Primary标记主实现;或用@Qualifier指定Bean名;或重构避免模糊匹配
BeanCurrentlyInCreationException构造器注入循环依赖;懒加载遇到循环重构为字段/Setter注入的单例模式;更重要是重构掉循环依赖
BeanDefinitionStoreException / BeanCreationExceptionBean定义格式错误或初始化抛异常定位到BeanName,查看Caused by具体异常栈,多半是初始化方法内抛错
BeanNotOfRequiredTypeException类型推断错误,比如代理对象与目标类型不匹配确认@Bean方法的返回类型是否与使用方期望的类型一致
注入的对象在每次请求中相同误用了singleton作用域注入request作用域Bean检查是否使用代理模式或者换成ObjectFactory/Provider等访问方式
@Autowired字段为nullBean扫描不到或字段所在类不是Spring管理的Bean确认类是否在扫描路径内、是否加了@Component等注解
@Configuration中@Bean方法内的new出的对象依赖不受容器管理用了new创建依赖对象而非通过参数注入改为把所需依赖声明为方法参数,交给Spring解析注入
BeanPostProcessor不生效没被注册为Bean,或自定义后置处理器实现不对确认自定义的后置处理器本身是容器中的Bean
懒加载Bean初始化报错延迟到运行期用了@Lazy使得问题只在首次调用时暴露大多数场景去掉@Lazy,让问题在启动阶段暴露

这里再分享几个排查技巧:

第一,遇到任何Bean相关异常,先看完整异常栈的Caused by。Spring的异常往往层层包装,最底层的Caused by才是真正原因,比如某个类初始化方法里出现了空指针。不去看Caused by而是盯着外层异常猜,效率最低。

第二,学会阅读启动日志。Spring Boot启动时会输出一堆组件的注册信息,里面有全路径扫描耗时、Bean数量变化等状态日志。关键信息如"Found X beans"以及自动配置类生效情况,都能帮你判断配置是否生效。

第三,打开Bean注册表信息。在调试时可以通过ApplicationContext对象的getBeansOfType方法查看某一类型下到底注册了哪些Bean,这个方法比肉眼排查配置来得快得多。还可以在启动时加-Ddebug参数验证自动配置类的生效情况。

第四,很多看起来莫名其妙的Bean问题根因其实在依赖的版本冲突。比如某个类同时存在于多个JAR包,版本老的那个没有被排除干净,导致类加载时拿到了老版本。这种情况下报错信息非常难懂,注意看Caused by里的类全限定名,用dependency tree等工具排查JAR依赖树是个很实用的方向。

7. 从Bean理解Spring的宏观设计逻辑

Bean机制表面上是对象管理,实际上贯穿着Spring整个框架的灵魂。搞清楚Bean之后,再去看AOP、事务管理、Spring Boot自动配置等高级特性,会变得非常顺畅。

Spring IoC容器本质上是一张Bean定义和依赖关系的网络。每个Bean是网络上的节点,每条装配关系是一条边。容器启动时做拓扑排序,逐个实例化节点、填充属性、建立连接,最终把整张网织好。AOP之所以能做切面增强,是因为BeanPostProcessor在Bean实例化之后插入代理逻辑,容器中的对象变成代理对象,外部调用时进入代理方法执行增强逻辑。事务管理之所以能自动提交回滚,是因为事务管理器被注入到所有被事务注解标注的Bean的代理中,代理在方法前后控制事务状态。

Spring Boot的自动配置更是把Bean机制运用到了极致。所有spring-boot-starter依赖都会引入一个自动配置类,这个类内部声明了大量@Bean方法,条件注解控制它们按需生效。开发者之所以不用写很多配置,底层全靠Bean装配机制和条件判断互相配合。

个人在实际操作中的体会是,理解Bean最有效的方式不是背理论,而是把Spring源码的AbstractApplicationContext类打开,从refresh()方法开始读一遍。你会看到BeanFactory的创建、Bean定义的加载、BeanPostProcessor的注册,以及最后的finishBeanFactoryInitialization阶段一整个流程。读完之后,很多概念都会串联起来,面试问到的生命周期、循环依赖这些也都迎刃而解了。

8. 从Bean到装配思维:一个值得沉淀的转变

最后分享一点个人经验。很多初学者学Spring的时候容易陷入一个误区:把Bean当成一种“注解语法”来背,只会写@Service、@Autowired,却不理解容器为什么这样设计。

我见过不少刚转行的朋友,写代码很强,但遇到“容器里拿不到Bean”这类问题时完全不知道怎么查。其实一旦建立起“所有Bean都是被容器统一管理的资源”这个心智模型,很多问题都会有清晰的排查路径:先确认Bean的声明方式对不对、再确认扫描范围对不对、再确认注入方式与作用域是否匹配、最后看启动日志里的异常细节。这四步几乎能覆盖绝大多数Bean相关故障。

更进一步,Bean机制塑造的是一种“装配思维”——对象不该自己去找它的依赖,而是声明依赖、把组装责任交给上层的容器。自己维护组装的代码,一旦组件数量增加,后期面临的维护成本会成倍上升。容器来做这件事,好处便是面向扩展开放、修改成本可控、底层细节有统一收口。顺着这个思路去理解框架,比记住一堆细节更有实际价值。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进