☰
Spring Bean生命周期、作用域与线程安全:从原理到实战避坑
2026/10/13 21:41:04 网站建设 项目流程

1. Bean生命周期不是一条直线,是三段独立的旅程

很多人讲Spring Bean生命周期时习惯画一条竖线,从实例化一路画到销毁。这个画法本身没错,但实际排查问题时会发现,真正影响你业务的往往不是那条线的长度,而是线上几个容易被忽略的转角点。我第一次把Bean生命周期拆开理解,是在一个翻车事故里:项目启动时某个Bean的初始化方法里做了网络调用,结果对方服务慢,整个Spring容器启动直接被拖住。当时我只知道Bean有个init方法,却不清楚它到底在容器的哪个阶段执行、和BeanPostProcessor的先后关系是什么。后来重新把生命周期拆成"定义阶段、实例化初始化阶段、销毁阶段"三段来理解,很多问题就顺了。

1.1 第一段旅程:BeanDefinition的装载与合并

很多教程直接从new对象开始讲Bean生命周期,但漏掉了一个上位概念:Spring容器里管的不是对象,而是BeanDefinition。我们写的@Component、@Bean、XML里的<bean>,最终都会被解析成一条BeanDefinition注册进容器,后面所有的实例化、注入、初始化,都是以这份定义为蓝本进行的。

这里有个特别值得注意的动作:ConfigurationClassPostProcessor在做完解析后,还会触发一次BeanDefinition的合并。当你在父类里定义了通用的属性或初始化方法,子类Bean会通过合并把这些定义继承下来。实际项目中比较常见的例子是:一个基类配置了@PreDestroy或者initMethod,如果子类覆盖了相关定义,合并顺序出错可能导致回调不执行。

这个阶段容器的状态对排查问题很有用:如果启动时报"BeanDefinitionStoreException"或者"ConflictingBeanDefinitionException",本质上是定义阶段出了问题,和对象实例化还没关系。你就知道不用去翻构造器逻辑,直接盯配置来源就行。

1.2 第二段旅程:实例化之后的世界,比实例化本身更值得研究

实例化阶段其实是Spring通过构造器创建对象的过程,但真正让Bean变得"可用"的是后续一串初始化动作。Spring拿到一个BeanDefinition后,会先根据构造器参数解析找到对应的构造函数,然后创建对象实例。这个对象此时只是一个空壳,属性全是默认值,依赖也没注入进来。

紧接着是属性填充(populateBean),@Autowired、@Resource、@Value这些注解的核心逻辑都集中在这一步。顺序上有讲究:Spring会先用BeanPostProcessor来处理@Autowired,再处理@Resource,这也是为什么有人发现同时混用两个注解时行为不一致的原因。

到这里,我一直强调一个观点:实例化和初始化是两个完全不同的阶段。实例化仅仅是把对象造出来,初始化才是让对象具备业务能力。搞混这两个概念,会导致你猜错Bean的可用时机。比如在一个@PostConstruct方法里调用另一个Bean的方法,如果那个Bean还没初始化完,可能拿到的是半成品状态。

1.3 初始化的完整时序:Aware回调、BeanPostProcessor与@PostConstruct

初始化阶段的执行顺序非常固定,我按实际启动日志的顺序拆给你看:

  1. BeanNameAware、BeanFactoryAware、ApplicationContextAware等Aware回调。容器会把Bean的id、所属工厂、上下文等基础设施信息塞给Bean。这一步通常很少感知,但你在自定义Bean里需要拿ApplicationContext时,就是靠ApplicationContextAware接口。
  2. BeanPostProcessor的postProcessBeforeInitialization方法。这个扩展点先于初始化方法执行,Spring内置的很多能力(比如@Autowired注解的解析,实际上在实例化后的属性填充阶段就已经发生了,但postProcessBeforeInitialization仍被大量框架用于注入额外基础能力)。
  3. @PostConstruct注解标记的方法,接着是InitializingBean的afterPropertiesSet方法,最后是XML/注解式定义的initMethod。执行顺序有优先级:注解优先于接口,接口优先于配置方法。
  4. BeanPostProcessor的postProcessAfterInitialization方法。这是整个生命周期里最顶级的扩展点之一,Spring AOP的代理对象就是在这个阶段生成的,代理会替换掉原始对象返回容器。

很多人在代码里同时使用@PostConstruct和InitializingBean,这没问题,但要清楚它们执行的先后。要是有业务对顺序敏感,比如先初始化缓存再加载配置,顺序设计错了就是线上事故。

@Bean(initMethod = "customInit") public CacheService cacheService() { return new CacheService(); }

上面这段配置,customInit会在构造器结束、属性填充完成、@PostConstruct执行完之后才被调用。如果customInit里依赖了别的Bean已经初始化完,直接调可能出问题,因为此时你无法保证对方Bean的初始化顺序。

1.4 销毁阶段不是垃圾回收,它影响优雅停机

销毁阶段对大多数业务代码来说存在感很低,但一旦涉及资源释放、连接关闭、线程池shutdown,就绕不开它。Spring容器关闭时会执行以下顺序:先触发@PreDestroy方法,然后是DisposableBean接口的destroy方法,最后是自定义的destroyMethod。

有个冷门但实用的经验:prototype作用域的Bean销毁不由容器管理。容器只负责创建它交给调用方,后续销毁需要调用方自己处理。如果你的系统里大量使用prototype且内部持有需要释放的资源,一定要自己兜底,否则就是资源泄漏。这一点在后面的作用域章节里再展开。

2. 作用域的取舍逻辑:singleton、prototype与Web作用域

作用域直接决定了"一个Bean会被容器创建几个实例、实例什么时候创建、实例谁能看见"。我见过很多团队,全部Bean一律用singleton,用到再说,出了问题再去补作用域配置。其实作用域的选择不是玄学,它的核心判断依据只有一句话:这个Bean实例能不能被安全地共享?

2.1 singleton不是唯一选择,但默认值确实合理

singleton表示Spring容器里只存在一个Bean实例,所有依赖注入都共享这一个对象。默认情况下,singleton Bean会在容器启动时实例化(饿汉式)。如果不想启动时被拖累,可以用@Lazy把实例化推迟到第一次真正使用时。

大部分Service、DAO、Controller、配置类都是无状态的,它们不保存和当前调用相关的中间数据,所以共享一个实例没有任何问题,反而节省了对象创建开销。这就是默认值存在的合理性。

但如果你把一个有状态的Bean声明为singleton,比如在里面放了一个普通的HashMap来保存用户会话数据,那并发请求下就会出现数据互相踩踏的问题。这种情况要么重构为无状态,要么改用其他作用域。

2.2 prototype:每次都新建,但它不是万能的

prototype作用域会保证每次从容器获取或者注入时都生成一个新实例。这个特性在"状态隔离"需求下确实合理,比如一个验证器对象内部保存本次校验过程中的临时状态,用prototype可以避免多线程共享带来的混乱。但要注意一点:Spring对prototype Bean的创建时机有两个阶段,属性填充和初始化一样会走,但销毁不再管理。

很多开发者设了一个prototype Bean,然后在singleton Bean里注入它,期望每次调用都拿到新实例,结果发现拿到的一直是同一个。原因很简单:singleton Bean在创建时注入了一次prototype Bean,之后就把这个实例攥在自己手里了,后续不会再重新去容器获取。

要打破这种"假prototype"现象,标准做法是使用ObjectProvider<T>或者注入ApplicationContext,每次主动getBean。这也是面试里经常考的"scoped proxy"的变体之一。

获取方式每次是否新实例适用场景
直接注入prototype Bean到singleton否不适合
ObjectProvider#getObject()是需要动态获取且不想注入容器的场景
ApplicationContext#getBean()是简单粗暴,但耦合了容器
@Scope(proxyMode = ScopedProxyMode.TARGET_CLASS)是Web作用域注入到singleton的标准方案

2.3 Web作用域的底层是"借用式代理"思想

request、session、application这三个Web作用域,表面上看是让Bean跟随着HTTP请求、会话或ServletContext的生命周期走。但它们面临一个核心矛盾:一个request作用域的Bean怎么注入到一个singleton的Service里?singleton在容器启动时就要确定依赖,而request Bean要等一次请求来了才有实例。

Spring的解法是生成一个代理对象注册进容器,真正调用时才通过代理解析到当前请求对应的真实Bean。这个代理就是ScopedProxyMode,一般建议用TARGET_CLASS生成基于CGLIB的代理,而不是接口代理,避免某些类没有接口时出问题。

实际项目里我见过最典型的用法就是用户上下文:一个UserContext对象是request作用域,每个请求进来自动塞入当前用户信息,Service层通过代理读取。实现思路就是注解加代理模式,谁写谁香。

3. 线程安全问题:Bean共享与并发状态的真实风险

3.1 先打破一个误解:singleton不等于线程安全

这是一次线下交流时一个A同学问我的问题:"Spring Bean默认是singleton,那它是不是线程安全的?"让我把话说完整:线程安全与否,不在于作用域,而在于Bean内部有没有可变共享状态。一个不保存任何字段的Service方法,无论多少个线程并发调用,都是安全的。

真正危险的是下面几种情况:

  • 在singleton的Service里定义了一个非线程安全的成员变量,比如普通HashMap,多个请求同时读写。
  • 在singleton的Bean里用了一个有状态的工具类或第三方组件,而这个组件内部维护了可变缓存或游标。
  • 把本该是方法局部变量的数据错误地提升为类字段,导致不同请求之间互相污染。

我印象很深的一个坑是:有人在Controller里定义了一个private SimpleDateFormat dateFormat;,因为SimpleDateFormat不是线程安全的,高并发下偶尔出现解析时间完全错乱的值。排查了很长时间,最后定位到是实例字段共享导致的并发安全问题。

3.2 线程安全的四个常见解法,按性价比排序

解决Spring Bean线程安全问题,我一般按这个顺序考虑:

  1. 无状态化:把可变数据全部放在方法栈内,也就是局部变量。这是成本最低、效果最好的方案。Service方法应该像函数一样,输入输出清晰,成员变量尽量保持final或只读配置。
  2. ThreadLocal隔离:ThreadLocal为每个线程维护一份独立副本,适合存储线程维度的上下文信息,比如请求ID、登录用户、TraceId。不过要注意,如果使用线程池,线程是复用的,用完后没有清理ThreadLocal,会导致脏数据被下一个任务读到,甚至内存泄漏。
  3. 锁和并发容器:如果非要有共享状态,比如访问计数、热门商品库存等,那就得用锁或原子类。AtomicInteger、ConcurrentHashMap、ReentrantReadWriteLock都是常用选择。这里要提醒:锁的粒度一定要小,否则吞吐量会断崖式下降。
  4. 改用合适的作用域:如果某个Bean就是为单次任务服务的,而且内部状态较多,干脆声明成prototype或者request作用域,让容器来保证隔离。不过要注意,prototype和request只是改变了实例的创建粒度,如果实例内部仍然共享了其他可变对象,问题依旧。

Spring MVC的Controller默认是singleton,这一点很多人不知道。如果Controller里有可变字段,并发下一样出问题。千万不要以为"Controller每次请求都是新对象"。

3.3 代理对象会不会引发新的线程安全问题

用@Transactional时,Spring会通过AOP生成一个代理Bean,业务方法走的是代理对象。很多初学的开发者担心"代理对象是不是线程安全的",这个问题得分两层看:代理对象本身是无状态的,它内部持有一个目标Bean的引用;而真正执行逻辑时,调用的还是目标Bean的方法。所以并发安全问题依然取决于目标Bean内部有没有可变共享状态,和代理没关系。

还有一点和线程安全强相关但容易被忽略:Spring事务的传播行为。@Transactional的保存点、回滚状态是绑定在当前线程事务上的,如果你把事务上下文误放到类字段里,多线程环境下事务就会互相干扰。这也是为什么Spring推荐用事务模板或代理机制来管理事务,而不是手动在成员变量里保存连接。

4. 三维联动的实战案例:缓存服务、任务执行器与请求上下文

前面三部分分别讲了生命周期、作用域、线程安全,但实际项目里它们永远是一起生效的。这一章我挑三个真实场景,把它们串起来看。

4.1 场景一:缓存服务怎么同时处理好生命周期和并发

你要做一个应用启动时加载一批配置到本地缓存的服务。第一直觉是写一个CacheService,用一个HashMap存数据,@PostConstruct里加载,提供一个get方法给业务调用。

如果只是这样写,高并发下会出现两个问题:一是HashMap线程不安全,读多写少也可能在rehash时出问题;二是缓存刷新如果走的是单线程定时任务,读和写之间可能看到不一致的中间状态。

我的建议是:Bean用singleton,这是配置数据共享的刚需;底层用ConcurrentHashMap承载数据;刷新逻辑用AtomicReference指向当前版本的数据对象,读的时候一次get就能拿到一个完整一致的数据视图。初始化放在@PostConstruct里,但不要在初始化方法里做耗时操作堵住容器启动,可以用ApplicationReadyEvent事件在容器完全启动后再加载,或者把加载逻辑放到后台线程去预热。

这个场景里生命周期决定了数据何时载入,线程安全决定了数据怎么存怎么读,二者共同作用,缺一不可。

@Component public class ConfigCacheService { private final AtomicReference<Map<String, String>> configRef = new AtomicReference<>(Map.of()); @EventListener(ApplicationReadyEvent.class) public void loadConfig() { Map<String, String> loaded = loadFromRemote(); configRef.set(Collections.unmodifiableMap(loaded)); } public String get(String key) { return configRef.get().get(key); } }

4.2 场景二:有状态的任务执行器为什么不能用singleton

假设系统里需要一个任务执行器,每个任务都会在对象内部累积执行步数、暂停状态、结果集。如果你把任务执行器声明成singleton,并发提交多个任务时,任务A的步数会被任务B覆盖。这时候有人会想到加锁,把所有操作都串行化,但这是拿吞吐量换正确性,任务本质上是独立并发的,串行化得不偿失。

正确做法是把任务执行器声明成prototype作用域,每次从容器获取都拿一个新实例。但要注意Spring容器不负责prototype的销毁,如果你的执行器内部打开了文件流或线程池,使用者有义务在任务结束后调用一个清理方法,或者利用try-with-resources思想让执行器实现AutoCloseable。

从生命周期角度看,prototype Bean的可观测生命周期终点在"返回给调用方那一刻"就断了,后面的清理完全是调用方的责任。这是prototype最大也最隐蔽的坑。

@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) @Component public class TaskExecutor implements AutoCloseable { private final List<String> steps = new ArrayList<>(); public void execute() { steps.add("run step"); } @Override public void close() { steps.clear(); } }

4.3 场景三:请求级用户上下文注入singleton服务

很多Web项目有一个UserContextHolder,希望在整个请求链路中都能拿到当前登录用户。最简单粗暴的做法是用ThreadLocal静态方法,但线程池会破坏"一个请求只在一个线程内处理"的假设,ThreadLocal里的用户信息可能被其他请求读到。

更符合Spring设计思路的做法是:把用户上下文做成request作用域的Bean,Service层通过代理注入它。request作用域本身意味着"一次请求一个实例",配合ScopedProxyMode.TARGET_CLASS,可以顺利注入到singleton的Service里。每次取用户信息时,代理会自动路由到当前请求对应的实例。

这个方案把生命周期(请求开始创建、请求结束销毁)、作用域(request隔离)、线程安全(每个请求独立实例,天然无共享冲突)三者全部编排在了一起,是我在实际项目中最推荐的Web上下文方案。

5. 避坑实录:生命周期、作用域与线程安全的那些意外事故

5.1 事故一:Bean初始化里的耗时操作拖垮了整个容器启动

有一次排查启动慢,定位到某个@PostConstruct里做了一次第三方接口调用,对方服务超时,Spring启动硬等了一分钟。问题不在代码逻辑,而在生命周期的选择——初始化阶段处于容器启动关键路径上,任何耗时都会阻塞所有后续Bean的创建。

后来我做了两个改动:初始化方法里只做纯内存的准备工作,外部调用全部异步化;那些必须在启动时准备好的资源,改用ApplicationReadyEvent或ApplicationRunner在应用启动后统一执行。效果立竿见影。

5.2 事故二:循环依赖解决了一半,另一半靠调整设计

Spring通过三级缓存解决了大部分setter注入的循环依赖,但构造器注入的循环依赖是解决不了的。很多人一遇到BeanCurrentlyInCreationException就条件反射去加@Lazy,这是治标不治本。@Lazy是把其中一个Bean的依赖延迟到第一次真正调用时才去解析,相当于暂停了循环,但并没有消除设计上的互相依赖。

我的观点是:循环依赖一旦出现,要先反思这个设计是不是把两个职责边界不清的组件绑在了一起。常见的解法是引入一个第三方的注册中心或事件总线,让A不再直接依赖B,而是通过事件通知。等你有过几次"解耦后代码更清爽"的经验,就不会再依赖三级缓存了。

5.3 事故三:ThreadLocal的内存泄漏,藏在复用线程里

在线程池场景下,任务执行完如果没有清理ThreadLocal,被复用的线程会带着上一个请求的数据进入下一个请求。我见过最严重的事故是,一个后台任务系统里,任务A写入的会话标识被任务B读到,导致B记录了完全错误的归属人。

教训很简单:使用ThreadLocal必须在finally块里显式调用remove()。Spring的TransmittableThreadLocal可以解决值传递问题,但清理仍然是必须的。这一点写不写是你的选择,线上炸不炸是它的选择。宁可多写一行finally,也不要去赌运气。

5.4 事故四:prototype注入singleton后变成"一次性僵死"

前面说的"假prototype"场景,我实际遇到的案例是:一个PDF生成器声明成prototype,里面保存了本次导出过程中的页码状态。在singleton的导出Service里直接注入它,结果不同线程同时导出时,页码互相覆盖,文件内容错乱。

排查时我先确认了PDF生成器确实是prototype作用域,再确认了端点调用逻辑没问题,最后才发现注入时机才是罪魁祸首。修复方式是把直接注入改成ObjectProvider<ReportGenerator>,每次需要时调getObject()。这个方法对"每次要新实例,但不想直接操作容器"的场景最为优雅。

6. 从这些概念走到项目落地:我的几个使用心得

写到这里,我想分享几个在实际代码里非常有用、但往往不会写在官方文档里的体会。

6.1 用Spring的生命周期事件来协调多Bean之间的启动顺序

Bean的@PostConstruct只保证自己的初始化,不保证别人已经初始化完成。如果系统里有"A要先加载数据,B才能读取,C才能启动调度"这样的依赖链,不要用@DependsOn硬扛。我的经验是配合SmartInitializingSingleton或者ApplicationReadyEvent来做阶段化启动,先加载基础数据,再启动业务模块,最后启动对外接口。这样既绕开了Bean创建顺序的泥潭,也让整个启动过程可以观测、可回放。

6.2 作用域设计要有"边界感"

一个Bean用什么作用域,应该看它的职责和状态特征,而不是看它的代码量。无状态且共享读的,用singleton;单次任务内部有状态的,用prototype;跟着请求走的,用request;跟着会话走的,用session。实践久了你会发现大部分业务代码其实都能压成无状态方法,prototype和request往往是兜底方案而不是首选方案。

6.3 线程安全没有银弹,但组合拳很有效

我个人这几年做下来最稳的组合是:核心业务方法无状态化 + 共享数据结构用并发容器 + 状态信息优先放请求作用域,尽量别用ThreadLocal。这个组合下,代码readable、可测试、并发问题显著少于动不动就加锁的项目。

如果你读了这篇文章之后只能记住一件事,那就记住这句:Spring Bean的生命周期、作用域和线程安全从来不是三个独立的考试知识点,它们共同决定了你的对象从出生到退休之间的每一个选择是否合理。下次遇到Bean相关的问题,希望你先问自己三个问题:这个Bean是什么时候创建的、它被几个人共享、它的状态安不安全。三个问题自问完,一半的问题已经有了答案。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询