Spring Bean 是否线程安全这句话我这两年被面试官问过不下十次。第一次被问到时我下意识回答“单例 Bean 不是线程安全的”结果对方立刻追问“那你们项目里 Controller、Service 不都是单例吗它们是不是全都加了锁”我当场意识到自己把“线程安全”和“Bean 是否是单例”这两件事搅在一起了。后来把底层逻辑理顺之后再遇到这个问题反而会觉得好答因为它背后真正考的是你对并发、状态、Spring 容器职责这三层概念有没有清晰的边界。准备这篇文章时我把 Bean 作用域、生命周期、三级缓存、Autowired 注入这些面试高频点全部串起来讲不会只给你一个“是或否”的背答案版本。Spring 默认的 Bean 是单例这是事实但单例不等同于线程不安全无状态单例在多线程环境下反而是最安全的写法。真正需要警惕的是“有状态且状态被并发修改”的 Bean。下面从问题本身开始拆最后给出一套可以直接复用的面试回答框架。1. 先搞清楚Bean 本身没有“线程安全”这个标签1.1 线程安全是访问行为的结果不是对象的天然属性在 Java 里说一个类“线程安全”通常指的是多个线程同时调用它的方法、读写它的字段时不需要额外同步结果依然正确。String、ConcurrentHashMap、AtomicInteger 这类对象可以称为线程安全因为它们在设计时就从内部保证了并发访问的一致性。而 ArrayList、HashMap、SimpleDateFormat 不是线程安全的因为多个线程同时操作时会出现数据覆盖、结构破坏或结果错乱。问题在于 Spring Bean 并不是 Java 并发模型里的概念它是一个“由 Spring 容器创建、装配、管理的对象”。容器本身并不会给 Bean 的方法加 synchronized也不会自动给成员变量加 volatile。Bean 的线程安全属性实际上等于该对象自身的线程安全属性。如果把一个线程安全的类声明成 Bean那它是安全的把一个有可变成员变量且没做同步的类声明成 Bean那它就是有隐患的。所以在回答“Spring Bean 是否线程安全”之前先要把命题修正不是 Spring 决定 Bean 是否安全而是 Bean 本身的状态设计和访问方式决定它是否安全。Spring 只是把你的对象放到容器里并在需要时注入到其他对象里它不会在每次方法调用前帮你做并发检查也不会在方法结束后帮你清理共享状态。1.2 Spring 默认单例一个实例被多个线程共享Spring 中 Bean 的默认 scope 是 singleton。这意味着默认情况下整个容器里每个 Bean ID 只对应一个实例。多个线程同时调用这个实例的方法是 Spring 应用里最常见的并发场景。Spring Boot 项目更明显Controller、Service、Repository 默认全是单例一个接口被大量请求打进来时所有请求线程共享同一个 Controller 实例和同一个 Service 实例。容器的职责是管理这个实例的生命周期和依赖装配它保证你能通过 getBean 拿到同一个对象但不保证并发访问时数据一致。很多人在面试时会说“Spring 默认单例所以 Bean 线程不安全”这个结论太粗糙。更准确的说法是Spring 默认单例意味着同一个实例会被多线程共享在这种情况下是否安全取决于这个实例有没有可变状态、状态是否被并发修改。2. Bean 作用域决定默认并发模型但别指望切 scope 解决一切2.1 singleton 和 prototype 在并发下的真实差异Spring 除了 singleton 之外还有 prototype、request、session、application 等作用域。从并发角度看不同作用域改变了“实例对谁可见”的问题。作用域实例数量并发模型与线程安全的关系singleton整个容器一个所有线程共享无状态则安全有状态需自行处理同步prototype每次获取都是新实例每次调用基本独享降低了共享风险但注入不当仍然会共享request每个 HTTP 请求一个单个请求线程内请求内相对安全但要小心异步线程session每个用户会话一个同一用户多个请求可能并发同一会话内的并发访问仍可能冲突application整个 ServletContext 一个所有请求线程共享相当于更宽范围的单例同样要关注状态prototype 看起来比 singleton 安全每次拿到的都是新对象线程之间存在天然隔离。但实际工程里prototype 使用频率很低因为大部分 Service 是无状态的单例就已经很安全了只有那些必须携带请求上下文或临时状态的组件才需要 prototype。而且更关键的一点作用域解决的是“实例数量”不是“并发同步”。同一个对象如果被故意放进静态变量里共享哪怕它是 prototype 创建的也可能变成线程安全隐患。2.2 注入 prototype 时容易踩的“假新实例”坑一个非常容易被忽略的坑是把 prototype Bean 直接通过 Autowired 注入到 singleton Bean 里。很多人以为只要 Bean 声明了 Scope(prototype)每次调用注入字段时都会拿到新实例但实际不是。Spring 在创建 singleton Bean 时如果发现它的字段依赖一个 prototype Bean会把这个 prototype 实例一次性注入进去。之后这个 singleton 一直持有一个固定的 prototype 实例并不会在每次业务方法调用时重新注入。换句话说你写的是 prototype但用起来仍然是共享单例行为。想要真正每次拿到新实例应该在 singleton Bean 里注入 ObjectProvider 或者使用 Lookup 方法。比如Component Scope(prototype) public class TaskHandler { public void handle() { ... } } Service public class ProcessService { Autowired private ObjectProviderTaskHandler taskHandlerProvider; public void process() { TaskHandler handler taskHandlerProvider.getObject(); handler.handle(); } }面试时补这一段会显得你有真实项目经验因为很多人只知道 Scope(prototype)却不知道它在构造器注入和字段注入时的真实行为。2.3 request / session / application 等 Web 作用域速查Spring Web 环境下的 request scope Bean 每次 HTTP 请求会创建新实例适合存放请求级别的临时上下文。session scope Bean 每个用户会话一个实例但同一用户的多个请求可能是并发的如果往 session Bean 里塞可变 HashMap依然要自己处理同步。application scope 相当于 ServletContext 级别的单例本质上比普通 singleton 的共享范围还大有状态时更危险。这些作用域适合面试时分类回答但真正落实到项目里我还是建议默认只使用 singleton。需要临时数据就放到方法参数、方法返回值里不要为了“看起来安全”去切 request 或 prototype。作用域切换是有成本的而且会把对象关系变复杂。3. 判断标准只有一个Bean 有没有可变状态3.1 无状态 Bean 天然安全无状态 Bean 指的是没有成员变量或者成员变量全是不可变依赖比如注入的 Service、Mapper、Repository 等。这类 Bean 中所有数据要么来自方法参数要么委托给注入的依赖自身不持有跨调用共享的业务数据。典型的安全写法像这样Service public class UserService { private final UserMapper userMapper; public UserService(UserMapper userMapper) { this.userMapper userMapper; } public User findById(Long id) { return userMapper.selectById(id); } }UserService 本身没有可变状态userMapper 在容器启动时被注入之后引用不再改变。即使多个请求线程同时调用 findById也只是各自把方法参数和返回值放在自己的线程栈里不存在互相干扰。这也是为什么大型项目里 Service 大多是单例却很安全的原因不是 Spring 做了什么特殊魔法而是代码设计让对象变得无状态。判断一份代码是否有状态最简单的标准是问一句这个对象的字段除了初始化时赋值之外还有没有在运行期被修改如果没有那基本都是安全对象如果有就需要继续往下分析。3.2 有状态 Bean 的翻车示例count 不是原子的看一个最典型的有状态 BeanService public class CounterService { private int count 0; public void increment() { count; } }count 看起来只有一步但在 JVM 执行时实际分成了读取 count、加 1、写回 count 三个步骤。两个请求线程同时执行时可能出现都读取到 0然后都写回 1最终 count 只加了 1而不是 2。这个现象就是竞态条件是单例 Bean 有可变状态时最常见的症状。如果面试官让你举一个“Spring Bean 线程安全”的反例这个就是最直观的。把 count 换成成员变量 Map比如缓存统计数据、在线用户数、请求计数器本质上同样是共享可变状态只是坏得更隐蔽。用 synchronized 加锁可以解决但要注意锁的粒度。给整个方法加锁通常会拖垮吞吐更好的做法是缩小锁范围或者直接使用 AtomicInteger 这类原子类型。3.3 最容易写进单例 Bean 的三种坏状态实际项目里有状态 Bean 常常不是你想主动写的而是写着写着就带出来了。我整理过三种最容易踩的坏状态面试时可以用来证明自己踩过坑。第一种是“缓存集合”。在 Service 里声明一个 HashMap 当缓存所有请求都往里面 put、get却发现并发量一高数据就错乱。正确做法是使用 ConcurrentHashMap并把 key、value 都设计成不可变的更稳妥的方案是交给 Redis 或 Caffeine 这类缓存组件。第二种是“非线程安全的格式化器”。SimpleDateFormat、NumberFormat 这些类内部有 Calendar 状态多个线程共享同一实例时会出现解析错乱。它比缓存集合更难发现因为本地测试很难复现等到生产环境某个时间点数据格式突然出错才会开始排查。第三种是“请求上下文塞进字段”。有人会在 Controller 或 Service 里定义一个字段先 set 再 get让一个请求的数据在整个业务链路里传递。单例 Bean 里这样做两个并发请求就会互相覆盖数据。这属于设计错误应该把上下文改成方法参数或者用 request scope Bean 来承载。这三种坏状态是很好的面试素材。你不需要把这些全说出来挑一个讲清楚“为什么错怎么改”面试官就能判断你不是背题。4. 有状态 Bean 的安全化改造四种可行姿势4.1 局部变量优先压根别碰成员变量最推荐也最简单的方案是把可变状态放在方法内部。方法参数、方法内声明的局部变量都属于当前线程的栈帧每个线程调用方法时都有自己独立的一份不存在共享问题。这个方法没有额外加锁也不引入复杂容器只是改变了状态存放位置。Service public class ExportService { public ListString buildReport(ListLong ids) { ListString result new ArrayList(); for (Long id : ids) { result.add(id : ok); } return result; } }result 只在方法内部使用每次调用都新建不同线程各用各的 list所以完全安全。设计业务代码时凡是能通过参数传递的数据就不要再往成员变量里塞。这条规则看着简单但在赶项目时经常被忘记尤其是那些从旧代码里迁移过来的类常常在字段上挂着临时上下文。4.2 ThreadLocal给每个线程一份私有副本如果某个对象不是线程安全的但你又希望每个线程都有一份独立实例可以用 ThreadLocal。它是通过每个 Thread 内部维护一个独立 Map 来实现的线程 A 设置的值不会影响线程 B。比较典型的场景是格式化日期。private static final ThreadLocalSimpleDateFormat DATE_FORMAT ThreadLocal.withInitial(() - new SimpleDateFormat(yyyy-MM-dd));这时每个线程都有自己的 SimpleDateFormat不会出现共享 Calendar 状态的问题。但要注意ThreadLocal 使用完要记得 remove尤其是在线程池场景里。线程池的线程是复用的如果不清理上一次用户的上下文可能会泄漏给下一次任务。Java 8 以后如果只是格式化日期我更推荐直接用 DateTimeFormatter它是线程安全的也不需要 ThreadLocal。ThreadLocal 不是万能的它适合“线程私有”的数据比如用户 ID、traceId、数据库连接。但如果你需要的状态本身就是跨线程共享的逻辑结果ThreadLocal 会掩盖问题而不是解决问题还可能导致内存泄漏和上下文错乱。4.3 原子类和锁用于必须共享的值有些状态确实需要被多个线程共享比如全局计数器、命中率统计、限流指标。这时候不要手动写 synchronized 大段代码直接用 java.util.concurrent 包里现成的类。private final AtomicInteger requestCount new AtomicInteger(0); public void recordRequest() { requestCount.incrementAndGet(); }AtomicInteger 内部通过 CAS 实现了原子的“读改写”操作性能比加锁好代码也更简洁。需要缓存共享数据时优先考虑 ConcurrentHashMap需要读多写少的配置值可以使用 volatile 或不可变对象配合发布。如果必须用 synchronized 或 ReentrantLock记得控制锁的粒度。不要把整个 Service 方法都锁住尤其方法里面还有网络调用、数据库操作时锁住整段代码会让并发能力急剧下降。锁的范围越小控制的共享状态越少性能越安全。4.4 prototype 与 ObjectProvider真正每次拿新的有状态 Bean 的另一个思路是不要共享直接每次使用都创建新实例。这需要把 Bean 声明成 prototype并通过 ObjectProvider 获取而不是通过 Autowired 直接注入。前面已经说过直接注入的坑这里再补充一个决定性问题如果用 prototype Bean 的原因只是为了躲避并发那大概率是设计不合理。如果一个对象每次执行都需要携带大量临时状态那它更适合被设计成方法内部的普通对象而不是 Spring Bean。Spring 管理 prototype 的成本比普通 new 更高而且 prototype Bean 的生命周期不受容器统一管理复杂场景下反而不好把握。所以我的建议是无状态业务逻辑用 singleton有状态且可共享的核心数据用并发容器或锁有状态且每次调用独立的临时处理对象直接用 new 创建必要时才选用 ObjectProvider 获取 prototype Bean。这样分类后代码会好维护很多。5. Bean 生命周期、三级缓存与容器并发面试的高频延伸5.1 生命周期回调发生在“发布”之前不代表之后安全Spring Bean 的生命周期包括实例化、属性填充、BeanNameAware、BeanPostProcessor 前置处理、InitializingBean、自定义 init-method、BeanPostProcessor 后置处理等阶段。构造器和属性注入阶段如果抛异常Bean 不会被发布出去容器启动也会失败。这些阶段大部分发生在容器启动时核心线程通常是单线程的所以生命周期回调本身很少遇到并发问题。但有一个例外如果你的初始化方法里启动了子线程或者把一个可变字段发布到了静态变量里那 Bean 虽然在 Spring 看来已经初始化完成业务线程仍然可能在对象真正安全之前就开始使用它。尤其是那些在 init 方法里做异步预热、缓存加载的 Bean需要额外注意提前发布。5.2 三级缓存解决的是循环依赖不是并发安全面试中经常把“三级缓存”和“线程安全”放在一起考其实它们是两件事。Spring 的三级缓存通常指 DefaultSingletonBeanRegistry 里的三个 MapsingletonObjects 存放成品单例earlySingletonObjects 存放提前暴露的半成品singletonFactories 存放可以生成早期引用的工厂对象。这套机制是为了解决 A 依赖 B、B 依赖 A 这类循环依赖。Spring 在 A 创建过程中把它的早期引用通过 singletonFactories 暴露出去让 B 先拿到 A 的引用完成 B 的创建再把 A 继续初始化完。它解决的问题是“Bean 还没初始化完的时候其他 Bean 需要引用它”而不是“多个线程同时操作一个 Bean 时怎么保证数据一致”。面试时可以这样回答三级缓存是容器内部的注册表逻辑容器用 synchronized 和缓存映射保证 getBean 过程本身的线程安全但业务方法里的共享状态同步容器不会管那是编码层面的问题。把这两者分开面试官会认为你真的读过源码。5.3 Autowired 注入完成后不要再修改字段构造器注入的字段如果是 final从发布开始就不会改变天然具备不可变性基础。但 Spring 也支持 Autowired 字段注入和 setter 注入这类字段不是 final 的运行期可以被重新赋值。如果在某个 Service 方法里偷偷修改注入字段比如把 userMapper 换成另一个对象或者给缓存 Map 重新 new 一个实例就相当于给单例 Bean 注入了可变状态。即使你用了构造器注入也不能保证字段不可变因为类内部可能还有 setter。最稳妥的规范是注入的依赖在容器启动后只读不在运行时重新赋值业务数据一律放在方法参数或专门的存储组件里。这也是我们团队现在强制使用构造器注入的一个原因它能从语法层面把依赖标成 final提醒后来的人别乱改。6. 面试现场把这个问题答成加分题6.1 一句话结论先说给面试官听面试时不要长篇大论堆概念先给一个能落在纸面的结论。我通常这样说Spring Bean 本身不保证线程安全但它不等于不能安全使用。Spring 默认把 Bean 设置为 singleton单例实例会被多个线程共享如果这个 Bean 是无状态的没有可变成员变量那么多线程并发调用也是安全的。如果 Bean 有可变状态就必须通过局部变量、ThreadLocal、原子类或锁来保证并发访问安全。Spring 容器只负责创建、装配和管理 Bean不负责业务方法的并发同步。这个说法足够完整又不会给自己挖坑。说完之后再根据面试官的方向展开如果对方问作用域就讲 singleton 和 prototype如果问项目实践就举 Controller、Service 的并发例子如果问底层就顺着三级缓存往容器源码走。6.2 落到 Spring Boot 业务场景里展开面试官接下来通常会问“你们项目里 Controller 不也是单例吗怎么保证安全”这时候可以拿真实场景说明。Controller 本身一般没有状态它只是接收请求把参数传给 Service。Service 如果也只做参数处理和数据库调用那它就是无状态的。这些 Bean 共享给所有请求线程使用完全没问题。但一旦有人在 Service 里加了一个 Map 用来统计接口耗时问题就来了。多个请求同时往 Map 里写就会出现覆盖和丢失数据。我的处理方式是比如需要统计接口耗时就把每次请求的数据放到方法局部变量结束后立即写入外部存储绝不在单例 Bean 的成员变量里长期存放。这个例子既贴合 Spring Boot 开发日常又能证明你不是只会背理论。6.3 一个礼貌反问帮你判断面试官真正想问什么如果面试官问题问得比较宽我建议在回答前反问一句“您是指无状态 Bean 在单例模式下的并发安全还是 Spring 容器本身对 Bean 创建过程的线程安全”这一句能让你摸清对方的考察重点也能体现你思考问题的层次。如果对方继续追问三级缓存就按 5.2 的角度回答强调它解决的是循环依赖中的提前引用问题不是并发安全。如果对方追问怎么解决有状态 Bean 的并发问题就把四种方案摆出来优先讲无状态设计再讲 ThreadLocal、原子类、锁的适用场景。这样整个回答就变成一场技术讨论而不是标准答案背诵。我在实际项目里处理这个问题时有一条始终不变的原则先把代码写成无状态让所有调用间需要传递的数据都走参数和返回值实在需要跨请求共享的状态再引入缓存中间件或显式同步。按这个原则写出来的 Bean单例天然安全也基本不需要关注作用域切换。这句经验比任何面试模板都实用希望你也能在自己的代码里用上。