Bean线程安全这个问题,我们今天从架构职责划分的角度尝试来讲一下。
问题本质:Bean线程安全到底在问什么
先回到问题本身。Spring容器中的Bean为什么会有线程安全问题?
Spring默认的Bean作用域是Singleton。容器启动时创建一个实例,之后所有请求共享这同一个对象。在Web应用里,一个HTTP请求就是一个线程,多个请求同时访问同一个Singleton Bean,读同一个字段、写同一个字段,自然会出现数据竞争。
所以问题可以拆成两层:
第一层,容器层面的:Bean实例的创建和获取过程本身是不是线程安全的?换句话说,两个线程同时向容器要同一个Bean,会不会拿到一个半成品?
第二层,业务层面的:Bean实例被多线程共享使用时,它内部的字段会不会被并发修改导致数据不一致?
这两层其实是两回事,搁一块答就容易说不清楚。
第一层问题的答案是:Spring容器保证Bean创建过程的线程安全。你不用担心两个线程同时getBean会拿到一个构造到一半的对象。
第二层问题的答案是:Spring容器不管Bean使用过程的线程安全。Bean的业务字段是否线程安全,是开发者的事。
不是Spring偷懒,这样完是有道理的。我们来聊聊为什么。
并发安全有几种策略
在聊Spring怎么选之前,先看看并发安全这个问题本身有多少种解法。
| 策略 | 原理 | 适用场景 | 代价 |
|---|---|---|---|
| 互斥同步 | 同一时刻只有一个线程能进入临界区 | 读写都有的共享状态 | 锁竞争导致线程阻塞 |
| 非阻塞同步 | CAS操作,冲突时重试而非阻塞 | 低冲突的计数器、状态标记 | 高冲突时自旋消耗CPU |
| 线程隔离 | 每个线程持有独立副本,互不干扰 | 每个线程需要独立状态的场景 | 内存开销随线程数增长 |
| 无状态设计 | 对象不持有可变共享状态 | 纯计算或纯委托类 | 不适用需要保持状态的场景 |
这张表是理解后续所有讨论的基础。不管什么框架、什么语言,并发安全的方案逃不出这四种。
Spring面对的选择是:在依赖注入容器这个层面,该用哪种策略来保证Bean的线程安全?
这里有个限定条件,只在容器层面讨论。容器能控制的只有Bean的创建、获取和销毁。容器不知道你的Service里有个count字段在累加,也不知道你的Controller里有个List在被追加。容器能做的是保证Bean的创建和获取过程是安全的,仅此而已。
Spring的方案:容器管生命周期,业务管并发安全

Spring在容器层面做了两件事:保证Bean创建过程线程安全,提供不同的作用域来隔离Bean实例。
Singleton Bean的创建过程
DefaultSingletonBeanRegistry是Singleton Bean存储和获取的核心类。它内部用了三个Map来管理Singleton的完整生命周期:
Java
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);
private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);
private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(16);
singletonObjects存的是完全初始化好的Bean实例。singletonFactories存的是正在创建中的Bean的ObjectFactory,用于处理循环引用。earlySingletonObjects存的是提前暴露的早期引用,也是为了循环引用。
获取Singleton Bean的逻辑在DefaultSingletonBeanRegistry的getSingleton方法中:
Java
synchronized (this.singletonObjects) {
Object singletonObject = this.singletonObjects.get(beanName);
if (singletonObject == null) {
singletonObject = singletonFactory.getObject();
addSingleton(beanName, singletonObject);
}
}
这里用的是synchronized互斥锁,锁对象是singletonObjects这个ConcurrentHashMap本身。为什么用ConcurrentHashMap存数据还要加synchronized?因为getSingleton方法中涉及对三个Map的联动操作,创建完Bean后要从singletonFactories移除,要往singletonObjects添加,要清理earlySingletonObjects。ConcurrentHashMap只能保证单个操作的原子性,跨多个Map的联动操作必须用锁来保证整体原子性。
读操作走的是无锁路径。getSingleton(String beanName, boolean allowEarlyReference)方法先直接从ConcurrentHashMap读,读到了就返回,不需要加锁。只有在读不到且Bean正在创建中(可能存在循环引用)时,才走synchronized路径。这是一个典型的读写分离优化,读多写少的场景下,大部分请求不会触发锁竞争。
到这里可以回答第一层问题了:Spring容器保证Bean创建过程的线程安全。通过synchronized锁住创建过程,确保同一个Singleton Bean只会被创建一次,不会出现半成品对象。
Prototype Bean:每次都new一个新的
Prototype作用域的Bean,每次getBean都会创建一个新实例。不存在共享,就不存在并发问题。
AbstractBeanFactory中用ThreadLocal来追踪当前线程正在创建的Prototype Bean:
Java
private final ThreadLocal<Object> prototypesCurrentlyInCreation =
new NamedThreadLocal<>("Prototype beans currently in creation");
ThreadLocal天然线程安全,每个线程有自己独立的副本,不需要任何同步机制。Prototype Bean的创建过程完全不需要锁。
Request和Session作用域:基于Web容器的线程模型
Request作用域的Bean,每个HTTP请求一个实例。Session作用域的Bean,每个HTTP会话一个实例。
这两种作用域的线程安全靠的是Web容器的线程模型。Tomcat处理每个请求的是一个独立线程,RequestContextHolder通过ThreadLocal把当前请求的RequestAttributes绑定到线程上:
Java
private static final ThreadLocal<RequestAttributes> requestAttributesHolder =
new NamedThreadLocal<>("Request attributes");
AbstractRequestAttributesScope的get方法从ThreadLocal取出当前请求的属性容器,再从里面取Bean实例。同一个请求内只有单个线程访问,所以Request作用域的Bean天然不存在并发问题。
Session作用域稍微复杂一点。同一个Session可能被多个请求同时访问,Session属性底层是HttpSession,线程安全由Servlet容器负责。Spring在Scope接口的Javadoc里明确写了一条契约:Scope implementations are expected to be thread-safe。所有作用域实现必须自己是线程安全的。
作用域与并发安全策略的对应关系
回到前面那张四种策略的对比表,Spring不同作用域的线程安全策略对应关系如下:
Singleton Bean的创建过程用的是互斥同步(synchronized)。Singleton Bean的使用过程Spring不管,留给开发者。
Prototype Bean用的是线程隔离的变体,每个请求一个新实例,从根本上消除了共享。
Request作用域同样是线程隔离,不过隔离粒度从每次获取变成了每次请求。
Session作用域依赖Servlet容器的线程安全保证,本质是容器层面的互斥。
Spring的设计逻辑是一致的:在能避免共享的场合,优先选择避免共享(Prototype、Request);在必须共享的场合(Singleton),只保证创建过程安全,使用过程的并发安全交给开发者。
Spring方案是不是最佳选择
讨论这个问题,需要先明确一个前提:我们在评判的是依赖注入容器层面的线程安全策略,不是通用场景的并发方案。容器的职责是管理对象的生命周期和依赖关系,不是管理业务数据的并发访问。
有人可能会说:Spring完全可以在Singleton Bean的getBean方法上加一把读写锁,读的时候用读锁,写的时候用写锁,不就解决业务字段的并发问题了吗?
这个想法有两个地方不太对。第一,getBean返回的是对象引用,拿到引用之后的操作已经脱离了容器的控制范围。容器不可能在你调用service.count++的时候插一把锁。第二,即使容器能做到方法级的拦截加锁,锁的粒度也不对,你的Service里有十个方法,只有两个方法访问了共享变量,给所有方法加锁是过度同步,白白损失性能。
所以Spring的选择是合理的:容器做容器该做的事(保证创建过程安全),业务做业务该做的事(处理使用过程的并发)。职责边界这样划是对的。
新版本的Spring有变化吗
Spring Framework 6.0(也就是Spring Boot 3底层依赖的版本)仍然是同样的策略。DefaultSingletonBeanRegistry的getSingleton方法签名和锁机制没有变化,三种Map的联动逻辑没有变化,ThreadLocal追踪Prototype创建的方式没有变化。
唯一值得提到的变化是Spring Boot从2.6版本开始默认禁止了循环引用(spring.main.allow-circular-references默认值从true改为false),这使得earlySingletonObjects和singletonFactories的使用场景大幅减少,但线程安全策略本身没有改变。注意这是Spring Boot层面的配置变更,Spring Framework自身的allowCircularReferences默认值仍然是true。
其他依赖注入框架怎么做的
Google的Guice和Spring是同一时代的依赖注入框架。Guice默认也是Singleton作用域,同样不在框架层面处理Bean使用过程的并发安全。Guice的Singleton创建过程用的是synchronized + double-checked locking,和Spring的思路一样。
Java EE的CDI(Contexts and Dependency Injection)规范定义了@RequestScoped、@SessionScoped、@ApplicationScoped等作用域,Weld是CDI的参考实现。CDI的作用域模型和Spring几乎一致,@ApplicationScoped对应Singleton,@RequestScoped对应Request,线程安全策略也是同样的逻辑:容器管创建,业务管使用。
Micronaut是近几年的新一代依赖注入框架,编译期完成依赖注入,运行时没有反射开销。Micronaut的Singleton也是默认作用域,同样不在框架层面处理并发安全。区别在于Micronaut用了编译期代理而不是运行时代理,但线程安全的架构决策和Spring是一样的。
你看,不是Spring一家这么做,Guice、CDI、Micronaut的选择都一样。当所有主流依赖注入框架在同一个问题上做了同样的决策,这就不是巧合了,是职责边界划对了的自然结果。
小结
聊完之后回头看,Bean线程安全这个话题真正有意思的地方不在技术细节,而在它暴露了一个架构设计里反复出现的规律:职责边界划在哪儿,决定了你能不能把问题解决干净。Spring把边界划在了容器和业务之间,容器只管生命周期,不管并发。这个选择看起来是容器不管事,实际上是不管不该管的事。Guice、CDI、Micronaut都做了同样的选择,说明这不是哪一家偶然的想法,而是依赖注入容器这个角色本身的约束决定的。你在别的架构场景里也会遇到类似的问题:框架该管什么,不该管什么?管多了是越界,管少了是缺位。Bean线程安全只是一个具体的例子,背后那个更深的判断才是值得记住的。