← 返回题目列表

Spring Bean 有哪些作用域?单例 Bean 是线程安全的吗?

高频 简单 第 2 / 30 题 更新于 2026/07/26
SpringBean 作用域singleton线程安全

简化版

Spring 常见作用域有 singletonprototype,Web 环境还有 requestsessionapplicationwebsocket;默认是每个容器、每个 Bean 定义一个实例的 singleton。单例只保证实例数量,不保证线程安全,无状态 Bean 通常可以安全共享,有可变成员状态时必须避免共享或自行同步。

详细版

  • singleton:同一 Spring IoC 容器中,同一个 Bean 定义通常只创建一个共享实例。
  • prototype:每次容器收到获取请求时创建新实例;容器完成创建和初始化后,不负责后续销毁。
  • request:一次 HTTP 请求一个实例。
  • session:一次 HTTP Session 一个实例。
  • application:一个 ServletContext 一个实例。
  • websocket:一次 WebSocket 会话一个实例。

Spring singleton 不等于 GoF 单例:它的边界是 Bean 定义和容器,不保证整个 JVM 绝对只有一个对象。Service 通常设计成无状态单例,把请求数据放在方法局部变量中;不要把当前用户、临时计算结果等写入共享字段。

完整版教学

一、singleton 的准确含义

@Service
class PriceService {
    BigDecimal calculate(Order order) {
        BigDecimal discount = findDiscount(order); // 局部变量
        return order.amount().subtract(discount);
    }
}

多个请求会同时调用同一个 PriceService 实例,但参数和局部变量位于各自线程的调用栈,没有共享可变状态,因此这种无状态写法通常是安全的。

Spring 的 singleton 缓存在所属 BeanFactory 内。创建两个 ApplicationContext,或者注册两个不同名称的同类型 Bean,都可能得到多个实例,所以不能把它理解成 JVM 级单例模式。

二、单例为什么可能不安全

@Service
class CounterService {
    private int count;

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

++count 是读、改、写的复合操作,并发调用会发生竞态。解决方式应从设计开始:尽量让 Bean 无状态,把跨请求状态放入数据库、缓存等有并发控制的组件;确实需要进程内共享状态时,再使用锁、原子类或线程安全容器,并明确一致性边界。

将 Bean 改为 prototype 也不自动等于线程安全。如果同一个 prototype 实例被调用方保存后跨线程共享,仍然会有并发问题;线程安全取决于对象如何被共享和内部状态如何管理。

三、prototype 的创建和销毁边界

prototype Bean 每次显式向容器获取都会创建新对象,但若把它直接注入 singleton,依赖注入只发生在 singleton 创建时,之后通常一直持有当时那一个 prototype 实例。

需要在运行期间反复获取新实例时,可使用 ObjectProvider<T>、方法注入或受控工厂。容器会执行 prototype 的初始化回调,却不会在容器关闭时自动调用其销毁回调;持有文件、连接等资源时,调用方必须负责清理。

四、Web 作用域如何注入单例

request 或 session Bean 的生命周期比 singleton 短,不能在应用启动时直接把某个真实请求实例固定注入单例。Spring 通常注入一个作用域代理:单例持有代理,每次调用时再定位当前请求或会话对应的真实对象。

如果当前线程没有活跃的请求上下文,访问 request-scoped Bean 会失败。异步线程也不会天然继承 Servlet 请求作用域,不能假设把任务扔进线程池后仍能直接读取请求 Bean。

五、作用域选择原则

大多数 Controller、Service、Repository 都适合无状态 singleton,创建成本低且便于统一管理。只有对象确实承载一次请求、一次会话或每次使用都独立的状态时,才选择更短作用域。

作用域解决的是实例生命周期和可见范围,不替代并发设计。即使是 session Bean,同一用户也可能并发发起多个请求,仍要考虑共享状态竞争。

六、用并发算例判断风险

假设 singleton CounterServicecount 初值为 0,两个线程同时执行 ++count。两者都可能先读到 0,各自计算出 1,再都写回 1;调用 2 次却只增加 1,这就是“实例只有一个”与“操作线程安全”完全不同的证据。使用 AtomicInteger.incrementAndGet() 能保证这个进程内计数操作的原子性,但若有 3 个应用实例,每个实例各自计数,它仍不能提供全局唯一结果。

private final AtomicInteger count = new AtomicInteger();

int increment() {
    return count.incrementAndGet();
}

这段代码修复的是单 JVM 内单次自增的竞态,不自动提供持久化、跨节点一致性或“读取后再判断”的复合原子性。需求一旦跨进程,就应把一致性边界放到能协调所有实例的数据存储中。

状态放置位置是否跨请求共享常见风险更合适的做法
方法参数、局部变量通常不共享对象参数自身仍可能可变保持方法无状态
singleton 可变字段共享竞态、可见性、复合操作失原子性避免或使用明确并发控制
ThreadLocal每线程一份线程池复用造成脏数据或泄漏finally 中清理,谨慎使用
数据库或缓存跨实例共享一致性和网络成本使用原子命令、锁或事务

request scope 也不是“每个线程一个实例”的同义词,它绑定的是 Web 请求上下文。一次请求发生异步分派或线程切换时,是否还能访问原上下文要看框架传播机制;不要用作用域名称替代并发分析。

心法:作用域回答“对象活多久、谁看到同一个对象”,线程安全回答“并发读写同一状态是否正确”,这是两条正交的问题轴。

七、常见误区与追问

  • 误区:Spring singleton 就是整个 JVM 只有一个实例。 它通常是每个 BeanDefinition、每个 IoC 容器一个共享实例,多容器或多 Bean 定义都可产生多个对象。
  • 误区:singleton Bean 天生线程安全。 容器只管理实例数量,不替业务可变状态提供同步;无状态设计才是 Service 通常可共享的原因。
  • 误区:把 Bean 改成 prototype 就一定线程安全。 prototype 只改变获取时的创建语义,同一个实例若仍被跨线程共享,竞态照样存在。
  • 追问:prototype 注入 singleton 为什么没有每次新建? singleton 只在创建时解析一次依赖,所以会长期持有当时获得的 prototype;要动态获取需使用 Provider、方法注入或工厂。
  • 追问:session Bean 是否无需考虑并发? 同一会话可以并发发起多个请求,因此其可变字段仍可能被同时访问。
  • 追问:request Bean 如何注入 singleton? 通常注入作用域代理,调用时再从当前请求上下文定位真实对象;没有活跃请求上下文时访问会失败。

八、加强记忆

singleton 表示一个容器中一个 Bean 定义共享一个实例,不承诺线程安全;无状态单例通常安全,共享可变字段才是风险来源。prototype 每次获取创建但销毁自理,短作用域 Bean 注入长作用域 Bean 时通常依靠作用域代理按当前上下文查找真实实例。