Spring Boot Controller 层怎么实现并发安全的?
一则或许对你有用的小广告
欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论
- 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于
Spring AI + Spring Boot3.x + JDK 21..., 点击查看; - 《从零手撸:仿小红书(微服务架构)》 已完结,基于
Spring Cloud Alibaba + Spring Boot3.x + JDK 17..., 点击查看项目介绍; 演示链接: http://116.62.199.48:7070/; - 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/
面试考察点
-
理解 Spring Bean 的默认作用域
面试官想知道你是否清楚 Spring MVC 的 Controller 默认是单例(Singleton)的,而不是每个请求都会创建一个新实例。 -
线程安全的基础知识
单例意味着多个线程(即多个HTTP请求)会同时访问同一个 Controller 实例。此时,如果你在 Controller 里定义了可变的成员变量,就可能出现数据错乱。面试官在考察你是否具备基本的并发安全意识。 -
解决并发问题的常见手段
不只是知道问题,还要知道怎么解决。是无状态设计?还是用同步锁?或者使用ThreadLocal?面试官希望看到你根据场景选择合适方案的能力。 -
框架层面的支持与限制
比如@Scope("prototype")、@SessionAttributes、RequestContextHolder等,了解这些说明你对 Spring 框架本身有较深入的认识。 -
实际开发中的最佳实践
最终,面试官会判断你在真实项目中是否会写出线程安全的代码,避免低级 bug。
核心答案
Spring Boot 中的 Controller 默认是单例的,也就是整个应用生命周期内只有一个实例来处理所有请求。因此,要实现并发安全,核心思路就是避免在 Controller 里定义可变的成员变量,让 Controller 成为无状态的。如果实在需要存储请求级别的数据,应该通过方法参数(如 @RequestParam、@PathVariable)或者 ThreadLocal 来传递。
深度解析
原理 / 机制
我们来捋一下 Spring MVC 处理请求的过程:
- 客户端发送请求到 Servlet 容器(如 Tomcat)。
- Tomcat 为每个请求分配一个独立的线程(默认线程池模型)。
- Spring 的前端控制器
DispatcherServlet接收请求,根据 URL 找到对应的 Controller 和 Method。 - 由于 Controller 是单例,多个线程会同时执行同一个 Controller 实例的方法。
这时,如果 Controller 里有这样一段代码:
@RestController
public class UnsafeController {
private int count = 0; // 共享可变状态
@GetMapping("/increment")
public String increment() {
count++; // 多线程下非原子操作
return "Count: " + count;
}
}
当多个请求同时打到 /increment 时,count++ 这个操作就不是原子的,很可能出现线程交错,导致最终结果比预期小(丢失更新)。这就是典型的线程安全问题。
代码示例
错误示例(有状态 Controller)
@RestController
public class WrongController {
private int visitorCount = 0; // 隐患!多个线程共享
@GetMapping("/visit")
public String visit() {
visitorCount++;
return "你是第 " + visitorCount + " 位访客";
}
}
正确示例(无状态 Controller)
@RestController
public class SafeController {
// 没有任何可变的成员变量,所有数据通过方法参数传递
@GetMapping("/add")
public int add(@RequestParam int a, @RequestParam int b) {
return a + b;
}
}
如果必须使用成员变量(比如需要注入 Service)
Service 本身也应该是无状态的(通常 Service 也是单例,且内部无可变成员变量)。如果 Service 有状态,同样需要注意并发问题。
@RestController
public class GoodController {
@Autowired
private UserService userService; // 注入的 Service 如果是无状态的,则安全
@GetMapping("/user/{id}")
public User getUser(@PathVariable Long id) {
return userService.findById(id);
}
}
特殊情况:使用 ThreadLocal
某些场景下,我们确实需要在一次请求的多个地方共享数据,但又不想通过方法参数层层传递。这时可以使用 ThreadLocal,它能为每个线程(即每个请求)维护一个独立的副本。
Spring 框架本身也利用了 ThreadLocal 来实现事务管理(TransactionSynchronizationManager)和请求上下文(RequestContextHolder)。
@RestController
public class ThreadLocalController {
private static final ThreadLocal<Integer> requestLocal = new ThreadLocal<>();
@GetMapping("/set")
public String set(@RequestParam int value) {
requestLocal.set(value);
return "set: " + value;
}
@GetMapping("/get")
public String get() {
Integer value = requestLocal.get();
return "get: " + value;
}
}
注意:用完 ThreadLocal 后一定要调用 remove() 方法清理,避免内存泄漏或请求间数据污染(尤其在使用了线程池的情况下)。
对比分析
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 无状态设计 | 简单、性能好、无锁 | 无法存储请求相关的成员变量 | 绝大多数 Controller |
| 同步锁(synchronized) | 保证线程安全 | 串行化,性能极差,几乎不用 | 极少数需要全局计数的场景(不推荐) |
| ThreadLocal | 每个请求独立,无锁 | 内存开销,需手动清理 | 需要在一次请求内传递上下文(如用户信息、traceId) |
| 修改作用域为 prototype | 每次请求创建新实例 | 创建开销大,无法利用单例优势 | 极少用,通常不推荐 |
最佳实践
-
默认无状态
Controller 里不要定义可变的实例变量。所有需要的数据都通过方法参数传入,或者从请求上下文中获取。 -
利用 Spring 的请求作用域 Bean
如果确实需要一个请求级别的 Bean,可以使用@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS),这样每次请求都会产生一个新的 Bean 实例。 -
谨慎使用
ThreadLocal
如果用,务必在请求结束时(例如通过HandlerInterceptor的afterCompletion)调用remove()清理。 -
避免在 Controller 中编写复杂业务逻辑
业务逻辑交给 Service 层处理,Controller 只负责参数解析、路由和响应。Service 层也要保持无状态。
常见误区
-
误区 1:认为每个请求都会创建一个新的 Controller 实例。
事实:Controller 默认是单例,除非显式声明@Scope("prototype")。 -
误区 2:觉得在方法内部定义的局部变量不会有线程安全问题。
局部变量是线程私有的,确实安全。但要注意,如果局部变量引用的是一个共享的可变对象(如 ArrayList 成员变量),则仍然有并发问题。 -
误区 3:滥用同步锁。
在 Controller 里加synchronized会让所有请求串行处理,完全丧失并发能力,不到万不得已不要用。 -
误区 4:忽视依赖注入的 Bean 的线程安全性。
注入的 Service、Repository 等,如果它们内部有可变状态且是单例的,同样会引发并发问题。
总结
Spring Boot Controller 层的并发安全问题,本质上是由单例模式和多线程并发访问引起的。最优雅的解决方案就是让 Controller 保持无状态 —— 不持有可变的成员变量。如果实在需要存储请求级别的数据,可以借助 ThreadLocal 或请求作用域的 Bean,但要注意清理和性能开销。记住这些原则,你就能写出既安全又高效的 Controller 代码。