Spring Bean 作用域:singleton、prototype、request、session 与线程安全(084)

Spring与Spring Boot约 15 分钟阅读8,311 字符
知识库文档 85 / 251

一、为什么作用域比生命周期更容易踩坑

前三篇分别讲了容器如何完成依赖注入(081)、Bean 从"半成品"到"成品"要经过哪些阶段(082)、以及容器如何解析配置并驱动这一切(083)。这些内容回答的都是"一个 Bean 怎么被造出来"。而作用域(Scope)回答的是另一个问题:"容器要造几个、什么时候造、给谁用"——这个问题看起来简单,但恰恰是线上并发 bug 的重灾区。

一个真实的场景:某个 @Controller 里定义了一个成员变量当"缓存"用,本地测试单线程跑没有任何问题,上线后并发量一高,用户 A 的查询结果出现在用户 B 的响应里。排查半天,根源就一句话:Controller 默认是 singleton,所有请求共享同一个实例,那个"缓存"变量成了所有线程共享的可变状态。这类问题不会在编译期报错,也不一定在测试环境暴露,是理解作用域和线程安全关系的意义所在。

二、Spring 内置的作用域一览

Spring 容器原生支持 6 种作用域,但常用的是前两种,后四种依赖 Web 环境:

作用域 创建时机 生命周期跨度 是否需要 Web 环境
singleton(默认) 容器启动时(非懒加载) 与容器同生共死 否
prototype 每次 getBean() 调用 由调用方自行管理,容器不追踪 否
request 每次 HTTP 请求 一次请求的处理周期内 是
session 每次 HTTP Session 创建 用户会话周期内 是
application ServletContext 生命周期内一个 与 Web 应用同生共死(近似全局 singleton) 是
websocket 每个 WebSocket 连接 一次连接周期内 是(且需 WebSocket)

题目聚焦的 singleton / prototype / request / session 四种,恰好覆盖了"容器全局共享"到"每次使用都是新对象"再到"和外部请求生命周期绑定"这三种典型模式。

文章配图
文章配图

三、singleton:容器里最常见但最容易被误解的作用域

3.1 "singleton" 不是 GoF 单例模式

第一个要澄清的误区:Spring 的 singleton 和设计模式里的单例模式(Singleton Pattern)不是一回事。GoF 单例模式保证整个 JVM 进程只有一个实例(通常靠私有构造器+静态字段实现);Spring 的 singleton 只保证**在当前容器(ApplicationContext)**内,同一个 bean name 只对应一个实例。如果你在同一个 JVM 里创建了两个 ApplicationContext,同一个类完全可以在两个容器里各有一个"singleton"实例,互不相关。

3.2 源码层面:singletonObjects 缓存

singleton Bean 的核心实现在 DefaultSingletonBeanRegistry 里,本质是一个三级缓存结构(这也是解决循环依赖的基础设施,086 会专门展开):

java
// 一级缓存:完全初始化好的单例
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);

// 二级缓存:已实例化但还没完成属性填充/初始化的早期引用
private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(16);

// 三级缓存:单例工厂,用于生成提前暴露的引用(可能是代理对象)
private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);

getSingleton(beanName) 的核心逻辑是:先查一级缓存,有就直接返回;没有才走 createBean() 的完整流程,创建完成后放入一级缓存,并从二三级缓存中清除。这意味着第二次、第三次……第 N 次**getBean("someSingleton")**,本质都是在查一次**ConcurrentHashMap**,开销极小,这也是 singleton 作为默认作用域的性能考量之一。

3.3 线程安全性:取决于状态,不取决于作用域本身

这是最容易被问到、也最容易答错的点:singleton 本身不保证线程安全,它只保证"只有一个实例"。线程安全与否,完全取决于这个实例内部有没有可变的共享状态。

java
// 反例:线程不安全的 singleton
@Service
public class OrderQueryService {
    private String lastQueryUserId; // 危险!所有线程共享这个字段

    public Order query(String userId) {
        this.lastQueryUserId = userId; // 线程 A 刚设置完
        // ... 线程 B 在这里把 lastQueryUserId 改成了别的值
        return doQuery(this.lastQueryUserId); // 线程 A 读到的可能是线程 B 的 userId
    }
}

// 正例:无状态,天然线程安全
@Service
public class OrderQueryService {
    public Order query(String userId) { // 依赖参数传递,不依赖实例字段
        return doQuery(userId);
    }
}

判断一个 singleton 是否线程安全,只需要问一句:这个类有没有会被多个线程同时读写的实例字段? 只依赖方法参数和无状态的依赖对象(Repository、其他 Service)是安全的;持有可变的实例字段(哪怕只是一个 int 计数器)就需要额外的同步手段(synchronized、AtomicInteger、ThreadLocal),或者干脆把状态挪到方法局部变量、数据库、Redis 等地方。

文章配图
文章配图

四、prototype:每次都是全新的实例,但容器"不管生死"

4.1 基本行为

java
@Component
@Scope("prototype")
public class ReportBuilder {
    private List<String> sections = new ArrayList<>(); // 有状态没关系,反正每次都是新实例
}
java
ReportBuilder r1 = context.getBean(ReportBuilder.class);
ReportBuilder r2 = context.getBean(ReportBuilder.class);
System.out.println(r1 == r2); // false —— 两个完全独立的对象

每次 getBean()(或被依赖注入触发的按需创建)都会执行一次完整的 Bean 创建流程(082 讲过的实例化 → 属性填充 → 初始化),得到一个全新对象。这让 prototype 天然适合"有状态但状态不该被共享"的场景,比如构建器、有状态的会话上下文对象。

4.2 容器不追踪销毁:这是最容易踩的坑

singleton 的销毁回调(@PreDestroy、DisposableBean.destroy())会在容器关闭时被容器主动调用,因为容器全程持有这个实例的引用。但 prototype Bean 一旦创建交给调用方,容器就不再持有引用,也就不会在容器关闭时调用它的销毁方法——因为容器根本不知道这个实例后来被传到哪里、是否还活着。如果 prototype Bean 持有需要主动释放的资源(连接、文件句柄),必须自己在业务代码里管理销毁,容器帮不上忙。

4.3 更隐蔽的坑:在 singleton 里注入 prototype,为什么"新建"变成了"复用"?

java
@Service // 默认 singleton
public class ReportService {
    @Autowired
    private ReportBuilder reportBuilder; // prototype

    public Report generate() {
        return reportBuilder.build(); // 问题:这里用的永远是同一个 reportBuilder!
    }
}

依赖注入只在 ReportService 被创建时执行一次,reportBuilder 字段在那一刻被赋值为某个 ReportBuilder 实例,之后 ReportService 是 singleton,这个字段值永远不会再变——所以看起来注入的是 prototype,实际用起来和 singleton 没有区别。三种正确解法:

java
// 方案一:@Lookup 方法注入(每次调用都会触发容器重新 getBean)
@Service
public abstract class ReportService {
    @Lookup
    protected abstract ReportBuilder createBuilder();

    public Report generate() {
        return createBuilder().build(); // 每次都是新实例
    }
}

// 方案二:注入 ObjectFactory / ObjectProvider,运行时按需获取
@Service
public class ReportService {
    @Autowired
    private ObjectProvider<ReportBuilder> builderProvider;

    public Report generate() {
        return builderProvider.getObject().build();
    }
}

// 方案三:scoped-proxy —— 注入的是一个代理,代理内部每次方法调用都转发到新实例
@Component
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class ReportBuilder { }

三种方案本质相同:把"什么时候拿实例"从"注入时刻"推迟到"实际调用时刻",只是实现方式不同(反射代理生成子类 vs 手动调用 Provider vs CGLIB 动态代理)。

文章配图
文章配图

五、request 与 session:和 Web 请求生命周期绑定的作用域

5.1 基本用法

java
@Component
@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS)
public class RequestContext {
    private String traceId;
    // 每个 HTTP 请求都会有独立的一个实例
}

@Component
@Scope(value = WebApplicationContext.SCOPE_SESSION, proxyMode = ScopedProxyMode.TARGET_CLASS)
public class UserSessionData {
    private String userId;
    // 同一个用户 Session 内共享,不同用户互相隔离
}

5.2 关键机制:为什么 singleton 里能注入一个"每次都不一样"的 request-scope Bean?

这是这两种作用域最容易被问到的原理题:RequestContext 明明是 request 作用域(生命周期极短),但它却经常被注入到 @Service(singleton,长期存活)里,按理说会重演上一节 prototype 的"注入时机错位"问题——但 request/session scope 默认必须配合 proxyMode 使用,原因就在这里。

容器在启动时给 RequestContext 生成的其实不是真实对象,而是一个动态代理(CGLIB 子类代理,即 proxyMode = ScopedProxyMode.TARGET_CLASS 的效果)。这个代理被注入到 singleton Service 里,字段值确实"固定"了——但固定下来的是代理,不是真实对象。每次调用代理的方法时,代理内部逻辑是:

  1. 通过 RequestContextHolder(基于 ThreadLocal 存储当前线程绑定的 HttpServletRequest)拿到当前请求的上下文;
  2. 根据当前请求的 scope 标识,去这次请求专属的一个"作用域缓存"里查找真实的 RequestContext 实例(没有就创建一个,绑定到本次请求);
  3. 把方法调用转发(delegate)到这个真实实例上。

也就是说:singleton Service 里那个字段永远指向同一个代理对象,但代理对象每次转发的目标,会随着"当前是哪个线程/哪个请求"而变化。这正是"用一个稳定的引用,包住一个动态变化的目标"的经典代理模式应用。

5.3 request 与 session 的隔离边界

  • request scope:同一个请求内,无论调用链多深、经过多少个方法,取到的都是同一个 RequestContext 实例;请求结束(响应返回后),这个实例被销毁,不会跨请求复用。
  • session scope:同一个用户的多次请求(只要 Session 没过期),取到的都是同一个 UserSessionData 实例;不同用户的 Session 各自独立,天然隔离,不需要开发者手动加锁区分用户。

文章配图
文章配图

六、线程安全全景:把作用域和状态放在一张图里看

结合前面几节,可以把"作用域选择"和"线程安全"的关系归纳成一张决策表:

场景 推荐作用域 线程安全性来源
无状态的 Service/Repository/Controller singleton 没有可变共享状态,天然安全
需要按请求隔离的上下文数据(traceId、当前登录用户等) request(配合 proxy) 每个请求独立实例,物理隔离
需要跨请求但按用户隔离的数据(购物车、多步表单) session(配合 proxy) 每个 Session 独立实例,物理隔离
构建器、临时的有状态计算对象 prototype 每次全新实例,用完即弃
singleton 但确实需要少量线程本地状态 singleton + ThreadLocal 字段 手动隔离,需要自己在用完后 remove() 防止内存泄漏

最后一行是实践中常见但容易被滥用的方案:直接在 singleton 里加 ThreadLocal 字段,效果类似"手写一个 request scope",但要格外小心——线程池复用线程时,如果忘记在使用完毕后调用 remove(),ThreadLocal 里的数据会"泄漏"到下一个逻辑无关的任务里,这是比"忘记同步"更隐蔽的一类 bug。

文章配图
文章配图

七、自定义作用域:Scope 接口

如果内置的 6 种作用域都不满足需求(比如想实现一个"按租户隔离"的作用域),Spring 允许通过实现 Scope 接口自定义:

java
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();
}

实现后需要注册到容器:

java
@Component
public class CustomScopeConfigurer implements BeanFactoryPostProcessor {
    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) {
        beanFactory.registerScope("tenant", new TenantScope());
    }
}

request/session/application/websocket 这四种 Web 作用域,本质上也是通过这套 Scope 接口实现并注册进容器的(RequestScope、SessionScope 等类),并不是容器"硬编码"出来的特例——理解这一点,就能明白 Spring 的作用域体系是一套开放、可扩展的机制,而不是一份写死的枚举列表。

文章配图
文章配图

八、一张图看懂四种作用域的选型逻辑

把前面的内容串成一条决策路径:先问"这个 Bean 需要跨请求共享吗?"——不需要则看"需要每次都是新实例吗"(是→prototype,否→singleton,再检查有无共享可变状态);需要跨请求共享则看"共享范围是单个请求还是整个用户会话"(请求内→request,会话内→session)。

文章配图
文章配图

九、常见误区

  • "singleton 就是线程安全的" —— 不对。singleton 只保证一个实例,线程安全取决于这个实例是否持有可变共享状态。
  • "在 singleton 里注入 prototype 就能拿到多个实例" —— 不对。依赖注入只在 singleton 创建时执行一次,之后字段值不会变,除非用 @Lookup/ObjectProvider/scoped-proxy。
  • "request scope 的 Bean 需要手动从 request 里取,很麻烦" —— 不对。配合 proxyMode 使用后,可以像普通 singleton 一样直接注入,代理会自动处理"定位当前请求实例"的逻辑。
  • "prototype Bean 的**@PreDestroy**会被容器调用" —— 不对。容器创建后即"放手",销毁回调需要业务代码自己触发。

十、总结

Bean 作用域决定的是"容器造几个、给谁用、活多久",理解它是避免并发 bug 的第一道防线:

  1. singleton 是默认作用域,性能好,但线程安全性完全取决于是否持有可变共享状态。
  2. prototype 每次创建全新实例,但容器不追踪其销毁,且在 singleton 中直接注入会"降级"为固定实例,需要 @Lookup/ObjectProvider/scoped-proxy 解决。
  3. request/session 依赖 Web 环境,配合动态代理(proxyMode)实现"用稳定引用包住动态目标",天然按请求/会话物理隔离,是天然线程安全的状态隔离方案。
  4. 作用域体系基于开放的 Scope 接口,内置的 Web 作用域和自定义作用域走的是同一套注册机制。

延伸阅读

  • Spring 源码:DefaultSingletonBeanRegistry、AbstractApplicationContext.getBeanFactory().registerScope()、RequestContextHolder
  • 《Spring 揭秘》作用域与代理章节
  • 本系列相关篇目:081(Spring IoC 与 DI)、082(Bean 生命周期)、083(BeanDefinition 与容器刷新)、086(循环依赖)、087(Spring AOP 原理)

面试官视角提问(不计入阅读时间)

以下问题不是复述文档内容,而是模拟面试官会追问的场景题,检验你是否真正理解作用域机制,而不是背了一张对比表。

Q1:如果一个 prototype Bean 依赖了另一个 prototype Bean,每次**getBean()**外层 Bean 时,内层依赖是新建还是复用?

每次都是全新的。依赖注入发生在 Bean 创建流程内部(082 讲过的属性填充阶段),外层 prototype Bean 每次被创建都会重新走一遍完整的创建流程,其内部对内层 prototype 依赖的注入自然也是"重新 getBean()",所以两层都是全新实例。容易搞混的是"prototype 注入到 singleton 里会被固化"这条规则——它只发生在外层是 singleton 的场景,因为 singleton 只创建一次,注入动作只发生一次;prototype 外层本身每次都重新创建,注入动作也就每次都重新发生。

Q2:request scope 的 Bean 如果不加**proxyMode**,直接注入到一个 singleton Controller 里,启动时会发生什么?

容器初始化 singleton 时会立刻尝试解析这个依赖,但此时没有正在处理的 HTTP 请求(容器启动阶段根本不存在 Request 上下文),会抛出 BeanCreationException,内部原因通常是 Scope 'request' is not active for the current thread。这也是为什么 request/session 作用域几乎总是要求搭配 proxyMode = ScopedProxyMode.TARGET_CLASS(或接口场景用 INTERFACES)——代理本身在没有 Web 请求时可以正常创建和持有,只有真正调用代理方法时才需要 Request 上下文,把"何时需要请求上下文"这件事从"容器启动时"推迟到了"业务方法调用时"。

Q3:**@Async**异步方法里访问一个 request-scope 的 Bean,会不会拿到正确的数据?

大概率不会,这是线上很容易踩的坑。@Async 会把方法丢到另一个线程池的线程执行,而 RequestContextHolder 默认基于 ThreadLocal 存储当前请求上下文——新线程里根本没有这个 ThreadLocal 数据,代理转发时会找不到对应的真实实例,直接抛异常(或者如果配置了 inheritable,情况会更复杂,涉及线程池复用导致的上下文串用风险)。正确做法是在提交异步任务前,把需要的数据从 request-scope Bean 里显式提取出来,作为参数传给异步方法,而不是让异步方法自己再去访问 request-scope 依赖。

Q4:Spring MVC 的**@Controller**默认是 singleton,那为什么它可以正常处理并发请求而不用像本文讲的"有状态字段"那样加锁?

因为 Spring MVC 框架层面已经帮你把"每次请求的状态"都变成了方法参数(@RequestParam、@RequestBody、HttpServletRequest 参数等),而不是让你把这些数据存成 Controller 的实例字段。只要你自己不在 Controller 里额外定义可变的实例字段去存请求相关的数据,Controller 本身就是无状态的,天然线程安全。这也印证了本文的核心结论:线程安全不是作用域自动给的,是"无状态"这个设计选择给的,Spring MVC 只是帮你把常见场景的状态都设计成了方法参数。

Q5:如果要实现一个"同一个分布式事务内共享同一个连接"的作用域(类似事务级别的作用域),你会怎么设计?

这正是自定义 Scope 接口的典型应用场景。思路是:实现 Scope.get(name, objectFactory),内部先检查当前事务上下文(可以用 TransactionSynchronizationManager 绑定的资源,或者自己维护一个 ThreadLocal<Map<String, Object>> 作为"事务级缓存")里是否已有该 Bean 的实例,有则直接返回,没有则调用 objectFactory.getObject() 创建并存入缓存;registerDestructionCallback 里注册"事务提交/回滚后清理缓存"的回调。本质上和 request scope 的实现思路完全一致,只是把"绑定的生命周期"从 HTTP 请求换成了事务边界——这也说明理解了 Scope 接口和 request/session 的实现原理后,遇到新的"生命周期绑定"需求时是可以类比设计的。

Q6:**@Scope("prototype")**的 Bean 如果被 Spring 容器作为另一个 singleton Bean 的构造器参数注入(而不是字段注入),行为和字段注入有区别吗?

没有本质区别,同样会被"固化"。构造器注入和字段注入的差异只是依赖注入的方式(何时把值传进去),不改变依赖注入发生的时机——都是在外层 singleton Bean 被创建、构造函数执行的那一刻发生一次,之后这个引用(无论存在字段还是被构造器用掉)就不会再变。想让构造器注入也支持"每次拿新实例",同样只能引入一层间接性:把构造器参数类型换成 ObjectProvider<T> 或者接受一个代理对象,而不是直接注入目标类型本身。

Q7:session scope 的 Bean 什么时候会被真正销毁?如果用户关闭浏览器但 Session 还没过期,这个 Bean 会不会一直占用内存?

session-scope Bean 的销毁时机绑定的是 HttpSession 的失效(sessionDestroyed 事件),触发条件包括:Session 主动调用 invalidate()、达到配置的超时时间自动过期、或者容器重启。单纯关闭浏览器标签页不会主动通知服务器,Session 在服务端依然存活,直到超时时间耗尽才会被回收——这意味着如果 session-scope Bean 持有较重的资源(大对象、连接),且大量用户关闭浏览器却不等 Session 过期,服务端会有一段时间的内存/资源占用膨胀。这是设置合理的 Session 超时时间、以及避免在 session-scope Bean 里放过重对象的现实原因。