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+ 小伙伴加入学习,欢迎点击围观

面试考察点

  1. Bean 作用域理解:面试官想知道你是否清楚 Spring MVC 的 Controller 是单例的,以及单例意味着什么——多线程共享同一个实例。
  2. 并发编程基础:能否识别出 Controller 中的线程安全隐患(成员变量被并发修改),以及知道怎么解决。
  3. 无状态设计意识:更深层地,面试官想看你是否有 "无状态设计" 的思维方式,这是写高并发代码的基本素养。

核心答案

先说结论:Spring MVC 的 Controller 默认是单例的,天然就存在并发安全问题。 但实际开发中,只要 Controller 不使用成员变量(实例变量),只使用方法内的局部变量,就是线程安全的。因为局部变量是每个线程独有的栈帧内存,不会跨线程共享。

如果必须在 Controller 中使用可变共享状态,有以下几种解决方案:

方案 做法 适用场景
最佳实践 不用成员变量,只用局部变量 99% 的场景
改变作用域 @Scope("prototype") 多例 少数需要状态的场景
使用 ThreadLocal 成员变量用 ThreadLocal 包装 需要线程隔离的场景
使用并发工具 ConcurrentHashMapAtomicInteger 必须共享状态的计数/缓存场景
加锁 synchronizedReentrantLock 严格控制并发访问(不推荐在 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 等)默认都是单例,全部遵循同一个原则:不要定义可变的成员变量。

五、一张图总结

面试高频追问

  1. 追问一:Spring MVC 为什么默认把 Controller 设计成单例?

    • 性能考虑。如果每次请求都创建一个 Controller 实例,创建和销毁对象的 GC 开销很大。单例模式下只创建一次,所有请求共享,性能最好。只要不使用可变成员变量,单例就是安全的。
  2. 追问二:@Autowired 注入的 Service 是线程安全的吗?

    • 是的,前提是 Service 本身也是无状态的(没有可变成员变量)。注入的 Service 引用本身是 final 的(Spring 推荐构造器注入),不会变,所以安全。如果 Service 里有可变成员变量,那跟 Controller 一样存在并发问题。
  3. 追问三:SimpleDateFormat 的线程安全问题怎么解决?

    • 三种方式:用 ThreadLocal<SimpleDateFormat> 包装、每次 new 一个新的(局部变量)、或者直接用 DateTimeFormatter(JDK 8 引入,天然线程安全)。推荐第三种,DateTimeFormatter 设计上就是不可变的,怎么用都安全。

常见面试变体

  • "Spring MVC 的 Controller 是单例还是多例?有什么影响?"
  • "如何在 Spring 中保证线程安全?"
  • "Spring Bean 的线程安全问题怎么处理?"
  • "为什么说无状态的 Bean 是线程安全的?"

总结

Controller 层实现并发安全的核心就一句话:保持无状态——不要用可变的成员变量,只用局部变量和方法参数。 Controller 默认单例,所有线程共享一个实例,但只要不共享可变状态,天然就是线程安全的。如果实在需要共享状态,用并发安全类(AtomicIntegerConcurrentHashMap)或 ThreadLocal 来保护。面试时把这个 "无状态设计" 的思想讲清楚,比罗列一堆解决方案更能打动面试官。