Spring Boot Controller 层怎么实现并发安全的?
一则或许对你有用的小广告
欢迎加入小哈的星球,你将获得:专属的实战项目(4个项目都能学) / 1v1 提问 / 简历修改 / Java 学习路线 / 社群讨论 / 学习打卡 / 每月赠书
《Spring AI 项目实战(问答机器人、RAG 智能客服、联网搜索)》已完结,基于
Spring AI + Spring Boot 3.x + JDK 21...,查看介绍《从零手撸:仿小红书(微服务架构)》 已完结,基于
Spring Cloud Alibaba + Spring Boot 3.x + JDK 17...,查看介绍;演示链接:http://116.62.199.48:7070/《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接:http://116.62.199.48/
新开坑项目:《从零手撸:秒杀系统高并发优化实战》 正在更新中...,查看介绍
截止目前,星球内专栏累计输出 150w+ 字,讲解图 5110+ 张,还在持续爆肝中.. 后续还会上新更多项目,已有 4700+ 小伙伴加入学习,欢迎点击围观
面试考察点
- Bean 作用域理解:面试官想知道你是否清楚 Spring MVC 的 Controller 是单例的,以及单例意味着什么——多线程共享同一个实例。
- 并发编程基础:能否识别出 Controller 中的线程安全隐患(成员变量被并发修改),以及知道怎么解决。
- 无状态设计意识:更深层地,面试官想看你是否有 "无状态设计" 的思维方式,这是写高并发代码的基本素养。
核心答案
先说结论:Spring MVC 的 Controller 默认是单例的,天然就存在并发安全问题。 但实际开发中,只要 Controller 不使用成员变量(实例变量),只使用方法内的局部变量,就是线程安全的。因为局部变量是每个线程独有的栈帧内存,不会跨线程共享。
如果必须在 Controller 中使用可变共享状态,有以下几种解决方案:
| 方案 | 做法 | 适用场景 |
|---|---|---|
| 最佳实践 | 不用成员变量,只用局部变量 | 99% 的场景 |
| 改变作用域 | @Scope("prototype") 多例 |
少数需要状态的场景 |
使用 ThreadLocal |
成员变量用 ThreadLocal 包装 |
需要线程隔离的场景 |
| 使用并发工具 | ConcurrentHashMap、AtomicInteger 等 |
必须共享状态的计数/缓存场景 |
| 加锁 | synchronized 或 ReentrantLock |
严格控制并发访问(不推荐在 Controller 用) |
深度解析
一、为什么 Controller 存在并发安全问题?
Spring MVC 的 Controller 默认是 单例(Singleton) 的,整个应用只有一个实例。Tomcat 处理每个请求都会用一个独立线程调用同一个 Controller 实例的方法。如果 Controller 里有可变的成员变量,多个线程同时读写同一个变量,并发问题就来了。
上图展示了单例 Controller 中成员变量的并发问题:
- Controller 是单例:Spring 容器中只有一个 Controller 实例,所有请求都调用这同一个对象的方法。
- 成员变量被共享:
count是成员变量,存储在堆内存中,所有线程都能访问到同一个变量。 - 线程并发修改:Tomcat 线程池中的多个工作线程同时执行
count++操作,这是一个非原子操作(读 → 改 → 写三步),导致丢失更新。
二、错误示范 vs 正确写法
错误示范:Controller 中使用可变成员变量
@RestController
public class UserController {
// ❌ 危险!成员变量被所有线程共享,并发不安全
private int requestCount = 0;
// ❌ 危险!SimpleDateFormat 不是线程安全的
private SimpleDateFormat sdf =
new SimpleDateFormat("yyyy-MM-dd");
// ❌ 危险!普通 HashMap 并发读写可能死循环
private Map<String, User> cache = new HashMap<>();
@GetMapping("/user")
public User getUser(@RequestParam Long id) {
requestCount++; // 非原子操作,并发下会丢数据
String date = sdf.format(new Date()); // 可能返回错误结果
cache.put("key", user); // 并发 put 可能导致数据丢失
return userService.getById(id);
}
}
上面这些问题都是真实发生过的。尤其是 SimpleDateFormat 那个坑,当年线上出过诡异的数据格式错乱,排查了半天才发现是这个原因。
正确写法:使用局部变量
@RestController
public class UserController {
@Autowired
private UserService userService; // ✅ 无状态注入,安全
@GetMapping("/user")
public User getUser(@RequestParam Long id) {
// ✅ 局部变量,每个线程在各自的栈帧中独享,天然线程安全
int count = 0;
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
Map<String, Object> result = new HashMap<>();
return userService.getById(id);
}
}
核心原则:Controller 中只使用局部变量和方法参数,不使用可变的成员变量。 这就是 "无状态设计"——Controller 不持有任何跟请求相关的状态,处理完请求就 "干净" 了,不管多少线程同时进来,互不干扰。
三、如果一定要用共享状态怎么办?
有些场景确实需要在 Controller 中维护一些状态。比如统计接口调用次数、做接口限流等。下面是几种安全的方式:
方式一:使用并发安全类
@RestController
public class UserController {
// ✅ 原子类,CAS 保证原子性
private final AtomicInteger requestCount = new AtomicInteger(0);
// ✅ 并发安全 Map
private final ConcurrentHashMap<String, User> cache =
new ConcurrentHashMap<>();
@GetMapping("/user")
public User getUser(@RequestParam Long id) {
requestCount.incrementAndGet(); // 原子自增
return userService.getById(id);
}
}
方式二:改变 Controller 的作用域为多例
@RestController
@Scope("prototype") // 每次请求都创建新的 Controller 实例
public class UserController {
// 现在每个线程有自己的实例,成员变量不共享
private int requestCount = 0; // 安全,但失去统计意义
}
但说实话,多例模式基本不推荐。每次请求都创建一个 Controller 实例,增加 GC 压力,而且让 requestCount 这种变量失去了统计意义——每个实例都有自己的 count,加起来不对。大部分面试官听到你说 "可以用 @Scope("prototype")" 之后,会追问 "有什么缺点",所以你得准备好回答。
方式三:使用 ThreadLocal
@RestController
public class UserController {
// ✅ ThreadLocal 保证每个线程有自己的副本
private final ThreadLocal<SimpleDateFormat> sdf =
ThreadLocal.withInitial(() ->
new SimpleDateFormat("yyyy-MM-dd"));
@GetMapping("/user")
public User getUser(@RequestParam Long id) {
String date = sdf.get().format(new Date()); // 线程安全
// 注意:用完后要在拦截器/过滤器中 remove()
// 防止内存泄漏(尤其是线程池复用线程的场景)
sdf.remove();
return userService.getById(id);
}
}
ThreadLocal 的坑是内存泄漏。Tomcat 线程池会复用线程,如果不 remove(),上一次请求的数据会残留到下一次请求。建议在拦截器的 afterCompletion() 中统一做清理。
四、Service 层需要考虑并发安全吗?
面试官可能会追问这个问题。答案是一样的:Service 默认也是单例的,同样的规则适用——不用成员变量就是安全的。
@Service
public class UserService {
@Autowired
private UserMapper userMapper; // ✅ 无状态注入,安全
// ❌ 别这么写!
private User currentUser; // 并发不安全
public User getById(Long id) {
// ✅ 局部变量,安全
User user = userMapper.selectById(id);
return user;
}
}
Spring 管理的 Bean(@Service、@Component、@Repository 等)默认都是单例,全部遵循同一个原则:不要定义可变的成员变量。
五、一张图总结
面试高频追问
-
追问一:Spring MVC 为什么默认把 Controller 设计成单例?
- 性能考虑。如果每次请求都创建一个 Controller 实例,创建和销毁对象的 GC 开销很大。单例模式下只创建一次,所有请求共享,性能最好。只要不使用可变成员变量,单例就是安全的。
-
追问二:
@Autowired注入的 Service 是线程安全的吗?- 是的,前提是 Service 本身也是无状态的(没有可变成员变量)。注入的 Service 引用本身是
final的(Spring 推荐构造器注入),不会变,所以安全。如果 Service 里有可变成员变量,那跟 Controller 一样存在并发问题。
- 是的,前提是 Service 本身也是无状态的(没有可变成员变量)。注入的 Service 引用本身是
-
追问三:
SimpleDateFormat的线程安全问题怎么解决?- 三种方式:用
ThreadLocal<SimpleDateFormat>包装、每次new一个新的(局部变量)、或者直接用DateTimeFormatter(JDK 8 引入,天然线程安全)。推荐第三种,DateTimeFormatter设计上就是不可变的,怎么用都安全。
- 三种方式:用
常见面试变体
- "Spring MVC 的 Controller 是单例还是多例?有什么影响?"
- "如何在 Spring 中保证线程安全?"
- "Spring Bean 的线程安全问题怎么处理?"
- "为什么说无状态的 Bean 是线程安全的?"
总结
Controller 层实现并发安全的核心就一句话:保持无状态——不要用可变的成员变量,只用局部变量和方法参数。 Controller 默认单例,所有线程共享一个实例,但只要不共享可变状态,天然就是线程安全的。如果实在需要共享状态,用并发安全类(AtomicInteger、ConcurrentHashMap)或 ThreadLocal 来保护。面试时把这个 "无状态设计" 的思想讲清楚,比罗列一堆解决方案更能打动面试官。
