CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Spring Boot 实战入门/课时/Bean 作用域与生命周期
RawBlame
课时目录16 节

Spring Boot 实战入门

  1. 01Spring Boot 是什么
  2. 02Starter 与自动配置
  3. 03配置与 Profile
  4. 04IoC 与依赖注入
  5. 05Bean 作用域与生命周期
  6. 06Spring AOP 入门
  7. 07Spring MVC 与 REST
  8. 08参数校验与异常处理
  9. 09JDBC 与 MyBatis
  10. 10Spring Data JPA 入门
  11. 11事务管理
  12. 12Spring Security 基础
  13. 13JWT 与 API 鉴权
  14. 14单元测试与 MockMvc
  15. 15Actuator 与健康检查
  16. 16综合练习与进阶路线

Bean 作用域与生命周期

singleton、prototype;@Configuration 与 @Bean。

25 分钟

Bean 作用域与生命周期

这一课你将能做到

  1. 用 @Configuration + @Bean 把第三方类注册进容器,并说清它和 @Component 的分工
  2. 演示 singleton 与 prototype 两种作用域的行为差异,并解决「单例注入多例」的经典坑
  3. 在 Bean 的关键生命周期节点(初始化、销毁)挂上自己的逻辑

为什么要学

上一课所有对象默认「全局一个实例」(singleton),你有没有想过:如果 Bean 里放了个会被并发修改的成员变量会怎样?如果每次请求都需要一个全新对象怎么办?对象初始化完、应用关闭时,连接池、线程池谁来收拾?

人话:作用域回答「一个 Bean 建几份、什么时候建」;生命周期回答「它出生、干活、死亡时我能插手什么」。连接池启动时预热、缓存启动时加载、应用关闭时优雅释放——全靠生命周期钩子。这也是面试 Spring 部分的必考区。

先建立图像

一个 singleton Bean 的一生:

扫描/解析定义 → 实例化(调构造器)→ 属性注入(依赖塞进来)
      → Aware 回调 → BeanPostProcessor 前置处理
      → 初始化(@PostConstruct → InitializingBean → initMethod)
      → BeanPostProcessor 后置处理(AOP 代理就在这生成!)
      → 放入单例池,供业务使用
      → 容器关闭 → @PreDestroy → destroyMethod

记住两个锚点:属性注入在构造器之后(所以构造器里读不到注入的字段值);AOP 代理在后置处理器阶段生成(所以代理能包住你的 Bean)。

作用域人话典型场景
singleton(默认)全容器一个,启动时就建好Service、Repository、工具类
prototype每次要的时候现建一个新的每次需要独立状态的对象
request / session每个 HTTP 请求 / 会话一个Web 场景的请求级上下文
application每个 ServletContext 一个全局 Web 级缓存

跟着做

步骤 1:用 @Configuration + @Bean 注册一个第三方类

Jackson 的 ObjectMapper 是现成的第三方类,假设我们想自定义它的行为:

package com.example.taskflow.config;

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration                      // 声明这是「Bean 定义」的来源
public class AppConfig {

    @Bean                           // 方法返回值注册进容器,Bean 名默认=方法名
    public ObjectMapper customObjectMapper() {
        ObjectMapper mapper = new ObjectMapper();
        // 遇到未知字段不报错(前后端字段对不齐时很救命)
        mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        return mapper;
    }
}

任意 Controller 注入验证:

@GetMapping("/api/om-check") public String omCheck(ObjectMapper injected) { return injected.getClass().getSimpleName(); }

分工口诀:自己的类用 @Component 系注解(类上标注),别人的类用 @Bean(方法上注册)——你改不了第三方类的源码,加不了 @Component。

步骤 2:singleton——默认作用域的行为

package com.example.taskflow.counter;

import org.springframework.stereotype.Component;

@Component
public class VisitCounter {
    private int count = 0;                 // 故意演示成员变量

    public int increase() { return ++count; }
}
@RestController
public class CounterController {

    private final VisitCounter counter;

    public CounterController(VisitCounter counter) {
        this.counter = counter;
    }

    @GetMapping("/api/visit")
    public int visit() { return counter.increase(); }
}

反复访问 /api/visit:1、2、3……所有请求共享同一个实例和同一份 count。这引出两个结论:①想跨请求计数,容器帮你做了(无需 static);②singleton 里放可变状态,多线程并发修改必然出问题(++count 不是原子操作)。

结论写进笔记:singleton Bean 必须无状态;要状态就放方法参数、局部变量,或交给 Redis/数据库。

步骤 3:prototype——每次都要新的

package com.example.taskflow.counter;

import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;

@Component
@Scope("prototype")            // 每次获取都新建实例
public class ReportBuilder {
    private final StringBuilder sb = new StringBuilder();

    public ReportBuilder append(String line) {
        sb.append(line).append('\n');
        return this;
    }

    public String build() { return sb.toString(); }
}
@GetMapping("/api/report")
public String report() {
    ReportBuilder b1 = applicationContext.getBean(ReportBuilder.class);
    ReportBuilder b2 = applicationContext.getBean(ReportBuilder.class);
    return "b1==b2 ? " + (b1 == b2);      // false:每次 getBean 都是新的
}

(applicationContext 通过构造器注入 ApplicationContext 获得。)prototype 的语义是「容器只负责造,不管后续」:销毁回调对它无效,容器不会追踪 prototype 的死亡。

步骤 4:单例注入多例的经典坑

直接给 singleton 注入 prototype 会发生什么?

@Service
public class TaskService {
    private final ReportBuilder builder;      // 只在启动时注入了一次!

    public TaskService(ReportBuilder builder) {
        this.builder = builder;
    }
}

启动注入时创建了一个实例,之后这个 singleton 用到死都是同一个 builder——prototype 失效了。正确姿势一:用 ObjectProvider 每次现取:

@Service
public class TaskService {

    private final ObjectProvider<ReportBuilder> builderProvider;

    public TaskService(ObjectProvider<ReportBuilder> builderProvider) {
        this.builderProvider = builderProvider;
    }

    public String export() {
        ReportBuilder builder = builderProvider.getObject();   // 每次调用都是新实例
        return builder.append("任务导出于 " + java.time.LocalDateTime.now()).build();
    }
}

正确姿势二:在 prototype 类上加 @Lookup 抽象方法(了解即可)。记住结论即可:singleton 要用 prototype,必须「按需获取」,不能「一次性注入」。

步骤 5:生命周期钩子——初始化与销毁

模拟一个需要预热和清理的资源类:

package com.example.taskflow.lifecycle;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.springframework.stereotype.Component;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Component
public class DictCache {

    private final Map<String, String> dict = new ConcurrentHashMap<>();

    @PostConstruct              // 依赖注入完成后执行,只执行一次
    public void load() {
        System.out.println("[DictCache] 启动预热:加载字典数据");
        dict.put("status_0", "待处理");
        dict.put("status_1", "进行中");
        dict.put("status_2", "已完成");
    }

    public String label(String code) { return dict.getOrDefault(code, code); }

    @PreDestroy                 // 容器关闭前执行
    public void cleanup() {
        System.out.println("[DictCache] 应用关闭:释放字典缓存");
        dict.clear();
    }
}

重启应用观察控制台:启动时先输出「启动预热」,Ctrl+C 关闭时输出「应用关闭」。三个可用钩子按执行顺序:

@PostConstruct                    // ① 推荐,JSR 标准,注解方法
public void init() { }

// ② 实现 InitializingBean / DisposableBean 接口(侵入性强,少用)
// ③ @Bean(initMethod = "start", destroyMethod = "stop")——第三方类没有注解时的唯一选择

应用优雅停机要生效,可在 yml 里开启 server.shutdown: graceful,让在途请求处理完再销毁 Bean。

步骤 6:@Lazy——延迟初始化

@Component
@Lazy
public class HeavyInitializer {
    public HeavyInitializer() {
        System.out.println("[HeavyInitializer] 构造了(现在才发生)");
    }
}

启动日志里看不到它,直到第一次被使用才创建。用途:加载慢的 Bean(如重量级客户端)不拖慢启动;也用于打破构造器循环依赖。

讲清楚要点

  1. singleton 默认且够用:Web 应用 99% 的 Bean 都是单例。代价是要遵守「无状态」纪律;有状态需求用方法局部变量、ThreadLocal 或外部存储解决,而不是改作用域。
  2. prototype 的「多例」只在 getBean/getObject 时发生:注入动作只发生一次。注入 prototype 到 singleton,拿到的是启动那一刻的单个实例。
  3. 初始化钩子的正确用法:@PostConstruct 里做「依赖已就绪」后的准备工作(预热缓存、校验配置、注册回调);此时字段注入已完成,但注意别在这里做依赖外部系统的重活拖慢启动。
  4. 销毁钩子只对 singleton 有效:prototype 对象容器不追踪。连接池、线程池这类需要关闭的资源必须是 singleton。
  5. BeanPostProcessor 是 AOP 的地基:Spring 在初始化前后留了全局钩子,@Transactional、@Async、@Cacheable 生成的代理全在这里织入。理解它,就理解了「注解魔法」的发生位置。

你可能会懵的地方

  • singleton Bean 是线程安全的吗? Bean 本身无所谓安全不安全,取决于你怎么用:无状态(只有 final 依赖和方法内局部变量)就是安全的;有可变成员变量被多请求并发读写就不安全。不要因为「怕线程不安全」改 prototype——那只是把并发问题换成了「每个请求都重建重资源」的性能问题。

  • @PostConstruct 和构造器的区别? 构造器执行时依赖还没注入;@PostConstruct 在注入完成后执行。需要用到注入依赖的初始化逻辑必须放 @PostConstruct。

  • request/session 作用域为什么实际项目很少见? 前后端分离架构下,请求级状态通常走参数、ThreadLocal(如拦截器存用户信息)或直接传值;session 作用域在无状态 API 风格里更少。了解即可,别当主力。

  • @Bean 方法之间互相调用会怎样? 在 @Configuration 类里调用另一个 @Bean 方法,Spring 通过 CGLIB 代理拦截,返回的仍是容器里的同一个单例(这就是 proxyBeanMethods=true 的作用);若 @Configuration(proxyBeanMethods = false) 则是普通方法调用,会 new 新对象。保持默认即可。

  • Bean 的加载顺序能控制吗? @DependsOn 显式声明、@Order 控制集合注入顺序、构造器依赖天然形成顺序。尽量不要依赖隐式顺序,依赖关系靠注入表达。

错法对照

错法问题正解
singleton 里放可变成员变量并发修改,计数错乱无状态化;状态放局部变量或外部存储
singleton 构造器里读 @Value 注入字段字段还是 null初始化逻辑放 @PostConstruct
直接注入 prototype 期望每次新的只注入一次,退化成单例ObjectProvider.getObject() 按需取
用 prototype 放数据库连接池池被反复创建,销毁回调失效资源类一律 singleton
给第三方类强行加 @Component改不了源码@Configuration + @Bean 注册

卡点急救

  1. @PostConstruct 方法抛异常导致启动失败:这是好事——问题被拦在启动阶段。读异常定位预热逻辑的错误(通常是外部依赖不可达或配置缺失),修好再启动;不要 try-catch 吞掉假装没事。
  2. 应用关闭时 @PreDestroy 没执行:确认是用 Ctrl+C / kill(SIGTERM)正常关闭而不是 kill -9(强杀不给机会);容器未正常启动完成也不会执行销毁。
  3. 需要 prototype 却报 Bean 是同一个:检查是否在注入点直接拿的(注入只发生一次),改成 ObjectProvider 或每次 getBean。
  4. 两个 Bean 必须有先后顺序:加 @DependsOn("beanA") 显式声明,或直接用构造器依赖表达。

面试常问

  • Spring Bean 的生命周期? 实例化 → 属性注入 → Aware 回调(BeanNameAware 等)→ BeanPostProcessor 前置 → 初始化(@PostConstruct → InitializingBean.afterPropertiesSet → initMethod)→ BeanPostProcessor 后置(AOP 代理在此生成)→ 使用 → 容器关闭 → 销毁(@PreDestroy → DisposableBean.destroy → destroyMethod)。

  • singleton 和 prototype 的区别?各自注意什么? singleton 全局一个、容器管理完整生命周期、必须无状态;prototype 每次获取新建、容器只管创建不负责销毁、与 singleton 组合注入时会退化为单例,需 ObjectProvider 按需获取。

  • 单例 Bean 依赖原型 Bean 怎么处理? ObjectProvider / @Lookup 方法 / Provider.getBean,实现「每次使用都是新实例」;同时评估是否真需要 prototype,多数场景可用无状态设计替代。

  • Bean 作用域有哪些?request 作用域怎么工作? singleton、prototype、request、session、application、websocket。request 依赖 RequestContextHolder 的 ThreadLocal 请求上下文,作用域代理在每次请求时路由到当前请求的实例——非 Web 环境使用会报错。

自测三问

  1. Bean 生命周期从实例化到销毁,中间至少说出 5 个关键节点?
  2. 为什么构造器里读不到注入的字段?初始化逻辑该放哪?
  3. singleton Service 里放一个 private int count 会有什么后果?正确做法是什么?

小结一句话

作用域决定 Bean「建几份」(默认单例、须无状态),生命周期钩子让你在「出生与死亡」时插手——连接池预热、缓存加载、优雅释放全靠它们。

延伸

  • Spring 官方文档:Bean 作用域
  • Spring 官方文档:Bean 生命周期与扩展点
  • 下一课:Spring AOP 入门——在不动业务代码的前提下,给所有 Service 加上日志与计时。
加载本节练习…
← 上一课IoC 与依赖注入下一课 →Spring AOP 入门