ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Spring Bean Scope 全解析:从 singleton 到 prototype 的创建机制与实战陷阱

Spring Bean Scope 全解析:从 singleton 到 prototype 的创建机制与实战陷阱

Spring 的 Scope 这个知识点,我印象里很多人是"面试背概念,写代码全默认"。直到某天线上一个单例 Service 注入的 prototype Bean 出了并发状态串号的问题,我才真正意识到:Scope 不是在讨论"一个对象能活多久",而是在决定程序每次向容器要一个名字时,容器是递给你同一个老面孔,还是现场给你捏个新人。这篇文章就是围绕"Spring 中不同 Scope 的 Bean 创建机制"展开的,我会把 singleton、prototype、request/session 这些 Web 作用域、自定义 Scope,以及它们背后最值得注意的创建时机、代理机制和生命周期差异一次说透。不管你是刚上手 Spring Boot 的新人,还是写了好几年业务但没怎么碰过高级用法的人,看完应该都能在项目里做出更稳的选择。

1. Scope 的本质:Bean 定义与实例之间的"一对多"关系

很多人一开始会把"Bean"当成"已经被创建出来的那个对象",但 Spring 里其实有两个完全不同的东西:BeanDefinition 和 Bean 实例。不理解这层区别,Scope 是怎么运作的就永远隔着一层纱。

1.1 BeanDefinition 只是一张"图纸"

Spring 容器启动的时候,并不会立刻把你所有业务类都 new 出来。它首先做的是"读图纸":扫描 @Component、@Service、@Repository、@Controller,解析 @Bean 方法,或者读 XML 里的 bean 标签,统统转换成一个个 BeanDefinition,注册到容器内部的注册表里。BeanDefinition 里记着这个 Bean 的类名、是否延迟加载、初始化方法是什么、销毁方法是什么、自动装配模式是什么,当然也包括 Scope。

这里有个很关键的认知:BeanDefinition 本身不产生任何实例。你可以把 BeanDefinition 想象成餐厅菜单上的一道菜名,它不意味着后厨已经做好了一盘菜放在保温柜里,只代表"客人点了这道菜,厨房有本事做出来"。Spring IoC 的核心思想本来就是"先收集元数据,再按需创建对象",而不是启动时把所有可能用到的对象一股脑全造出来。

1.2 真正触发创建的是 getBean() 和依赖注入

那实例到底是什么时候创建的?真正的触发点是 getBean() 调用,以及依赖注入。你在任意代码里做@Autowired注入,或者通过ApplicationContext.getBean("xxx")去拿,最终都会走到AbstractBeanFactory.doGetBean()这个方法。

在这个方法内部,Spring 会先看一眼目标 Bean 的 Scope 是什么:

  • 如果是 singleton,它会先去单例缓存里找,找不到才走创建逻辑;
  • 如果是 prototype,它不会查任何缓存,直接调用 createBean 创建新实例;
  • 如果是 request、session 这类自定义 Web 作用域,它会从当前作用域容器里找,找不到再创建。

所以说,Scope 控制的核心可不是"这个 Bean 能活多久",而是"容器在收到创建请求后,如何决定返回已有对象还是新对象"。prototype 翻译成"原型"其实有点误导,它更像一次性地"每次新建",而 singleton 的"单例"则是"全局缓存一份,永远复用"。想透彻了这一点,后面再聊创建时机就顺了。

还有一个细节容易被忽略:容器在启动过程中会主动"预实例化"一部分 Bean。在AbstractApplicationContext.finishBeanFactoryInitialization里有个preInstantiateSingletons方法,它会遍历所有注册的 singleton BeanDefinition,把那些非抽象、非懒加载的 Bean 全部提前拿到手。这也是为什么你偶尔只是启动一个 Spring Boot 项目,却感觉好像一下子创建了成百上千个对象——它们大多是被预实例化的 singleton,而不是 prototype。prototype 不会有这种待遇,它是"懒"到极致的,你不主动 getBean 或者找容器注入,它永远不出现。

2. singleton 与 prototype:创建时机、内存模型和选择依据

这两种 Scope 是日常开发里出现频率最高的,也是大多数面试题喜欢挖的点。它俩的差别,表面是"一个还是多个",实际是容器在创建机制上的完全不同的两条路线。

2.1 singleton 的三级缓存,到底在缓存什么

singleton 是 Spring 的默认 Scope,绝大多数业务类——Service、Repository、Controller、配置类——都应该是 singleton。原因是业务服务通常是无状态的:同一个实例可以被任意多个线程安全地复用,不创建新对象,省内存、省 GC,性能也最好。

singleton 的创建机制主要在DefaultSingletonBeanRegistry里。容器维护了三个相互独立的 Map,俗称"三级缓存":

缓存名称作用
第一级singletonObjects存放创建完成、已经经历过完整生命周期的成品 Bean
第二级earlySingletonObjects存放提前暴露的半成品 Bean,为了支持循环依赖
第三级singletonFactories存放 ObjectFactory 工厂,用于生成提前暴露的早期引用

如果一个 singleton Bean 在创建过程中依赖了另一个 singleton Bean,而对方又反过来依赖它,就会形成循环依赖。三级缓存解决的就是这个问题:A 创建到一半,先把一个"早期引用"借给 B 用;B 完成后 A 再继续初始化。这里要提醒一句,循环依赖虽然技术上能解,但业务上往往是设计出问题的信号,能避免还是尽量避免,别把三级缓存当成纵容耦合的挡箭牌。

默认情况下,singleton 在容器启动时就完成预实例化。如果你不想某个单例那么"积极",可以给它加@Lazy注解,让它在第一次被使用的时候才创建。但@Lazy只是延迟了创建时机,并没有改变"全局只有一个实例"这个本质。

2.2 prototype 的创建流程:每次都走完整生命周期

prototype Bean 的创建逻辑要简单粗暴得多:每次 getBean 都是一个新的开始。在doGetBean里,Spring 检测到是 prototype 后,直接走 createBean 方法,走完整个 Bean 生命周期:实例化、属性填充、执行各种 BeanPostProcessor、执行 Aware 回调、执行 @PostConstruct 和 init-method。创建完就把对象交给调用方,自己这边不留存根,不缓存,不记录引用。

很多人以为 prototype 就是"new 一个对象",这么理解只对了一半。它确实每次都是新的,但它依然享受 Spring 的依赖注入、AOP 增强、注解回调等一系列容器能力。区别只在于:singleton 对同一个名字只会执行一次完整的创建,然后所有调用者共享那一个结果;prototype 对同一个名字可以在不同时间点创建出无数个彼此无关联的个体。

还有个很重要的坑:prototype 的创建时机是"第一次被获取"或者"每次被注入"。如果你在一个 singleton 的构造函数里注入了 prototype,实际上只会在那个 singleton 创建时注入一次,之后这个 singleton 一直抱着那个"新"实例不放——这跟直觉完全相反。很多人栽在这上面,后面第 5 章我会专门讲怎么正确地把 prototype 用起来。

2.3 Scope 选择,从什么角度去考虑

直接给一张对比表,方便你做选型:

对比项singletonprototype
实例数量整个容器最多一个,或每个容器一个每次获取都会新建
创建时机默认容器启动时预创建,或首次获取时每次 getBean 时
缓存有,三级缓存中的单例池没有缓存,创建完即交付
销毁管理容器关闭时统一销毁,执行回调容器完全不跟踪,不执行销毁回调
线程安全要求必须无状态或自身安全每次独立实例,天然隔离,但长期持有仍需小心
典型场景Service、Repository、配置类有状态轻量对象、复杂组合依赖、临时执行器

实际项目里,我的建议是:默认全用 singleton,只有在"这个 Bean 本身需要保存每次会话特有的状态"并且"状态不能由方法参数传递"时,才考虑 prototype。Spring 社区里有一句经验之谈:如果你发现自己在用 prototype 解决并发问题,第一件事不应该是改 Scope,而是检查是不是把可变状态放在了不该放的地方。prototype 是工具,不是兜底方案。

3. request/session/application/websocket:Web 作用域的创建机制与代理模式

进入 Web 环境之后,Scope 的玩法又不一样了。Spring 在内置容器里预注册了 request、session、application、websocket 这几个 Web 作用域,它们的命名很直观,但背后的创建机制和代理机制如果不搞清楚,踩坑是分分钟的事。

3.1 Web 作用域的本质:把 Bean 塞进"本来就有的作用域容器"

以 Spring Boot 内嵌容器为例,容器在启动过程中会检测到 Web 环境,并把几个 Scope 对象注册到 BeanFactory:RequestScope、SessionScope、ApplicationScope、WebSocketScope。你可以去翻 Spring 源码,这几个类的实现其实都不复杂,底层逻辑高度一致——去对应的存储容器里找,找不到就调用 ObjectFactory 创建,然后把新实例放回去。

Web 作用域实例存放的位置生命周期边界
requestHttpServletRequest 的 attribute一次 HTTP 请求
sessionHttpSession 的 attribute一个用户会话
applicationServletContext 的 attribute整个 Web 应用
websocketWebSocketSession 的 attribute一个 WebSocket 连接会话

举个具体例子,RequestScope 的 get 方法大致是这样:先通过 RequestContextHolder 拿到当前线程绑定的请求对象,从请求的 attribute 里取名为 beanName 的实例;如果取不到,就执行 objectFactory.getObject() 创建,再把新实例放回 attribute。整个过程对使用者来说是透明的,你只需要声明@Scope("request"),剩下的交给容器。

这里要特别说一句:application 和 singleton 在"数量"上很像,都是整个应用基本一份实例,但语义上不一样。singleton 是"容器级"的单例,当你部署多个应用共享同一个容器进程时(比如一个传统应用下挂多个 Web 应用),每个 Web 应用会有自己独立的 ApplicationContext,所以 singleton 在每个应用里各有一份。而 application scope 是直接绑定在 ServletContext 上的,以 Web 应用为单位存储。不过在 Spring Boot 单应用场景下,两者差别并不大,主要是在传统多 Web 应用部署时才有区分意义。

3.2 Scoped Proxy:为什么"注入 request Bean"必须要代理

假设现在有一个 singleton 的全局配置服务,它想注入一个@Scope("request")的当前用户信息 Bean。问题来了:singleton 是在容器创建时被实例化的,那一刻根本不一定有 HTTP 请求在飞;就算有,注入进来的也只是"那一次请求"的对象。之后换一个用户再来,singleton 手里的实例还是上一个用户的,这不就串了?

Spring 的解决方案是 Scoped Proxy:注入进来的是一个代理对象,而不是目标 Bean 本身。这个代理在每次方法调用时,都会从 RequestContextHolder 重新获取当前请求,再从当前请求的 scope 容器里取出真正的目标对象,然后把调用转发给它。

这样设计之后,singleton 持有的永远是一个"取货码",每次用的时候拿取货码到当前请求里去换真货。实现方式有两种:

  • ScopedProxyMode.INTERFACES:JDK 动态代理,要求目标类必须实现至少一个接口;
  • ScopedProxyMode.TARGET_CLASS:CGLIB 子类代理,不要求接口,但目标类不能是 final。

实际开发里,如果你直接写@Scope("request")而没配 proxyMode,Spring 在注入到 singleton 的时候往往会直接报错,提示"no thread-bound request found"或类似的问题。原因就是它真的去尝试获取当前请求了,但当前根本不在请求生命周期中。正确写法是:

@Component @Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS) public class CurrentUser { private String userId; // getter/setter }

这样 CurrentUser 的调用方只要注入 CurrentUser,Spring 自动给你塞一个代理,所有方法调用都发生在"当前请求"这个上下文里。

3.3 代理模式在使用中的几个隐性坑

代理模式虽然好用,但引入代理对象之后,一些平时被忽略的细节会冒出来。

第一个是 self-invocation 问题。CGLIB 生成的代理是目标类的子类,代理对象调用目标方法时,能生效的前提是"方法调用从代理对象发起"。如果你在代理对象内部,另一个方法内部直接调用了 this 的同类的另一个方法,那就绕过了代理层,不会重新走 scope 获取逻辑。这和事务注解上的 self-invocation 失效是同一个坑。

第二个是 identity 问题。代理对象和真实对象不是同一个引用,你拿注入的 CurrentUser 和从 request attribute 里手动取出来的实例做==比较,得到的是 false。做 instanceof 检查通常没问题,但如果有人用equals()或hashCode()做业务判断,也可能出现意外结果,因为这些方法如果没有在代理里特殊处理,走的是代理类自身的逻辑。

第三个是序列化问题。CGLIB 代理类要支持序列化得满足额外条件,如果你把这个 Bean 往 Redis 里写,或通过远程接口传出去,很可能因为代理对象里套着一个复杂的目标对象引用而在序列化时踩坑。稳妥做法是:不让 Web 作用域 Bean 跨界传出去,远程调用前转成 DTO 或者普通值对象。

4. 自定义 Scope:当内置 Scope 不够用时的扩展机制

说实话,大部分项目一辈子都用不上自定义 Scope。但一旦你遇到了"每个线程都要有自己的专用实例"或者"每个租户要有独立上下文"之类的需求,你就能体会到一个通用扩展点有多救命。Spring 的 Scope 接口设计得非常干净,扩展一个作用域本质上就是实现一个接口,再注册一下。

4.1 Scope 接口的五个方法,各自的职责是什么

public interface Scope { Object get(String name, ObjectFactory<?> objectFactory); Object remove(String name); void registerDestructionCallback(String name, Runnable callback); Object resolveContextualObject(String key); String getConversationId(); }

逐个拆开看:

  • get:从当前作用域存储里找名为 name 的实例,找不到就调用 objectFactory.getObject() 创建,这是整个 Scope 的创建入口;
  • remove:把指定实例从当前作用域移除,返回被移除的对象;
  • registerDestructionCallback:注册一个销毁回调,当作用域结束、对象需要被销毁时执行,这是自定义 Scope 管理 bean 生命周期的关键;
  • resolveContextualObject:按 key 解析出当前作用域上下文里的某个对象,一般用来暴露上下文信息,比如从 request scope 里解析出 HttpServletRequest;
  • getConversationId:返回当前作用域的标识 ID,Spring 会话管理相关功能会用到。

4.2 手写一个线程级 Scope:让每个线程都拥有独立 Bean

我来演示一个最常见的自定义场景:线程级 Scope。需求很简单:同一个 Bean,每个线程拿到的都是自己线程专属的实例,线程之间互不干扰。实现思路就是用一个 ThreadLocal 包一层 Map。

public class ThreadScope implements Scope { private static final ThreadLocal<Map<String, Object>> THREAD_SCOPE = ThreadLocal.withInitial(HashMap::new); @Override public Object get(String name, ObjectFactory<?> objectFactory) { Map<String, Object> map = THREAD_SCOPE.get(); Object instance = map.get(name); if (instance == null) { instance = objectFactory.getObject(); map.put(name, instance); } return instance; } @Override public Object remove(String name) { return THREAD_SCOPE.get().remove(name); } @Override public void registerDestructionCallback(String name, Runnable callback) { // 线程结束时的清理逻辑,后面再说 } @Override public Object resolveContextualObject(String key) { return null; } @Override public String getConversationId() { return String.valueOf(Thread.currentThread().getId()); } }

注册这个 Scope 到容器里,可以借助 BeanFactoryPostProcessor:

@Configuration public class ThreadScopeConfig { @Bean public static BeanFactoryPostProcessor threadScopeRegister() { return beanFactory -> { if (beanFactory instanceof ConfigurableBeanFactory) { ((ConfigurableBeanFactory) beanFactory).registerScope("thread", new ThreadScope()); } }; } }

之后需要线程级实例的 Bean,加注解就行:

@Component @Scope("thread") public class ThreadContextHolder { // 每个线程独立的字段 }

这个方案在模拟并发请求、测试隔离、批量任务分片等场景下非常好用。

4.3 自定义 Scope 时最容易忽略的三个细节

第一是清理。ThreadScope 如果不主动清理 ThreadLocal,在线程池复用线程时,上一次任务的残留数据会漏到下一个任务里,这比内存泄漏更隐蔽也更危险。所以最好在任务跑完时显式调用 remove,或者注册回调,在线程结束时统一清理。

第二是销毁回调。Scope 接口里 registerDestructionCallback 的意义就是让作用域结束时有地方安放销毁逻辑。比如线程级作用域,你要在线程结束的时候遍历 Map,逐个执行注册进去的 Runnable,实现类似 @PreDestroy 的效果。Spring 只保证 singleton 的销毁回调由容器调用,自定义 Scope 的销毁时机完全取决于你自己。

第三是代理模式。自定义 Scope 一样会遇到"高作用域 Bean 注入低作用域 Bean"的问题,如果你的 thread Bean 要被 singleton 注入,同样需要给 thread Bean 加 proxyMode,否则这个线程级隔离就会失效。机制和 request 的代理完全一致,理解了一个就理解了全部。

5. prototype 依赖的"生命周期陷阱":为什么容器不帮你销毁它

这是 prototype 使用中争议最大的地方,也是网上问题最多的点之一。很多人以为 prototype Bean 既然由容器创建,销毁也应该由容器统一处理,结果写了个 @PreDestroy 方法,容器关闭时一看,压根没执行。

5.1 初始化回调执行,销毁回调却不执行的真相

先看结论:prototype Bean 的初始化生命周期(@PostConstruct、InitializingBean、init-method)会执行,但销毁生命周期(@PreDestroy、DisposableBean、destroy-method)不会执行。

原因其实在容器实现上:singleton 实例创建完成后,Spring 会把它登记到"可销毁实例"清单里,持有引用,等容器关闭时统一清理。但 prototype 实例创建完之后,容器直接把引用交给你了,自己没有保存任何记录。等容器关闭时,它根本没有办法知道你手上还有哪些 prototype 实例,自然也无从执行销毁回调。

这一点在官方文档里写得很直接:该作用域不负责 prototype Bean 的销毁,调用方负责资源清理。所以如果你让一个 prototype Bean 持有了数据库连接、文件句柄、网络流,用完不自己关,是真的会泄漏的,容器帮不上忙。

5.2 把 prototype 正确注入 singleton 的四种姿势

前面提过,直接在 singleton 里 @Autowired 一个 prototype,结果只会注入一次。那正确的做法是什么?我按推荐程度排序:

第一种,在 singleton 方法里直接用 new。如果这个 prototype 不依赖别的 Spring Bean,那 new 是最简单可靠的,不折腾容器,性能还好。缺点是放弃了依赖注入和 AOP 能力。

第二种,通过 ApplicationContext 手动获取。注入 ApplicationContext,每次需要时调用context.getBean(PrototypeBean.class)。能用,但对容器 API 侵入强,测试也不方便。

第三种,用 ObjectProvider。Spring 4.3 起推荐的方式:

@Service public class OrderService { private final ObjectProvider<PriceCalculator> calculatorProvider; public OrderService(ObjectProvider<PriceCalculator> calculatorProvider) { this.calculatorProvider = calculatorProvider; } public void calculate(Order order) { PriceCalculator calculator = calculatorProvider.getObject(); // 每次拿到的都是新实例 } }

第四种,用 @Lookup 方法注入。这在需要"以方法为粒度每次新实例"时很优雅:

@Service public class OrderService { public void calculate(Order order) { PriceCalculator calculator = createCalculator(); // 每次调用返回的是新实例 } @Lookup public PriceCalculator createCalculator() { // 这里写死返回 null 即可,Spring 会生成子类并重写 return null; } }

@Lookup 的原理也是 CGLIB 子类代理,所以 OrderService 这个类不能是 final,方法是 public 效果最好。这两种方式我实际项目里都用过,ObjectProvider 更推荐,因为它不要求类满足代理条件,语义也清晰。

5.3 资源型 prototype 的清理建议

如果你的 prototype Bean 确实持有需要释放的资源,我的习惯是这样的:让 Bean 实现 AutoCloseable,然后在调用方用 try-with-resources 或者在 finally 块里显式关闭。

@Scope("prototype") @Component public class BatchWriter implements AutoCloseable { private BufferedWriter writer; @Override public void close() { // 释放资源 } }

调用方:

try (BatchWriter writer = provider.getObject()) { writer.write(batch); }

如果你更喜欢"由容器统一管理清理"的体验,可以考虑把这种 Bean 的 Scope 改成 request 或自定义的会话 Scope,让作用域结束成为销毁回调的触发点。但一般业务里,显式关闭是更符合直觉的做法,别把所有生命周期责任都甩给容器。

6. 实战排查:三类常见 Scope 错误的表现与根因定位

写代码的时候 Scope 的问题很难一眼看出来,因为它经常是"代码能跑,但行为诡异"。我把过去几年见得最多的四类问题整理出来,每一条都带上排查思路,帮你遇到类似现象时能快速定位。

6.1 现象一:singleton 里的状态在并发下互相串台

有用户反馈,多个并发请求同时操作同一个业务组件时,数据出现了互相覆盖。排查链路是这样走的:

第一步,先看这个组件的 Scope。如果它是 singleton,再往下看;第二步,检查组件里有没有可写实例字段。如果有一个很典型的 pageNum、currentCount、tempCache 之类的字段,问题几乎就锁定了:多个线程共享同一个组件实例,状态被彼此改写。

这种问题有两个层面的解法。第一层,把问题 Bean 改成 prototype,让每个线程各用各的实例。但要注意的是,如果调用方是 singleton,前面的"注入一次"陷阱会让你改了个寂寞,必须配合 ObjectProvider 或 @Lookup 使用。第二层,也是我更推荐的:重构掉可写状态。把共享数据从实例字段挪到方法参数里,Bean 保持无状态,这远比依赖 Scope 切换要稳。当时我们排查后发的根因其实就是"有人在 singleton 的成员变量里放了个分页游标"——那不是 Scope 的锅,是设计没做对。

6.2 现象二:启动时或首次注入时报 No thread-bound request found

这个报错信息非常典型,翻译过来就是"线程上没有绑定请求对象"。触发场景一般是:一个 @Scope("request") 的 Bean 被某个 singleton 直接在字段注入,而容器在启动阶段或非请求线程中初始化这个 singleton。

这时候 Spring 去获取 request Bean,发现当前线程根本没有绑定 HTTP 请求上下文,于是异常直接抛出。解决办法很明确:

  • 如果是 Web 作用域 Bean 注入 singleton,加proxyMode = ScopedProxyMode.TARGET_CLASS;
  • 或者干脆把注入改成 ObjectProvider,延迟到真正有请求的时候再 getObject。

这里有个判断经验:报错如果发生在启动阶段,多半是字段直接注入导致预实例化提前触发;如果发生在异步线程里,往往是请求上下文没传递过去。两种场景处理方式不同,先确认触发时机再动手。

6.3 现象三:prototype 之间形成循环依赖,容器直接拒绝

singleton 的循环依赖靠三级缓存能救回来,但 prototype 的循环依赖是救不了的。如果你让两个 prototype Bean 相互注入,Spring 会抛出 BeanCurrentlyInCreationException。

道理也不复杂:prototype 创建时根本不缓存也不提前暴露早期引用,A 创建到一半需要 B,B 创建到一半又需要 A,而 A 此时还在创建中且没有任何可以被提前拿出来的对象。Spring 发现你又在创建一个"正在创建中"的 prototype,直接判定循环创建,报错终止。

遇到这种问题,别想着配置层面绕过去,正确做法是重构对象依赖关系。拆掉 A 和 B 之间的循环引用,把其中一个改成非构造函数注入,或者让其中一个变成 singleton 作为门面,prototype 只依赖它而不反向依赖。Spring 官方对 prototype 循环依赖的态度就是:不支持,也不打算支持。

6.4 现象四:异步线程里调用 request Bean,上下文说丢就丢

最后分享一个特别常见的场景:你在接口里把一个含业务逻辑的任务丢进了 CompletableFuture.runAsync 或线程池,然后在异步任务里调用了注入的 @Scope("request") Bean,结果抛出了和 6.2 类似的"No thread-bound request found"。

原因在于 RequestContextHolder 默认就是 ThreadLocal 实现的,主线程的请求上下文不会自动传递给子线程。这是我个人踩得最多的一次:接口里把当前用户的 request Bean 传进了异步任务,结果子线程里拿到的是个代理,代理想从线程上找请求属性,结果发现这个新线程压根没有绑定任何请求。

解决思路有两个方向:

  1. 在提交异步任务之前,把当前 RequestContextHolder 的 attributes 取出来,放进子线程再设置回去,用完之后恢复。线程池任务类或者用一个 TaskDecorator 统一处理会比较好维护。
  2. 更简单的是,跨线程传递不是 Scope 该干的事。在进入异步任务之前,把 request Bean 里的数据转成一个普通的值对象或 DTO 传进去,异步任务里不要依赖任何 Web 作用域 Bean。这个方法虽然没有"技术含量",但在生产环境最不容易出幺蛾子。

我后来定了一条团队习惯:Web 作用域的 Bean 只活在"请求处理的主链路"里,凡是出了这条链路,要么转值对象,要么重新从当前上下文取,绝不隐含地跨线程依赖。这条习惯执行下来,异步加多线程场景里的诡异 bug 少了大半。

最后再分享一个很实用的小原则:在项目里,我默认所有 Bean 都是 singleton,如果要用 prototype,就从 ObjectProvider 获取,并且明确知道自己在为谁承担销毁责任;如果是 Web 作用域,要么用代理,要么只用 ObjectProvider 在请求线程内取用;而自定义 Scope,则永远是"内置 Scope 确实覆盖不了需求"时的最后一张牌。把这几个大原则刻进日常编码习惯里,Scope 给你带来的惊喜就会远远少于惊吓。

RELATED READING

延伸阅读

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