Spring Boot 4.0 Bean 生命周期与依赖注入常见坑
前言
代码里写了 @Service,启动后却提示找不到 Bean;字段注入写起来很短,但单元测试时无法直接创建对象;项目运行时正常,关闭时却发现资源没有释放。这些问题都和 Spring 容器如何创建、注入和销毁 Bean 有关。
这篇教程会在现有项目中加入一个生命周期示例 Bean,通过启动日志和关闭日志,让读者亲眼看到三个时刻:
- 构造器执行。
@PostConstruct初始化。@PreDestroy销毁。
完成后,你会知道依赖应该从哪个入口进入对象,以及哪些工作不能塞进错误的阶段。
配套源码:dvsusan/spring-boot4-demo。
最终结果
启动项目后能看到类似日志:
BeanLifecycleDemo 构造器执行,environment=dev
BeanLifecycleDemo 初始化完成请求状态接口返回 READY。停止服务时再看到:
BeanLifecycleDemo 即将销毁本示例在 demo-web 模块中,不改变订单业务模块的依赖方向。

图 1:Bean 只有在 @PostConstruct 之后才进入 READY,正常停机时再执行销毁方法。
第 1 步:先分清 Bean 与普通对象
订单模块中的 OrderService 是普通 Java 类,没有 @Service:
/**
* 订单领域服务。
*
* <p>本阶段使用内存 Map 保存数据,后续接入数据库时只替换模块内部实现。</p>
*/
public class OrderService {
// 并发 Map 只用于保护示例中的并发请求,不能替代数据库。
private final Map<String, OrderView> orders = new ConcurrentHashMap<>();
}普通对象需要 new:
// 普通 Java 对象不经过 Spring 容器,直接 new 即可。
OrderService service = new OrderService();Bean 由容器创建和管理。Web 模块中的配置类负责把普通业务对象放进容器:
/**
* Web 层的 Bean 装配配置。
*
* <p>订单模块保持普通 Java,由这里把 OrderService 加入 Spring 容器。</p>
*/
@Configuration
public class OrderConfiguration {
@Bean
public OrderService orderService() {
return new OrderService();
}
}这样业务模块不必依赖 Spring,Web 模块又可以通过构造器拿到 OrderService。
第 2 步:创建生命周期示例 Bean
创建 BeanLifecycleDemo.java:
package com.susan.training.lifecycle;
import com.susan.training.config.DemoEnvironmentProperties;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
/**
* 演示 Spring Bean 的创建、初始化和销毁阶段。
*
* <p>构造器执行时依赖已经注入,但容器还没有完成全部初始化;
* @PostConstruct 适合做本对象内部的准备工作。</p>
*/
@Component
public class BeanLifecycleDemo {
private static final Logger log = LoggerFactory.getLogger(BeanLifecycleDemo.class);
private final DemoEnvironmentProperties environmentProperties;
private volatile String status = "CREATED";
public BeanLifecycleDemo(DemoEnvironmentProperties environmentProperties) {
this.environmentProperties = environmentProperties;
log.info("BeanLifecycleDemo 构造器执行,environment={}",
environmentProperties.getLabel());
}
@PostConstruct
public void init() {
status = "READY";
log.info("BeanLifecycleDemo 初始化完成");
}
@PreDestroy
public void destroy() {
status = "STOPPED";
log.info("BeanLifecycleDemo 即将销毁");
}
public String status() {
return status;
}
}@Component 让启动类包扫描发现这个类。volatile 表示状态可能被不同线程读取,声明后更容易看清可见性意图。
第 3 步:理解三个生命周期阶段
构造器
构造器负责完成对象的必要依赖和不可变字段:
public BeanLifecycleDemo(DemoEnvironmentProperties environmentProperties) {
// 构造器只保存必需依赖,不在此时执行网络请求。
this.environmentProperties = environmentProperties;
}构造器执行时,依赖对象已经由容器准备好。不要在这个阶段做网络请求、长时间扫描或依赖尚未初始化的其他 Bean。
PostConstruct
@PostConstruct
public void init() {
// 依赖注入完成后,再切换为可用状态。
status = "READY";
}它适合做当前对象自身的初始化,例如校验本对象配置、构建只读缓存、启动需要显式控制的内部状态。
如果初始化依赖其他 Bean,必须确认依赖已经完成初始化。复杂的跨 Bean 启动逻辑更适合 ApplicationRunner、事件监听器或明确的编排服务。
PreDestroy
@PreDestroy
public void destroy() {
// 正常停机时释放当前对象持有的资源。
status = "STOPPED";
}它适合关闭当前对象持有的线程池、连接或临时文件。应用收到正常关闭信号时,Spring 会调用它;进程被强制杀死时,不保证有机会执行。
第 4 步:通过接口确认初始化完成
SystemController 通过构造器注入这个 Bean:
private final BeanLifecycleDemo lifecycleDemo;
// 构造器注入在对象创建时明确必需依赖。
public SystemController(
Environment environment,
DemoEnvironmentProperties properties,
BeanLifecycleDemo lifecycleDemo,
ObjectProvider<GreetingService> greetingServiceProvider
) {
this.lifecycleDemo = lifecycleDemo;
}状态接口:
@GetMapping("/bean")
public ApiResponse<String> beanStatus() {
// 通过接口观察初始化阶段是否已经完成。
return ApiResponse.success(lifecycleDemo.status());
}构建并启动:
mvn clean package -DskipTests
java -jar demo-web/target/demo-web-1.0.0-SNAPSHOT.jar --server.port=18086启动日志中要同时看到构造和初始化:
BeanLifecycleDemo 构造器执行,environment=dev
BeanLifecycleDemo 初始化完成请求:
curl "http://localhost:18086/api/v1/system/bean"预期返回:
{
"code": "0",
"message": "success",
"data": "READY"
}按 Ctrl+C 停止应用,再观察销毁日志。
第 5 步:为什么优先使用构造器注入?
项目里的依赖都通过构造器传入:
public OrderController(OrderService orderService) {
// final 字段保证依赖在对象创建后不再变化。
this.orderService = orderService;
}它有三个直接好处:
- 依赖可以声明为
final,对象创建后不会被替换。 - 单元测试可以直接
new OrderController(mockService)。 - 缺少依赖时容器启动就失败,不会拖到第一次调用才出现空指针。
字段注入:
// 字段注入隐藏依赖,不利于直接测试和无容器初始化。
@Autowired
private OrderService orderService;看起来少写构造器,却把依赖藏了起来,也不方便脱离容器创建对象。字段注入并非无法运行,而是维护成本更高。
第 6 步:可选依赖用 ObjectProvider
自定义 Starter 可能被关闭,所以 GreetingService 不保证存在。SystemController 使用:
private final ObjectProvider<GreetingService> greetingServiceProvider;
// ObjectProvider 表示依赖可能存在,也可能已经被条件配置关闭。
GreetingService greetingService = greetingServiceProvider.getIfAvailable();如果直接写成必需的构造器参数,而 Starter 又被关闭,应用启动会因缺少 Bean 失败。
ObjectProvider 适合“可以有,也可以没有”的依赖。普通必需依赖仍然直接注入,不要把所有依赖都改成 ObjectProvider,否则代码会失去构造器注入的明确性。
第 7 步:检查包扫描
启动类位于:
package com.susan.training;
// 启动类位于根包,默认组件扫描覆盖其业务子包。
@SpringBootApplication
public class DemoApplication {
}它默认扫描 com.susan.training 以及所有子包:
com.susan.training.config
com.susan.training.lifecycle
com.susan.training.order
com.susan.training.web如果启动类移到 com.susan.training.web,其他包不会被默认扫描。此时可能出现 Controller 正常、业务 Bean 却找不到的“半启动成功”。
自定义 Starter 位于 com.susan.starter.greeting,不在这个扫描树内。它由上一章的 AutoConfiguration.imports 加载,不依赖组件扫描。
解决方式有两种:
- 把启动类放回项目根业务包。
- 明确写
scanBasePackages,但要确保扫描范围没有意外扩大。
优先选择更清晰的包结构。
循环依赖为什么不应该靠配置解决?
假设 A 的构造器需要 B,B 的构造器又需要 A,容器无法先创建任意一个对象。这不是“Spring 版本不兼容”,而是对象职责形成了环。
推荐处理方式:
- 把共同逻辑下沉到第三个服务。
- 用事件解耦后续动作。
- 检查是否只是查询依赖,可以传递需要的数据而不是整个对象。
- 重新划分模块边界。
开启允许循环依赖的配置只会把设计问题藏起来,并不能让对象职责更清楚。
常见问题排查
| 现象 | 原因 | 处理方式 |
|---|---|---|
找不到 OrderService Bean | 配置类没有被扫描 | 检查启动类包和 @Bean 配置 |
| 构造器参数无法注入 | 依赖对象没有注册 | 添加 @Bean、@Component 或自动配置 |
| 关闭时没有销毁日志 | 强制结束进程或未正常停机 | 使用 Ctrl+C 并检查优雅停机 |
| 可选 Bean 导致启动失败 | 把可选依赖写成必需参数 | 使用 ObjectProvider 或条件配置 |
| 出现循环依赖 | 构造器形成对象环 | 下沉逻辑或改为事件解耦 |
练习题
让 BeanLifecycleDemo 记录启动耗时。
要求:
- 在构造器记录开始时间。
- 在
@PostConstruct计算耗时并输出日志。 - 不把耗时当成真实性能结论,只观察生命周期时序。
总结
Spring Bean 的生命周期可以概括为:容器创建对象、完成依赖注入、调用初始化方法、使用对象、正常关闭时执行销毁。
依赖优先通过构造器进入,必需和可选依赖要区分清楚。下一篇把这些对象产生的日志串起来,为每次请求生成可检索的 traceId。
上一节:Spring Boot 4.0 配置管理
下一节:Logback、MDC 与结构化日志
13 个企业级实战项目 · 源码 + 教程 + 答疑
简历包装指导AI Agent 实战高并发·微服务Offer 导向持续更新3000+ 同行交流1V1 答疑3 天包退
覆盖高并发、微服务、分布式与 AI Agent:100万QPS短链、企业智能知识库、商城微服务、SaaS点餐、秒杀、刷题小程序、代码生成器、AI Agent 等。券后 ¥129(原价 ¥159),不满意 3 天包退。
