面试官问Bean线程安全,你该从架构角度回答

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线程安全只是一个具体的例子,背后那个更深的判断才是值得记住的。

参考的内容

相关推荐
用户713874229001 小时前
git fsck 深度解析 Git 仓库的体检医生
后端
敖正炀1 小时前
ArrayList 与 LinkedList 源码全景:从数据结构选择到性能分歧的完整代码路径
java
凌波粒1 小时前
LeetCode--513.找树左下角的值(二叉树)
java·算法·leetcode
敖正炀1 小时前
HashMap 红黑树化与退化
java
风度前端1 小时前
阿里云宝塔面板部署https证书
linux·后端·https
喜欢小苹果的码农1 小时前
xxl-job主流程分析
java
敖正炀1 小时前
HashMap 源码深度拆解(JDK 7→8)
java
Yeats_Liao1 小时前
物联网接入层技术剖析(二):epoll到底是怎么工作的
java·linux·网络·物联网·信息与通信
还是鼠鼠1 小时前
AI掘金头条新闻系统 (Toutiao News)-相关推荐
后端·python·mysql·fastapi·web