📌PDF:大白话说Java面试题 — 06_Spring篇
第13题:Spring 中 Bean 是线程安全的吗?
📚回答:
- 核心考点: Spring Bean 的线程安全性是并发编程与 Spring 框架交叉的经典问题,大厂面试不会只问"是否安全",而是深入考察Spring 作用域与线程安全的关系(
singleton/prototype/request/session)、有状态 Bean vs 无状态 Bean 的设计原则、ThreadLocal 在 Spring 中的正确使用姿势(内存泄漏风险)、以及@Scope(proxyMode = ScopedProxyMode.TARGET_CLASS)解决作用域代理问题的原理。面试官真正想判断的是:你是否能从框架设计层面理解线程安全的本质,以及能否在 Controller 层、Service 层、DAO 层等不同层级做出正确的线程安全设计。
1. Spring Bean 作用域与线程安全性
Spring 定义了 6 种 Bean 作用域,其中 4 种在 Web 环境下可用:
| 作用域 | 说明 | 线程安全性 | 适用场景 |
|---|---|---|---|
singleton | 默认,每个 Spring 容器只有一个实例 | 不安全(有状态时) | 无状态 Service、DAO、工具类 |
prototype | 每次获取都创建新实例 | 安全(天然隔离) | 有状态对象,但创建开销大 |
request | 每个 HTTP 请求一个实例 | 安全(请求隔离) | Web 环境,请求级状态 |
session | 每个 HTTP Session 一个实例 | 安全(会话隔离) | Web 环境,用户级状态 |
application | 每个 ServletContext 一个实例 | 不安全(有状态时) | 全局配置、缓存 |
websocket | 每个 WebSocket 连接一个实例 | 安全(连接隔离) | WebSocket 场景 |
关键结论:Spring 的singleton作用域本身不提供线程安全保证,线程安全取决于 Bean 的状态设计。
2. 有状态 Bean vs 无状态 Bean——设计的分水岭
2.1 无状态 Bean(线程安全)
无状态 Bean 是指不保存任何实例变量的 Bean,所有操作都通过方法参数和返回值完成:
@ServicepublicclassUserService{@AutowiredprivateUserDaouserDao;// 依赖注入,本身无状态publicUsergetUser(Longid){returnuserDao.findById(id);// 纯查询,不修改实例变量}publicvoidupdateUser(Useruser){userDao.update(user);// 操作通过参数传递,无实例变量修改}}无状态 Bean 的特征:
- 没有可变的实例变量(
final常量除外); - 不保存用户会话信息或请求上下文;
- 方法之间不共享状态;
- 天然线程安全,所有线程共享同一个实例无风险。
Spring 中 99% 的 Bean 应该是无状态的:Service、DAO、Mapper、Repository 等通常都是无状态设计。
- 没有可变的实例变量(
2.2 有状态 Bean(线程不安全)
有状态 Bean 保存了可变的实例变量,多个线程并发访问时产生竞态条件:
@Service// ❌ 错误:有状态的单例 BeanpublicclassCounterService{privateintcount=0;// 实例变量,线程共享publicvoidincrement(){count++;// 非原子操作,线程不安全!}publicintgetCount(){returncount;}}并发问题演示:
时间线 线程 A 线程 B count 值 T1 读取 count = 0 — 0 T2 — 读取 count = 0 0 T3 计算 0 + 1 = 1 — 0 T4 — 计算 0 + 1 = 1 0 T5 写入 count = 1 — 1 T6 — 写入 count = 1 1 两个线程各执行一次
increment(),预期结果为 2,实际结果为 1,丢失了一次更新。2.3 有状态 Bean 的典型误用场景
误用场景 问题 正确做法 Controller 中保存用户上下文 多请求共享状态,数据串乱 使用方法参数传递,或 ThreadLocal Service 中缓存查询结果到实例变量 多线程覆盖缓存 使用外部缓存(Redis/Caffeine) 工具类中保存临时计算状态 并发计算结果互相干扰 使用局部变量,或改为无状态 @Autowired的 Bean 被修改依赖对象被替换 使用 final+ 构造器注入
3. 保证线程安全的五种方案
3.1 方案一:无状态设计(首选)
将 Bean 设计为无状态,所有数据通过方法参数传递:
@ServicepublicclassCounterService{// ✅ 无实例变量,天然线程安全publicintincrement(intcount){returncount+1;// 通过参数和返回值传递状态}}优势:零同步开销,性能最优,代码最清晰。
适用场景:Service 层、DAO 层、工具类。3.2 方案二:不可变对象
使用
final修饰字段,对象创建后不可变:@ServicepublicclassConfigService{privatefinalMap<String,String>configMap;// final 引用publicConfigService(@Value("${app.config}")Stringconfig){this.configMap=parseConfig(config);// 构造时初始化,之后不可变}publicStringgetConfig(Stringkey){returnconfigMap.get(key);// 只读操作,线程安全}}注意:
final只保证引用不可变,如果引用对象本身可变(如ArrayList),仍需同步。3.3 方案三:ThreadLocal(线程隔离)
ThreadLocal为每个线程提供独立的变量副本,实现线程级隔离:@ServicepublicclassRequestContextService{// 每个线程有独立的 SimpleDateFormat 副本privatestaticfinalThreadLocal<SimpleDateFormat>dateFormatHolder=ThreadLocal.withInitial(()->newSimpleDateFormat("yyyy-MM-dd HH:mm:ss"));publicStringformatDate(Datedate){returndateFormatHolder.get().format(date);}}ThreadLocal 在 Spring 中的经典应用:
场景 使用方式 说明 日期格式化 ThreadLocal<SimpleDateFormat>SimpleDateFormat非线程安全数据库连接 ThreadLocal<Connection>Spring 事务管理器底层实现 用户上下文 ThreadLocal<UserContext>拦截器设置,Service 层获取 请求追踪 ThreadLocal<TraceId>全链路日志追踪 ⚠️ ThreadLocal 内存泄漏风险:
@ServicepublicclassUserContextHolder{privatestaticfinalThreadLocal<User>currentUser=newThreadLocal<>();publicstaticvoidsetUser(Useruser){currentUser.set(user);}publicstaticUsergetUser(){returncurrentUser.get();}// ✅ 必须在使用完毕后清理!publicstaticvoidclear(){currentUser.remove();// 防止内存泄漏}}// 在拦截器中清理publicclassUserContextInterceptorimplementsHandlerInterceptor{@OverridepublicvoidafterCompletion(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler,Exceptionex){UserContextHolder.clear();// 请求结束后清理}}内存泄漏原因:ThreadLocal 的键是弱引用(
WeakReference<ThreadLocal<?>>),但值是强引用。如果线程池复用线程,线程结束时 ThreadLocal 的键被 GC,但值仍被线程的ThreadLocalMap引用,导致内存泄漏。解决方案:
- 使用完必须
remove(); - 使用
try-finally确保清理; - 使用
InheritableThreadLocal时注意子线程继承问题; - 使用
TransmittableThreadLocal(阿里开源)解决线程池传递问题。
- 使用完必须
3.4 方案四:同步机制(synchronized/Lock/Atomic)
当必须共享可变状态时,使用同步机制:
@ServicepublicclassCounterService{privatefinalAtomicIntegercount=newAtomicInteger(0);// ✅ 原子操作publicvoidincrement(){count.incrementAndGet();// CAS 无锁,线程安全}publicintgetCount(){returncount.get();}}同步方案 适用场景 性能 代码复杂度 synchronized简单临界区 低(阻塞) 低 ReentrantLock需要超时/中断/条件变量 中(阻塞) 中 AtomicInteger/Long简单计数器 高(CAS) 低 LongAdder高并发计数器 极高(分段) 低 ConcurrentHashMap并发 Map 高(分段锁) 低 CopyOnWriteArrayList读多写少列表 高(无锁读) 低 3.5 方案五:改变作用域(prototype/request)
当 Bean 必须保存状态时,改变作用域避免共享:
@Component@Scope(value=WebApplicationContext.SCOPE_REQUEST,proxyMode=ScopedProxyMode.TARGET_CLASS)publicclassRequestContext{privateStringtraceId;privateLonguserId;// ... 请求级状态}proxyMode的作用:当
singletonBean 注入request作用域 Bean 时,由于singletonBean 只创建一次,而requestBean 每个请求都不同,直接注入会导致requestBean 在首次注入后固定不变。ScopedProxyMode.TARGET_CLASS(CGLIB 代理)或ScopedProxyMode.INTERFACES(JDK 代理)会为作用域 Bean 创建代理对象,每次调用时从当前作用域(如当前 Request)获取真实实例:@Service// singletonpublicclassUserService{@AutowiredprivateRequestContextrequestContext;// 注入的是代理对象publicvoiddoSomething(){// 每次调用都会从当前 Request 获取真实实例StringtraceId=requestContext.getTraceId();}}
4. Spring 各层的线程安全设计规范
| 层级 | 作用域 | 状态设计 | 线程安全策略 |
|---|---|---|---|
| Controller | singleton | 无状态 | 方法参数传递请求数据,不保存实例变量 |
| Service | singleton | 无状态 | 纯业务逻辑,依赖通过注入获取 |
| DAO/Mapper | singleton | 无状态 | 只负责数据访问,不保存查询结果 |
| Entity/POJO | prototype | 有状态 | 每个请求/线程独立实例 |
| 配置类 | singleton | 不可变 | final字段,构造时初始化 |
| 缓存组件 | singleton | 有状态(缓存数据) | 使用线程安全的缓存(Redis/Caffeine/ConcurrentHashMap) |
5. 生产环境避坑指南
5.1 不要在单例 Bean 中使用实例变量保存请求数据
@RestController// ❌ 致命错误!单例 + 有状态publicclassUserController{privateUsercurrentUser;// 多个请求共享!@GetMapping("/user/{id}")publicUsergetUser(@PathVariableLongid){currentUser=userService.findById(id);// 请求A的数据被请求B覆盖returncurrentUser;}}// ✅ 正确:无状态设计@RestControllerpublicclassUserController{@GetMapping("/user/{id}")publicUsergetUser(@PathVariableLongid){returnuserService.findById(id);// 直接返回,不保存状态}}5.2 SimpleDateFormat 必须用 ThreadLocal
SimpleDateFormat是非线程安全的,多线程共享会导致日期解析错误:@ServicepublicclassDateService{// ❌ 错误:共享 SimpleDateFormatprivatestaticfinalSimpleDateFormatsdf=newSimpleDateFormat("yyyy-MM-dd");// ✅ 正确:ThreadLocal 隔离privatestaticfinalThreadLocal<SimpleDateFormat>sdfHolder=ThreadLocal.withInitial(()->newSimpleDateFormat("yyyy-MM-dd"));}Java 8+ 推荐:使用
DateTimeFormatter(线程安全),彻底告别 ThreadLocal:privatestaticfinalDateTimeFormatterformatter=DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");5.3 注意 @Async 与 ThreadLocal
@Async使用线程池执行异步任务,子线程无法继承父线程的ThreadLocal:@ServicepublicclassUserService{publicvoidprocess(){UserContextHolder.setUser(newUser("admin"));// 主线程设置asyncTask.execute();// 子线程中 UserContextHolder.getUser() = null!}}解决方案:使用
InheritableThreadLocal或阿里TransmittableThreadLocal(TTL)。5.4 线程池场景下的 ThreadLocal
线程池复用线程,如果不清理 ThreadLocal,下一个任务可能读到上一个任务的数据:
executor.execute(()->{try{ThreadLocalHolder.set(data);// 执行业务逻辑...}finally{ThreadLocalHolder.remove();// ✅ 必须清理}});5.5 警惕 Spring 代理对象的线程安全
@Transactional、@Cacheable等注解基于 AOP 代理实现,代理对象本身是单例且线程安全的。但目标对象中的实例变量仍然需要开发者保证线程安全。
6. 面试官追问与高分回答模板
追问 1:“Spring 中的 Bean 是线程安全的吗?”
低分回答:“不是,单例 Bean 多线程共享,有状态时不安全。”(没有区分状态设计)
高分回答:
"Spring Bean 的线程安全性取决于作用域和状态设计,不能一概而论:
- 默认
singleton作用域:Spring 容器只创建一个实例,多线程共享。如果 Bean 是无状态的(没有可变实例变量),则天然线程安全;如果 Bean 是有状态的(保存了可变实例变量),则线程不安全。 prototype/request/session作用域:每个线程/请求/会话独立实例,天然线程安全,但创建开销大。
因此,Spring Bean 是否线程安全,核心在于状态设计而非作用域。Spring 官方推荐将 Bean 设计为无状态,这是 Service 层、DAO 层的最佳实践。"
- 默认
追问 2:“如何保证 Spring Bean 的线程安全?”
高分回答:
"保证线程安全有五种方案,按推荐优先级排序:
- 无状态设计(首选):Bean 不保存实例变量,所有数据通过方法参数传递。零同步开销,性能最优,代码最清晰。Spring 中 99% 的 Bean 应该如此设计。
- 不可变对象:使用
final字段,对象创建后不可变。注意final只保证引用不可变,引用对象本身可变时仍需同步。 - ThreadLocal 线程隔离:为每个线程提供独立变量副本。适用于日期格式化、用户上下文等场景。但必须注意内存泄漏,使用完必须
remove()。 - 同步机制:
AtomicInteger、ConcurrentHashMap、synchronized等。适用于必须共享可变状态的场景。 - 改变作用域:
@Scope("prototype")或@Scope("request")配合proxyMode = TARGET_CLASS。适用于必须保存状态且无法重构的场景,但创建开销大。
推荐优先级:无状态 > 不可变 > ThreadLocal > 同步机制 > 改变作用域。"
追问 3:“ThreadLocal 在 Spring 中怎么用?有什么风险?”
高分回答:
"ThreadLocal 在 Spring 中的典型应用包括:
- 日期格式化:
SimpleDateFormat非线程安全,用 ThreadLocal 隔离; - 用户上下文:拦截器设置当前用户,Service 层获取;
- 数据库连接:Spring 事务管理器底层用 ThreadLocal 绑定连接;
- 请求追踪:TraceId 全链路传递。
内存泄漏风险:
ThreadLocal 的键是WeakReference<ThreadLocal<?>>,但值是强引用。线程池场景下,线程复用不结束,ThreadLocalMap 中的值不会被清理,导致内存泄漏。解决方案:
- 使用完必须调用
remove(); - 使用
try-finally确保清理; - 在拦截器的
afterCompletion()中清理; - 使用
TransmittableThreadLocal(阿里 TTL)解决线程池传递和自动清理问题。
Java 8+ 替代方案:
DateTimeFormatter线程安全,可替代ThreadLocal<SimpleDateFormat>。"- 日期格式化:
追问 4:“@Scope(proxyMode = TARGET_CLASS) 是做什么的?”
高分回答:
"
proxyMode用于解决不同作用域 Bean 的注入问题。当
singletonBean(如 Service)注入request作用域 Bean(如 RequestContext)时,Service 只创建一次,如果直接注入 RequestContext,会在首次注入时固定为一个 Request 的实例,后续请求获取的是旧数据。ScopedProxyMode.TARGET_CLASS会为requestBean 创建CGLIB 代理对象。Service 注入的是代理对象,每次调用代理对象的方法时,代理会从当前 Request 作用域中获取真实的 Bean 实例,确保每次请求获取的都是当前请求的实例。类似地,
ScopedProxyMode.INTERFACES使用 JDK 动态代理,要求目标类实现接口。"追问 5:“Spring 的 @Transactional 是线程安全的吗?”
高分回答:
"
@Transactional本身是线程安全的,原因:- 代理对象线程安全:Spring 为 Bean 创建的 AOP 代理对象是单例的,代理逻辑(开启事务、提交/回滚)是无状态的;
- 事务上下文线程隔离:Spring 使用
TransactionSynchronizationManager(底层是 ThreadLocal)将数据库连接绑定到当前线程,每个线程有独立的事务上下文; - 事务管理器无状态:
DataSourceTransactionManager等管理器本身不保存事务状态。
但需要注意:如果事务方法中修改了 Bean 的实例变量,这些变量仍然是线程共享的,需要开发者自行保证线程安全。
@Transactional只保证事务本身的线程安全,不保证业务数据的线程安全。"追问 6:“你在项目中怎么设计线程安全的 Spring Bean?”
高分回答:
"我的设计原则是分层的:
Controller 层:严格无状态,不保存任何实例变量。请求数据通过方法参数(
@PathVariable、@RequestBody)传递,响应直接返回。Service 层:严格无状态,业务逻辑通过参数和返回值传递。需要共享的缓存使用外部服务(Redis),需要计数的使用
LongAdder或 Redis。DAO/Mapper 层:无状态,只负责数据访问。
用户上下文:使用 ThreadLocal 传递,在拦截器中设置,在
afterCompletion()中清理。Java 8+ 用DateTimeFormatter替代ThreadLocal<SimpleDateFormat>。配置类:使用
final不可变对象,构造器注入。唯一使用有状态 Bean 的场景是请求级上下文(如
RequestContext),使用@Scope(value = SCOPE_REQUEST, proxyMode = TARGET_CLASS),并确保通过代理访问。"
7. 方案选型速查表
| 业务场景 | 推荐方案 | 核心理由 |
|---|---|---|
| Service/DAO 层设计 | 无状态 Bean | 零同步开销,性能最优,Spring 推荐 |
| 配置类 | 不可变对象(final) | 构造时初始化,之后只读 |
| 日期格式化 | DateTimeFormatter(Java 8+) | 线程安全,无需 ThreadLocal |
| 用户上下文传递 | ThreadLocal + 拦截器清理 | 线程隔离,记得 remove() |
| 简单计数器 | AtomicInteger | CAS 无锁,性能高 |
| 高并发计数器 | LongAdder | 分段累加,避免 CAS 冲突 |
| 请求级状态 | @Scope(request) + proxyMode | 请求隔离,通过代理访问 |
| 会话级状态 | @Scope(session) + proxyMode | 会话隔离 |
| 线程池任务上下文 | TransmittableThreadLocal | 解决线程池传递和清理问题 |
| 并发 Map | ConcurrentHashMap | 分段锁,高并发安全 |
| 读多写少列表 | CopyOnWriteArrayList | 无锁读,写时复制 |
💡面试官想要的满分总结:
Spring Bean 的线程安全性不是框架保证的,而是开发者设计的责任。默认
singleton作用域下,无状态 Bean 天然线程安全,有状态 Bean 必须采取保护措施。理解线程安全必须抓住三个核心:
- 状态是根源:线程安全问题的本质是共享可变状态。无状态设计从根本上消除了这个问题,是 Spring 开发的金标准。
- ThreadLocal 是双刃剑:它实现了线程隔离,但内存泄漏风险(尤其是线程池场景)必须警惕。使用完必须
remove(),Java 8+ 优先用DateTimeFormatter等线程安全类替代。- 作用域代理解决跨域注入:
singletonBean 注入requestBean 时,必须使用proxyMode = TARGET_CLASS创建作用域代理,确保每次调用获取当前作用域的真实实例。工程实践中,99% 的 Spring Bean 应该是无状态的。Controller、Service、DAO 层都不应保存实例变量。只有真正的请求级/会话级状态才考虑有状态设计,且必须通过作用域代理或 ThreadLocal 隔离。记住:线程安全不是事后加锁,而是事前设计。
觉得对您有帮助,麻烦点点关注啦,您的关注是我创作的最大动力~ 🎯