ThreadLocal 讲清楚:它是什么、为什么会“内存泄漏”、线程池复用为什么会串号


ThreadLocal 是 Java 后端里很常见的基础能力:traceId、用户上下文、事务资源、MDC......很多框架都在用它。但 ThreadLocal 也很容易"翻车",最典型就是两类问题:

  1. 看起来像内存泄漏(其实是"线程级常驻")
  2. 线程池复用导致数据串号

这篇文章不整复杂工程,只用最小示例让你看懂本质。


1. ThreadLocal 是什么?

一句话:

ThreadLocal = 每个线程一份的隐式变量

同一个线程里 set 的值,后面随处 get;不同线程互相隔离。

关键点:数据不存在线程外,而是存在 Thread 自己的一个 Map 里

你可以想象成"每个线程都有个口袋":

  • set(x):把 x 放到当前线程口袋
  • get():从当前线程口袋拿出来

2. 为什么不用普通变量?

  • 局部变量 + 显式传参最安全(推荐),但工程里上下文常常跨很多层(日志、审计、鉴权、DAO),方法签名会爆炸。
  • 成员变量 / static 变量并发下会串号(尤其 Controller 通常是单例,多个请求并发访问)。

ThreadLocal 的意义是:在不污染大量方法签名的情况下,提供线程级隔离的上下文


3. 线程池复用为什么会导致"串号"?

核心原因只有一个:

线程池会复用同一个 Thread 执行多个任务。

如果你 set 了 ThreadLocal 却不 remove,下一个任务复用同一线程时就可能读到上一次残留值。

下面这个纯 Java 示例,能让你肉眼看到"串号"。

✅ 最小可运行示例:复用 + 不清理 = 串号

java 复制代码
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadLocalBleedDemo {

    private static final ThreadLocal<String> TL = new ThreadLocal<>();

    public static void main(String[] args) throws Exception {
        // 单线程池:保证每次都复用同一个线程,现象最清晰
        ExecutorService pool = Executors.newFixedThreadPool(1);

        pool.submit(() -> doWork("Alice")).get();
        pool.submit(() -> doWork("Bob")).get();
        pool.submit(() -> doWork("Cindy")).get();

        pool.shutdown();
    }

    private static void doWork(String user) {
        String before = TL.get();   // 读到上一次残留(如果没清理)
        TL.set(user);
        String after = TL.get();

        System.out.printf("[%s] before=%s, after=%s%n",
                Thread.currentThread().getName(), before, after);

        // ❌ 故意不 remove:下一次任务复用同一线程就会看到 before=上次的 after
        // TL.remove(); // ✅ 打开它就不会串号
    }
}

典型输出:

ini 复制代码
[pool-1-thread-1] before=null,  after=Alice
[pool-1-thread-1] before=Alice, after=Bob
[pool-1-thread-1] before=Bob,   after=Cindy

看到没:这不是并发竞争,而是生命周期管理错误

正确姿势(必背)

set 之后必须 finally remove

csharp 复制代码
try {
    TL.set(user);
    // do work
} finally {
    TL.remove();
}

4. ThreadLocal 为什么会"内存泄漏"?(更准确:线程级常驻)

先说结论:

ThreadLocal "泄漏"的不是 ThreadLocal 本身,而是线程长期持有的 value 释放不掉。

在线程池里,线程活得很久,所以 value 会"常驻",表现得像内存泄漏。

4.1 发生了什么(抓住关键结构)

在 JDK 里,ThreadLocal 数据的结构大致是:

  • 每个 Thread 有一个 ThreadLocalMap

  • Map 的 Entry 里:

    • key 是 ThreadLocal 的弱引用(WeakReference)
    • value 是强引用(Object value)

也就是:

ini 复制代码
Thread
 └── ThreadLocalMap
      └── Entry(key=ThreadLocal弱引用, value=强引用的大对象)

4.2 为什么 key 弱引用了还会"泄漏"?

因为泄漏点在 value:

  • 某些情况下 ThreadLocal 这个 key 没人引用了,会被 GC 掉(弱引用变 null)
  • Entry.value 仍然被 ThreadLocalMap 强引用着
  • 线程(尤其线程池线程)不结束 → Map 不释放 → value 也释放不了

于是:你以为"请求结束对象就该回收",实际它挂在线程上一直在。

4.3 一个更直观的"常驻"例子

假设你把大对象塞进 ThreadLocal(比如 10MB 的 byte[] 或大 Map):

  • 线程池有 200 个线程
  • 每个线程都 set 过一次 10MB
  • 即使业务早就结束,这些 value 仍可能长期挂在线程上

结果就是:

  • 堆内存长期偏高
  • GC 压力上升
  • 极端情况下 OOM

4.4 如何避免(工程里就三条)

  1. 必须 finally remove(最重要)
  2. ThreadLocal 尽量 static final,不要到处 new ThreadLocal()
  3. ThreadLocal 里尽量别放大对象/重资源;确实要放就更要严格清理

5. 一句话总结

ThreadLocal 的问题不是并发,而是生命周期。

它绑定的是线程,不是请求。在线程池环境里,set 必须配 remove,否则串号和"线程级常驻"只是时间问题。


相关推荐
艺杯羹2 小时前
Thymeleaf模板引擎:让Spring Boot页面开发更简单高效
java·spring boot·后端·thymeleadf
逸风尊者2 小时前
开发可掌握的知识:推荐系统
java·后端·算法
Violet_YSWY2 小时前
阿里巴巴状态码
后端
灵魂猎手2 小时前
Antrl4 入门 —— 使用Antrl4实现一个表达式计算器
java·后端
moxiaoran57533 小时前
Go语言的递归函数
开发语言·后端·golang
IT 行者3 小时前
Spring Security 7.0 新特性详解
java·后端·spring
华仔啊3 小时前
Java 的金额计算用 long 还是 BigDecimal?资深程序员这样选
java·后端
12344523 小时前
【MCP入门篇】从0到1教你搭建MCP服务
后端·mcp