Best practice-ThreadLocal高并发场景的最佳实践

关于ThreadLocal基础信息

引用一段来自ThreadLocal源码中的doc注释来说明其特性:

This class provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one (via its get or set method) has its own, independently initialized copy of the variable. ThreadLocal instances are typically private static fields in classes that wish to associate state with a thread (e. g., a user ID or Transaction ID).

个人理解:该类提供了一个线程级别的变量,不同线程间通过get/set方法可以访问到自己不同的数据副本,ThreadLocal实例通常是类中的私有的静态数据如userId或事务id。

ThreadLocal在Java中是一个线程级别的变量,每一个线程都有自己独立的变量,ThreadLocal类型所创建,key是线程信息,value值是数据的副本,其副本值在各个线程间不可见,在并发模式下各个线程会访问到自己对应副本的数据信息。

通过以上特性信息,可以将ThreadLoacl应用在获取信息较为昂贵且上下文中多次使用到的业务场景,用户信息的存储和缓存场景就很适合使用该类。

线程池中的线程重用

前置知识

Spring Boot虽然没有明确配置Tomcat的地方,实则不同Spring Boot版本内置了Tomcat,不同Spring Boot版本与Tomcat版本呈现绑定关系。以下用例为方便复现Spring Boot项目中的线程复用问题,需要手动将Spring Boot的线程池大小设置为1,以保证每次请求都是同一个线程处理请求。Spring Boot 2.1.x版本之后内置Tomcat 9.x,Spring Boot 3.x版本后内置Tomcat 10.x,修properties配置文件为例,修改线程池大小的代码为:

properties 复制代码
server.tomcat.threads.max=1

yaml配置文件写法:

yaml 复制代码
server:
  tomcat:
    threads:
      max: 1

Spring Boot版本嵌入式Tomcat版本对应关系:

Spring Boot 版本 默认 Tomcat 版本
1.5.x 8.5.x
2.0.x 8.5.x
2.1.x 9.0.x
2.2.x 9.0.x
2.3.x 9.0.x
2.4.x 9.0.x
2.5.x 9.0.x
2.6.x 9.0.x
2.7.x 9.0.x
3.0.x 10.1.x
3.1.x 10.1.x
3.2.x 10.1.x

Spring Boot 2.0.x版本配置:

properties 复制代码
server.tomcat.max-threads=1

错误实践业务场景

ThreadLocal 在多线程环境下可能导致的线程复用问题,即ThreadLocal没有被清理的情况下,上一个请求的 ThreadLocal 值可能会影响下一个请求。

以下代码示例使用ThreadLocal存储用户信息,使用ThreadLocal.withInitial()进行初始化,before表示请求前的值,设置用户信息之前先查询一次ThreadLocal中的用户信息,after表示赋值到ThreadLocal,使用ThreadLocal保存的副本变量。

java 复制代码
// String类型表示用户信息
private static final ThreadLocal<String> requestIdThreadLocal = ThreadLocal.withInitial(() -> null);

@GetMapping("/threadlocal")
public Map<String, String> test(@RequestParam("requestId") String requestId) {
    // 查询ThreadLocal中是否已经有值
    String before = Thread.currentThread().getName() + " : " + requestIdThreadLocal.get();

    // 设置请求 ID
    requestIdThreadLocal.set(requestId);

    // 再次查询
    String after = Thread.currentThread().getName() + " : " + requestIdThreadLocal.get();

    // 模拟业务逻辑处理
    processRequest();

    // 这里如果不清理,可能会影响后续请求
    // requestIdThreadLocal.remove();  // ✅ 推荐清理

    // 返回数据
    Map<String, String> result = new HashMap<>();
    result.put("before", before);
    result.put("after", after);
    return result;
}

private void processRequest() {
    // 模拟一些业务处理
    try {
        TimeUnit.MILLISECONDS.sleep(100);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}

假设id为2的用户登录系统开始第一次请求接口,ThreadLocal中没有用户2的信息,所以before是null,模拟业务处理从数据库表中加载出用户信息,再次从ThreadLocal中获取用户信息,会正常获取到用户信息:

此时再来用户1请求该接口:

用户1请求到的初始值是2,在从数据库中获取到的用户信息是1,这个在业务上是错误的❎,即一开始的初始值获取到了其他线程的用户信息,造成该问题的原因是:Spring Boot的线程池中的线程是可以复用的,假设线程id为1的线程在代码执行完毕后,对应的ThreadLocal副本变量值没有被清空,当线程池中的线程被复用时,该线程就会获取到历史请求获取到的副本变量。

解决方案就是在每次使用完成后,将副本变量进行清空。

java 复制代码
requestIdThreadLocal.remove();

将requestIdThreadLocal.remove();的注释放开即可,重启服务,再次请求:

ThreadLocal最佳实践

针对Threadlocal的避坑使用方式,以下是在存储用户信息业务场景中的最佳实践,代码可参考:

java 复制代码
// 1. 定义静态的 ThreadLocal 变量(推荐使用 static final)且 重写 initialValue 提供默认值(可选)
private static final ThreadLocal<Integer> currentUser = ThreadLocal.withInitial(() -> 1);

@GetMapping("/best/way")
    public void bestWay() {
        // 提交多个任务
        for (int i = 0; i < 5; i++) {
            final int index = i;
            executor.execute(() -> {
                // 每个任务使用不同的用户上下文
                String before = Thread.currentThread().getName() + " : " + currentUser.get();

                // 设置请求 ID
                currentUser.set(index);

                // 再次查询
                String after = Thread.currentThread().getName() + " : " + currentUser.get();

                // 模拟业务逻辑处理
                processRequest();

                // ✅ 推荐清理,这里如果不清理,可能会影响后续请求
                currentUser.remove();

                log.info("before : " + before + " after : " + after);
            });
        }
        executor.shutdown();
    }

DEMO中使用两个线程处理5个请求,模拟并发场景,覆盖上文中ThreadLocal在线程复用场景下获取到其他用户信息的问题,以下是执行结果:

before值为1,是因为在初始化ThreadLocal时将数据定义为了1。

如不使用remove()方法清理ThreadLocal中的副本值,则会出现上文中获取到其他用户信息问题:

最佳实践说明

  1. 静态化ThreadLocal变量

    • 使用 static final 定义 ThreadLocal,避免多次创建实例,减少内存占用。
  2. 避免线程池污染

    • 在线程池场景中,线程会被重用,必须显式清理 ThreadLocal,防止旧数据残留,并且在 try-finally 块中调用 remove(),确保即使发生异常也能清理资源。
  3. 初始化默认值

    • 通过重写 initialValue() 提供默认值,避免 get() 时返回 null。
相关推荐
武子康7 小时前
MultiNet7 每轮都 clean,ESP32-P4 内存为什么还在掉?
人工智能·后端·agent
zhangfeng11337 小时前
6篇RAG核心论文的工程启示:Vector + Graph + Agent路由混合架构实战
人工智能·架构·llm·agent
缓慢更新7 小时前
AI 辅助生成单测:从接口定义到用例的流水线
java·服务器·前端·网络·数据库·人工智能
超级架构师7 小时前
PRAXO-CORE 2.5.0:用 conformance 和显式边界构建 Go Agent 框架
开发语言·人工智能·后端·golang·ai编程·智能体框架·praxo-core
陈随易7 小时前
AI 运维服务器,安全又可靠的方法
前端·后端·程序员
Decay__8 小时前
09_MySQL 闪回与时间点恢复 —— binlog 回放、mysqlbinlog、PITR 与误删恢复
运维·数据库·sql·mysql·架构
LMT流氓兔8 小时前
第五单元——第十六课:给书店助手加上网页界面
java·开发语言·jvm·人工智能·oracle·langchain·1024程序员节
代码方舟8 小时前
零信任架构实战:基于天远企业法人四要素高级版构建自动化电子印章授权与签约准入网关
人工智能·架构·自动化·区块链
IT_陈寒9 小时前
JavaScript的this坑人实录,这次我真的记下来了
前端·人工智能·后端
wangbing112510 小时前
平台介绍-modbus设备调试
java