原创

Spring Bean 的作用域详解

在 Spring 里,Bean 的作用域指的是:同一个 BeanDefinition 被容器创建对象时,到底应该创建几个实例,以及这些实例在什么范围内被复用

很多人刚接触 Spring 时,会默认认为所有 Bean 都是“单例”。这个理解只对了一半。更准确地说,Spring 默认作用域是 singleton,但 singleton 只是 Spring 容器级别的单例,不等于 JVM 进程级单例,更不等于传统设计模式里严格控制构造的 Singleton 模式。把这件事搞清楚,后面理解 Bean 生命周期、线程安全、Web 请求隔离、Session 数据隔离,都会顺很多。

什么是 Bean 作用域

先看一个最核心的问题:当你多次从 Spring 容器中获取同一个 Bean 时,返回的是不是同一个对象?

答案取决于这个 Bean 的作用域。

Spring 常见作用域包括:

  • singleton
  • prototype
  • request
  • session
  • application
  • websocket

其中前两个属于最基础、最常用的作用域,后四个主要用于 Web 环境。

singleton:默认作用域

singleton 是 Spring 默认作用域。它表示:

  • 一个 Spring IoC 容器
  • 同一个 Bean 名称
  • 只会创建 一个实例
  • 后续注入或获取时,复用这一个对象

示例

@Component
@Scope("singleton")
public class UserService {
}

如果省略 @Scope,默认也是 singleton

测试代码:

AnnotationConfigApplicationContext context =
        new AnnotationConfigApplicationContext(AppConfig.class);

UserService bean1 = context.getBean(UserService.class);
UserService bean2 = context.getBean(UserService.class);

System.out.println(bean1 == bean2); // true

输出为 true,说明拿到的是同一个对象。

singleton 的本质

这里要特别注意,Spring 的 singleton 是:

  • 每个 IoC 容器一个实例
  • 不是整个应用绝对全局唯一
  • 如果你创建两个 ApplicationContext,同一个 Bean 会有两份实例

示例:

AnnotationConfigApplicationContext context1 =
        new AnnotationConfigApplicationContext(AppConfig.class);
AnnotationConfigApplicationContext context2 =
        new AnnotationConfigApplicationContext(AppConfig.class);

UserService bean1 = context1.getBean(UserService.class);
UserService bean2 = context2.getBean(UserService.class);

System.out.println(bean1 == bean2); // false

这也是很多文章容易讲模糊的地方。Spring 单例是容器维度,不是 ClassLoader 维度,不是 JVM 维度。

singleton 的适用场景

适合无状态 Bean,比如:

  • Service
  • DAO / Repository
  • 配置类
  • 工具类
  • 大多数业务组件

这里的“无状态”指的是:Bean 本身不保存用户请求之间互相影响的数据

例如下面这种就适合单例:

@Service
public class OrderService {

    public void createOrder(Long userId, Long productId) {
        // 通过参数执行业务逻辑,不在成员变量里保存用户态数据
    }
}

singleton 的线程安全问题

很多人一看到单例就问:线程安全吗?

答案是:Spring 不会自动保证 singleton Bean 的线程安全。

Spring 只负责把它做成一个实例,不负责你在这个实例里怎么写代码。

例如:

@Service
public class CounterService {

    private int count = 0;

    public int increment() {
        return ++count;
    }
}

这在并发环境下就是线程不安全的。因为多个线程会同时修改同一个成员变量。

正确理解应该是:

  • singleton 只决定实例数量
  • 线程安全取决于 Bean 内部是否有共享可变状态

所以实际开发里,单例 Bean 最好遵循一个原则:

尽量设计成无状态 Bean,不在成员变量中保存会变化的业务数据。

prototype:多例作用域

prototype 表示:每次向容器获取 Bean,都会创建一个新的实例。

示例

@Component
@Scope("prototype")
public class TaskProcessor {
}

测试代码:

AnnotationConfigApplicationContext context =
        new AnnotationConfigApplicationContext(AppConfig.class);

TaskProcessor bean1 = context.getBean(TaskProcessor.class);
TaskProcessor bean2 = context.getBean(TaskProcessor.class);

System.out.println(bean1 == bean2); // false

输出为 false,说明每次获取都是新对象。

prototype 的典型场景

适合有状态对象,例如:

  • 临时任务处理器
  • 构建器对象
  • 每次使用都需要独立上下文的组件
  • 非线程安全且必须独占使用的对象

例如:

@Component
@Scope("prototype")
public class UploadContext {

    private String fileName;
    private long fileSize;

    // getter/setter
}

每次拿一个新的 UploadContext,互不影响。

prototype 的一个关键细节:Spring 只负责创建,不负责完整销毁

这是 prototype 最容易被忽略的点。

对于 singleton Bean:

  • 容器启动时创建
  • 容器关闭时销毁
  • 初始化和销毁回调都完整参与

对于 prototype Bean:

  • 容器每次请求时创建
  • 创建完交给调用方
  • 容器不再跟踪其完整生命周期
  • 一般不会在容器关闭时帮你执行销毁逻辑

比如:

@Component
@Scope("prototype")
public class PrototypeBean {

    @PostConstruct
    public void init() {
        System.out.println("init");
    }

    @PreDestroy
    public void destroy() {
        System.out.println("destroy");
    }
}

你获取 Bean 时,init 会执行;但容器关闭时,destroy 通常不会自动执行。

这意味着:如果 prototype Bean 持有文件句柄、Socket、连接等资源,资源清理责任通常在业务代码,不在 Spring 容器。

request:一次 HTTP 请求一个 Bean

request 作用域只在 Web 环境下有效。它表示:

  • 一次 HTTP 请求内
  • 同一个 Bean 只创建一个实例
  • 不同请求之间互不共享

示例

@Component
@Scope(value = WebApplicationContext.SCOPE_REQUEST)
public class RequestTrace {

    private String traceId;

    public String getTraceId() {
        return traceId;
    }

    public void setTraceId(String traceId) {
        this.traceId = traceId;
    }
}

如果一个请求链路中多次注入这个 Bean,得到的是同一个对象;但下一个请求会重新创建。

适用场景

适合保存一次请求内的上下文数据,例如:

  • 请求追踪 ID
  • 当前请求临时缓存
  • 表单处理中间状态
  • 请求级鉴权信息封装

注意事项

request Bean 不能脱离 Web 请求上下文独立存在。如果你在非 Web 线程、异步线程、启动阶段去访问它,通常会报错,因为当前没有 request 上下文。

session:一次会话一个 Bean

session 作用域同样只在 Web 环境下有效。它表示:

  • 同一个 HTTP Session 内共用一个实例
  • 不同用户 Session 之间互相隔离

示例

@Component
@Scope(value = WebApplicationContext.SCOPE_SESSION)
public class UserCart {

    private List<String> items = new ArrayList<>();

    public List<String> getItems() {
        return items;
    }
}

一个用户在自己的会话周期内多次请求,看到的是同一个购物车对象;另一个用户则是另一份实例。

适用场景

常见于:

  • 购物车
  • 多步骤表单状态
  • 会话级用户偏好
  • 临时登录态扩展信息

使用上的现实问题

虽然 session 看起来很方便,但现在很多系统都是前后端分离、无状态认证、分布式部署,所以它使用频率比早期 MVC 应用低不少。

原因很直接:

  • Session 依赖服务端状态保存
  • 集群下要考虑 Session 共享或粘性会话
  • 不利于完全无状态扩展

因此在现代架构中,很多业务更倾向于:

  • JWT 保存认证信息
  • Redis 保存分布式会话数据
  • 尽量减少 HttpSession 中的状态

所以 session 不是不能用,而是要结合架构选型。

application:ServletContext 级别共享

application 作用域表示:

  • 在一个 ServletContext 范围内共享一个 Bean 实例

示例

@Component
@Scope(value = WebApplicationContext.SCOPE_APPLICATION)
public class AppGlobalState {

    private String version = "1.0.0";

    public String getVersion() {
        return version;
    }
}

它的可见范围比 session 更大,是整个 Web 应用共享。

与 singleton 的区别

很多人会问:applicationsingleton 看起来都像全局一个,有什么区别?

区别在于它们所属的“管理边界”不同:

  • singleton:Spring IoC 容器范围
  • applicationServletContext 范围

大多数单体 Web 项目里,两者看起来效果接近,但语义不同。只有在更复杂的 Web 应用上下文场景下,这个区别才会更明显。

websocket:WebSocket 会话级作用域

websocket 作用域用于 WebSocket 场景,表示:

  • 每个 WebSocket 会话对应一个 Bean 实例

适合保存 WebSocket 连接相关的上下文状态。

例如:

@Component
@Scope("websocket")
public class WebSocketSessionContext {

    private String clientId;

    public String getClientId() {
        return clientId;
    }

    public void setClientId(String clientId) {
        this.clientId = clientId;
    }
}

这个作用域用得不算多,但在实时通信、在线协作、消息推送等场景里很有用。

各种作用域的对比

下面把几个常见作用域放在一起看,更容易建立整体认识。

作用域 含义 是否默认 是否依赖 Web 环境 典型场景
singleton 容器内单例 Service、Repository、工具类
prototype 每次获取新建 有状态临时对象
request 每次 HTTP 请求一个实例 请求上下文、链路跟踪
session 每个 Session 一个实例 购物车、会话状态
application 整个 Web 应用一个实例 Web 应用级共享状态
websocket 每个 WebSocket 会话一个实例 WebSocket 连接上下文

如何定义 Bean 作用域

Spring 中定义作用域常见有三种方式:

1. 使用 @Scope

@Component
@Scope("prototype")
public class MyBean {
}

或者使用常量:

@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class MyBean {
}

Web 环境常量:

@Component
@Scope(WebApplicationContext.SCOPE_REQUEST)
public class MyRequestBean {
}

2. 在 @Bean 方法上指定

@Configuration
public class BeanConfig {

    @Bean
    @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
    public TaskProcessor taskProcessor() {
        return new TaskProcessor();
    }
}

3. XML 配置方式

如果项目还在用 XML,也可以这样写:

<bean id="userService" class="com.example.service.UserService" scope="singleton"/>
<bean id="taskProcessor" class="com.example.service.TaskProcessor" scope="prototype"/>

不过现在新项目基本都以注解配置为主。

singleton 注入 prototype,会发生什么

这是 Spring 作用域里最经典、最容易踩坑的问题之一。

假设有两个 Bean:

  • OrderService 是 singleton
  • TaskProcessor 是 prototype

代码如下:

@Component
@Scope("prototype")
public class TaskProcessor {
}
@Service
public class OrderService {

    @Autowired
    private TaskProcessor taskProcessor;

    public TaskProcessor getTaskProcessor() {
        return taskProcessor;
    }
}

测试:

AnnotationConfigApplicationContext context =
        new AnnotationConfigApplicationContext(AppConfig.class);

OrderService orderService = context.getBean(OrderService.class);

TaskProcessor p1 = orderService.getTaskProcessor();
TaskProcessor p2 = orderService.getTaskProcessor();

System.out.println(p1 == p2); // true

很多人会本能地以为是 false,但实际上是 true

原因是什么

因为 OrderService 是单例,它在容器启动时只创建一次。创建它时,Spring 会把 TaskProcessor 注入进去。虽然 TaskProcessor 定义成了 prototype,但注入动作只发生一次,所以最终 OrderService 成员变量里持有的,仍然是那一次创建出来的对象。

也就是说:

  • prototype 的语义是“每次向容器请求时新建
  • 不是“每次使用成员变量时自动新建

这是两个完全不同的概念。

如何在 singleton 中正确使用 prototype

如果你确实需要在 singleton Bean 中每次都拿到新的 prototype 实例,常见做法有几种。

方式一:注入 ObjectProvider

这是最推荐、最常见的方式。

@Service
public class OrderService {

    @Autowired
    private ObjectProvider<TaskProcessor> taskProcessorProvider;

    public TaskProcessor createTaskProcessor() {
        return taskProcessorProvider.getObject();
    }
}

测试:

AnnotationConfigApplicationContext context =
        new AnnotationConfigApplicationContext(AppConfig.class);

OrderService orderService = context.getBean(OrderService.class);

TaskProcessor p1 = orderService.createTaskProcessor();
TaskProcessor p2 = orderService.createTaskProcessor();

System.out.println(p1 == p2); // false

每次调用 getObject(),都会向容器重新取一个 prototype Bean。

方式二:注入 ApplicationContext

能用,但耦合更高。

@Service
public class OrderService {

    @Autowired
    private ApplicationContext applicationContext;

    public TaskProcessor createTaskProcessor() {
        return applicationContext.getBean(TaskProcessor.class);
    }
}

这本质上是主动从容器拉取对象,属于一种 Service Locator 风格,功能上可行,但不如 ObjectProvider 干净。

方式三:使用 @Lookup

@Component
public abstract class OrderService {

    public void process() {
        TaskProcessor taskProcessor = createTaskProcessor();
    }

    @Lookup
    protected abstract TaskProcessor createTaskProcessor();
}

Spring 会为这个方法生成运行时代码,每次调用时返回新的 prototype Bean。

这个机制可以用,但相对没那么直观,团队里如果对 Spring 底层理解不一致,可读性不如 ObjectProvider

作用域代理:解决跨作用域注入问题

当 Web 作用域 Bean 被注入到 singleton Bean 时,情况会更复杂。

例如:

@Component
@Scope(value = WebApplicationContext.SCOPE_REQUEST)
public class RequestUserContext {
    private String userId;
}
@Service
public class UserService {

    @Autowired
    private RequestUserContext requestUserContext;
}

这里 UserService 是单例,RequestUserContext 是 request 级。如果直接注入,单例创建时当前并没有固定请求对象,通常需要借助作用域代理

写法如下:

@Component
@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS)
public class RequestUserContext {
    private String userId;
}

代理做了什么

Spring 注入给 UserService 的不是实际的 RequestUserContext,而是一个代理对象。这个代理对象在每次真正调用方法时,再根据当前请求上下文找到对应的 request Bean。

所以可以把它理解为:

  • 注入阶段注入的是“占位代理”
  • 运行阶段按当前作用域解析真实对象

这个机制非常重要。没有它,很多跨作用域注入根本没法正常工作。

常见代理模式

proxyMode = ScopedProxyMode.TARGET_CLASS

表示使用基于类的代理,通常最常用。

也可以用:

proxyMode = ScopedProxyMode.INTERFACES

表示基于接口代理,但前提是 Bean 有合适接口。

Bean 作用域和生命周期的关系

作用域和生命周期紧密相关,但不是同一个概念。

  • 作用域决定实例创建和共享范围
  • 生命周期决定实例从创建到销毁经历哪些阶段

singleton 为例:

  1. 容器启动
  2. 实例化 Bean
  3. 属性注入
  4. 执行初始化回调
  5. Bean 被使用
  6. 容器关闭
  7. 执行销毁回调

prototype 只会走到前半段:

  1. 请求获取 Bean
  2. 实例化 Bean
  3. 属性注入
  4. 执行初始化回调
  5. 交给调用者使用
  6. 容器通常不再负责销毁

所以很多生命周期问题,本质上都跟作用域有关。

实际开发中怎么选作用域

如果只给一个非常实用的结论,那就是:

  • 绝大多数 Bean 用 singleton
  • 确实需要独立状态时才用 prototype
  • 和 HTTP 请求、Session 强相关的数据才用 request/session
  • 不要因为“担心线程安全”就滥用 prototype

为什么大多数时候用 singleton

因为 Spring 的主流设计本来就是围绕单例组件展开的:

  • 创建成本低
  • 依赖管理简单
  • 容器启动和运行行为更可预测
  • 易于统一管理事务、AOP、缓存、监控等横切逻辑

只要你的 Bean 是无状态的,singleton 通常就是最合理的选择。

为什么不要滥用 prototype

有些人觉得单例不安全,就把 Service 全部改成 prototype。这通常不是正确方案。

原因很简单:

  1. 线程安全问题的根源是共享可变状态,不是 singleton 这个标签本身
  2. prototype 会增加对象创建开销
  3. 生命周期更难管理
  4. 注入到单例中时还容易产生误解
  5. 容器对其销毁不做完整托管

真正正确的做法,通常是:

  • 保持 Service 无状态
  • 把状态放到方法参数、局部变量、数据库、缓存、上下文对象里
  • 必要时显式创建短生命周期对象

一个容易混淆的问题:局部变量和成员变量

理解线程安全时,这个区分很关键。

看下面的代码:

@Service
public class PriceService {

    public BigDecimal calculate(BigDecimal price, BigDecimal discount) {
        BigDecimal result = price.subtract(discount);
        return result.max(BigDecimal.ZERO);
    }
}

这是单例 Bean,但通常没有线程安全问题,因为方法里的 result 是局部变量,每个线程都有自己的调用栈。

再看这个:

@Service
public class PriceService {

    private BigDecimal currentResult;

    public BigDecimal calculate(BigDecimal price, BigDecimal discount) {
        currentResult = price.subtract(discount);
        return currentResult.max(BigDecimal.ZERO);
    }
}

这里就有问题了,因为多个线程会共享 currentResult 成员变量。

所以讨论 Bean 作用域时,不要只停留在“单例安全吗”这种表层问题,真正该看的是:这个 Bean 是否保存共享可变状态。

自定义作用域

Spring 还支持自定义 Scope,不过这已经不是日常 CRUD 开发的常规需求,而是偏框架扩展场景。

你可以通过实现 org.springframework.beans.factory.config.Scope 接口,定义自己的对象存储和获取规则。比如:

  • 线程级作用域
  • 租户级作用域
  • 流程级作用域

大致步骤包括:

  1. 实现 Scope 接口
  2. 注册到 BeanFactory
  3. 在 Bean 上使用自定义作用域名称

简化示意:

public class ThreadLocalScope implements Scope {

    private final ThreadLocal<Map<String, Object>> beanMap =
            ThreadLocal.withInitial(HashMap::new);

    @Override
    public Object get(String name, ObjectFactory<?> objectFactory) {
        Map<String, Object> map = beanMap.get();
        return map.computeIfAbsent(name, k -> objectFactory.getObject());
    }

    @Override
    public Object remove(String name) {
        return beanMap.get().remove(name);
    }

    @Override
    public void registerDestructionCallback(String name, Runnable callback) {
    }

    @Override
    public Object resolveContextualObject(String key) {
        return null;
    }

    @Override
    public String getConversationId() {
        return Thread.currentThread().getName();
    }
}

注册后即可使用:

@Component
@Scope("threadLocal")
public class ThreadScopeBean {
}

不过这类方案一定要谨慎。只要引入自定义作用域,就意味着你在改变 Spring 默认的对象管理模型,后续维护成本会明显升高。

面试里经常问的几个点

1. Spring Bean 默认是什么作用域

默认是 singleton

2. Spring 的 singleton 和设计模式里的单例一样吗

不一样。Spring 的 singleton 是 容器级单例,同一个容器内一个 BeanName 对应一个实例;设计模式单例通常强调某个类在整个运行时环境中只存在一个实例,控制方式也不同。

3. prototype Bean 注入 singleton Bean 时,每次使用都会新建吗

不会。默认只在注入时创建一次。要想每次使用都新建,需要 ObjectProviderApplicationContext#getBean()@Lookup 等方式。

4. prototype Bean 会执行销毁方法吗

一般不会由 Spring 容器自动执行完整销毁流程,资源释放通常要由业务代码负责。

5. 单例 Bean 一定线程安全吗

不一定。是否线程安全取决于 Bean 是否包含共享可变状态。

一段完整示例

下面给一个更完整的示例,把 singleton 和 prototype 的行为放在一起。

@Configuration
@ComponentScan("com.example.scope")
public class AppConfig {
}
package com.example.scope;

import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;

@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class PrototypeBean {

    public PrototypeBean() {
        System.out.println("PrototypeBean created: " + this);
    }
}
package com.example.scope;

import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;

@Service
public class SingletonService {

    private final ObjectProvider<PrototypeBean> prototypeBeanProvider;

    public SingletonService(ObjectProvider<PrototypeBean> prototypeBeanProvider) {
        this.prototypeBeanProvider = prototypeBeanProvider;
    }

    public PrototypeBean getNewPrototypeBean() {
        return prototypeBeanProvider.getObject();
    }
}
package com.example.scope;

import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class ScopeTest {
    public static void main(String[] args) {
        AnnotationConfigApplicationContext context =
                new AnnotationConfigApplicationContext(AppConfig.class);

        SingletonService singletonService = context.getBean(SingletonService.class);

        PrototypeBean p1 = singletonService.getNewPrototypeBean();
        PrototypeBean p2 = singletonService.getNewPrototypeBean();

        System.out.println(p1 == p2); // false

        context.close();
    }
}

运行时你会看到 PrototypeBean 被创建两次,这才是真正按需获取 prototype 的正确方式。

版本说明

Spring Framework 5.x 与 6.x 在 Bean 作用域上的差异

对于本文讨论的这些核心作用域:

  • singleton
  • prototype
  • request
  • session
  • application
  • websocket

Spring Framework 5.x 与 6.x 的概念和基本行为没有本质差异。

差异更多体现在:

  • Spring 6 基于 Jakarta EE 9+,相关包名从 javax.* 迁移为 jakarta.*
  • Web 相关整合依赖发生变化
  • Boot 3 基于 Spring 6,整体运行环境要求更高

这意味着如果你在 Spring Boot 2.x 和 3.x 之间迁移项目,Bean 作用域本身的使用思路不用重学,但 Web 相关依赖和导包要注意兼容性。

总结

Bean 的作用域,表面看只是一个配置项,实质上决定了 Spring 如何管理对象实例、如何复用对象,以及对象在并发和 Web 场景中的行为边界。

真正需要记住的不是那几个英文单词,而是下面这几条:

  1. 默认作用域是 singleton,表示容器级单例
  2. singleton 不等于线程安全,线程安全取决于是否存在共享可变状态
  3. prototype 表示每次获取新实例,但注入到 singleton 时不会自动“每次使用都新建”
  4. request、session、application、websocket 都依赖 Web 上下文
  5. 跨作用域注入时,经常需要作用域代理或延迟获取机制
  6. 实际开发中,大多数业务 Bean 都应该优先设计为无状态 singleton

如果把作用域理解透了,Spring 里很多看似分散的问题——比如生命周期、线程安全、依赖注入行为、Web 上下文隔离——都会自然串起来。它不是一个孤立知识点,而是理解 Spring 容器运行方式的基础。

正文到此结束
评论插件初始化中...
Loading...