原创

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 的区别

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

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

  • singleton:Spring IoC 容器范围
  • application:ServletContext 范围

大多数单体 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 时,每次使用都会新建吗

不会。默认只在注入时创建一次。要想每次使用都新建,需要 ObjectProvider、ApplicationContext#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...