跳转到内容
123xiao | 无名键客

《Java开发踩坑实录:ThreadLocal 在线程池中的内存泄漏与上下文串号排查及修复实践》

字数: 0 阅读时长: 1 分钟

背景与问题

ThreadLocal 是 Java 里一个“很好用,也很容易出事”的工具。

很多项目里都会这么干:

  • ThreadLocal 保存当前登录用户
  • 保存 traceId,串联日志
  • 保存数据库路由上下文
  • 保存租户 ID、灰度标识、语言环境等

单线程、短生命周期线程里,它确实省事。但一旦进入线程池场景,如果使用姿势不对,就会踩两个非常典型的坑:

  1. 内存泄漏
  2. 上下文串号

我第一次遇到这个问题,是线上日志里突然出现了非常诡异的现象:A 用户发起的请求,后续日志里偶尔带着 B 用户的 traceId。更离谱的是,堆内存还在缓慢上涨,Full GC 次数逐渐增多。

最后一路查下来,根因并不复杂:
请求处理结束后,没有清理 ThreadLocal,而线程池线程又会被复用。

这篇文章我不打算只讲“记得 remove()”这种口号,而是从:

  • 为什么会泄漏
  • 为什么会串号
  • 怎么复现
  • 怎么定位
  • 怎么止血
  • 怎么彻底修

带你完整走一遍。


背景场景图

flowchart LR
    A[请求1 进入线程池线程 T1] --> B[ThreadLocal.set 用户A/traceA]
    B --> C[业务执行]
    C --> D[未调用 remove]
    D --> E[线程 T1 归还线程池]

    F[请求2 再次分配到线程 T1] --> G[读取 ThreadLocal]
    E --> G
    G --> H[读到旧值 用户A/traceA]
    H --> I[出现上下文串号]

现象复现

先说最常见的两个现场症状。

1. 上下文串号

比如你把当前用户放到了 ThreadLocal

  • 请求 A 在线程池线程 pool-1-thread-1 上执行,写入用户 alice
  • 结束后没清理
  • 请求 B 恰好复用了同一条线程
  • 代码里又直接 get() 当前用户
  • 结果读到了 alice

这就是经典串号。

常见表现:

  • 日志 traceId 错乱
  • 用户 ID 偶发串到别人请求里
  • 多租户场景下,租户隔离失效
  • 灰度标识、权限上下文错乱

2. 内存泄漏

有些人会说:“串号我理解,但为什么会内存泄漏?”

原因是:

  • 线程池线程是长生命周期
  • ThreadLocalMap 挂在线程对象上
  • 只要线程不死,value 就可能一直留着
  • 如果 value 还很大,或者引用了一串对象,那内存就被长时间占住

于是堆内存会表现出:

  • Old 区缓慢增长
  • Full GC 后仍回收不明显
  • dump 后能看到很多业务上下文对象被线程引用

核心原理

先把底层关系讲透,不然后面的排查会像黑盒。

ThreadLocal 和 Thread 的关系

很多人以为值存在 ThreadLocal 对象里,其实不是。
真实情况更接近:

  • ThreadLocal 只是“访问 key”
  • 真正的数据存在 Thread 对象的 ThreadLocalMap

可以理解为:

Thread
 └── ThreadLocalMap
      ├── Entry(key=ThreadLocal实例, value=你的上下文对象)
      ├── Entry(...)
      └── Entry(...)

为什么会泄漏

ThreadLocalMap.Entry 的 key 是弱引用,value 是强引用

这意味着:

  • 如果外部没有强引用指向某个 ThreadLocal 实例了
  • GC 可以把这个 key 回收掉
  • 但 value 不会自动立刻消失
  • 只要线程还活着,这个 value 可能继续挂在 map 里,直到后续触发清理逻辑

这就是很多文章提到的“key 没了,value 还在”的情况。

为什么在线程池里更容易出事

因为线程池线程不是“用完即销毁”,而是会一直复用。

如果是每次请求都新建线程,即使你没 remove(),线程结束时整条线程对象也会被回收,问题还不至于长期积累。
但在线程池里:

  • 线程常驻
  • ThreadLocalMap 常驻
  • 脏数据也常驻

于是泄漏和串号都具备了长期存在的条件。


ThreadLocal 在线程池中的生命周期

sequenceDiagram
    participant Req1 as 请求1
    participant Pool as 线程池
    participant T1 as 工作线程T1
    participant TL as ThreadLocalMap

    Req1->>Pool: 提交任务
    Pool->>T1: 分配线程
    T1->>TL: set(traceId=A)
    T1->>T1: 执行业务
    T1-->>Pool: 任务结束(未remove)

    participant Req2 as 请求2
    Req2->>Pool: 提交任务
    Pool->>T1: 复用同一线程
    T1->>TL: get()
    TL-->>T1: 返回旧值 A
    T1-->>Req2: 发生串号

核心原理:两类问题不要混为一谈

这两个问题经常一起出现,但本质不同。

串号的根因

同一线程被复用,而旧上下文未清理。

即使你的 ThreadLocal 是静态常量、永远不会被 GC,照样会串号。因为旧值还在。

泄漏的根因

长生命周期线程持有不再需要的 value。

尤其是这几种情况更危险:

  • value 很大,比如缓存片段、大对象集合
  • value 持有数据库连接、文件句柄等资源
  • ThreadLocal 频繁创建,而不是定义成稳定的静态成员
  • 使用线程池,线程长期不销毁

所以排查时要分开看:

  • 串号:看有没有清理、有没有跨线程传播失控
  • 泄漏:看 value 是否长时间被线程引用、是否存在 stale entry

实战代码(可运行)

下面用一段可运行示例,先复现串号,再演示正确修复。

示例一:错误写法,复现上下文串号

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class ThreadLocalLeakDemo {

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

    public static void main(String[] args) throws Exception {
        ExecutorService executor = Executors.newFixedThreadPool(1);

        // 第一个任务:写入用户,但不清理
        executor.submit(() -> {
            USER_CONTEXT.set("alice");
            System.out.println(Thread.currentThread().getName() + " set user = " + USER_CONTEXT.get());
            // 模拟业务执行结束,但忘记 remove()
        }).get();

        // 第二个任务:没有设置用户,直接读取
        executor.submit(() -> {
            String user = USER_CONTEXT.get();
            System.out.println(Thread.currentThread().getName() + " get user = " + user);
            if (user != null) {
                System.out.println("发生串号:读到了上一个任务遗留的上下文");
            }
        }).get();

        executor.shutdown();
        executor.awaitTermination(3, TimeUnit.SECONDS);
    }
}

运行结果示意

pool-1-thread-1 set user = alice
pool-1-thread-1 get user = alice
发生串号:读到了上一个任务遗留的上下文

这段代码虽然简单,但已经把问题本质暴露得很清楚了:
线程池只有一个线程,所以第二个任务复用了第一个任务的线程。


示例二:正确写法,用 try-finally 清理

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class ThreadLocalSafeDemo {

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

    public static void main(String[] args) throws Exception {
        ExecutorService executor = Executors.newFixedThreadPool(1);

        executor.submit(() -> {
            try {
                USER_CONTEXT.set("alice");
                System.out.println(Thread.currentThread().getName() + " set user = " + USER_CONTEXT.get());
            } finally {
                USER_CONTEXT.remove();
            }
        }).get();

        executor.submit(() -> {
            String user = USER_CONTEXT.get();
            System.out.println(Thread.currentThread().getName() + " get user = " + user);
            if (user == null) {
                System.out.println("正常:上下文已清理,没有串号");
            }
        }).get();

        executor.shutdown();
        executor.awaitTermination(3, TimeUnit.SECONDS);
    }
}

示例三:模拟“大对象残留”导致的内存风险

下面这个例子不保证在所有机器上都直观 OOM,但足以说明风险模式。

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadLocalMemoryRiskDemo {

    private static final ThreadLocal<List<byte[]>> LOCAL = new ThreadLocal<>();

    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(4);

        for (int i = 0; i < 1000; i++) {
            executor.submit(() -> {
                // 每个任务都放一些大对象进去
                List<byte[]> data = new ArrayList<>();
                for (int j = 0; j < 10; j++) {
                    data.add(new byte[1024 * 1024]); // 1MB
                }
                LOCAL.set(data);

                // 业务结束后若不 remove,线程会一直持有这些对象
                // LOCAL.remove();
            });
        }

        executor.shutdown();
    }
}

如果你把 remove() 注释掉,线程池里的工作线程会长期持有最后一次任务写入的大对象。
虽然不是“每个任务都无限叠加”,但对于常驻线程来说,这种残留就足够危险了。


定位路径:我是怎么一步步查出来的

排查这类问题,我建议按“现象 -> 线程 -> 上下文 -> 引用链”的顺序走,别一上来就怀疑 JVM。

第一步:先确认是不是线程复用导致的脏上下文

最实用的办法是加日志,打出:

  • 线程名
  • traceId / userId / tenantId
  • 请求唯一标识

例如:

System.out.printf("thread=%s, requestId=%s, userId=%s%n",
        Thread.currentThread().getName(),
        requestId,
        UserContextHolder.get());

如果你发现:

  • 不同请求 ID
  • 但线程名相同
  • 且上下文值意外相同

基本就已经很像 ThreadLocal 未清理了。

第二步:全局搜 ThreadLocal 的 set/remove 是否成对出现

这个动作非常土,但极其有效。

直接全局搜索:

  • new ThreadLocal
  • .set(
  • .remove(

重点看这些位置:

  • Filter
  • Interceptor
  • AOP 切面
  • 自定义线程池任务包装器
  • 异步回调
  • MQ 消费逻辑
  • 定时任务

常见坏味道:

contextHolder.set(xxx);
// 中间各种 return / throw
// 没有 finally remove

或者:

if (needSet) {
    contextHolder.set(xxx);
}
// 某些分支没清理

第三步:看堆转储,找线程到 value 的引用链

如果怀疑内存泄漏,就上 dump。

可以用:

jmap -dump:live,format=b,file=heap.hprof <pid>

然后用 MAT 或者其他分析工具看:

  • 可疑大对象是谁
  • 是谁在持有它
  • 引用链是否经过 java.lang.Thread
  • 是否能看到 threadLocals / ThreadLocalMap

典型引用链大概会长这样:

Thread
 -> threadLocals
 -> ThreadLocalMap
 -> Entry
 -> value
 -> YourContextObject

如果你看到 value 是:

  • 用户上下文对象
  • trace 上下文
  • 大集合
  • byte[]
  • 数据源路由对象

那基本就坐实了。


排查思路图

flowchart TD
    A[发现现象: 串号/内存上涨] --> B{是否涉及线程池/异步}
    B -- 是 --> C[检查 ThreadLocal 使用点]
    B -- 否 --> D[先排除其他引用链]

    C --> E[确认 set/remove 是否成对]
    E --> F{有无 finally remove}
    F -- 否 --> G[高概率根因]
    F -- 是 --> H[检查异步传播/嵌套调用]

    G --> I[复现问题并加线程名/请求ID日志]
    H --> I
    I --> J{是否仍异常}
    J -- 是 --> K[导出 heap dump / 线程栈]
    K --> L[查看 Thread -> ThreadLocalMap -> value 引用链]
    J -- 否 --> M[验证修复并回归]

常见坑与排查

下面这些坑,我几乎都见过。

1. 只 set,不 remove

这是头号元凶。

错误示例:

public void handle(Request req) {
    USER_CONTEXT.set(req.getUserId());
    doBusiness(req);
}

正确姿势:

public void handle(Request req) {
    try {
        USER_CONTEXT.set(req.getUserId());
        doBusiness(req);
    } finally {
        USER_CONTEXT.remove();
    }
}

2. remove 写了,但没放 finally

很多代码“看起来”有清理,实际上异常一来就失效。

错误示例:

USER_CONTEXT.set(userId);
doBusiness();
USER_CONTEXT.remove();

如果 doBusiness() 抛异常,remove() 根本执行不到。

3. 在线程池父线程 set,子任务里以为能自动拿到

普通 ThreadLocal 不能跨线程传递。

ThreadLocal<String> local = new ThreadLocal<>();
local.set("trace-123");

executor.submit(() -> {
    System.out.println(local.get()); // 大概率是 null
});

有些人改成 InheritableThreadLocal,以为就解决了。
但在线程池里,这通常也不靠谱,因为线程不是新建的,而是复用的,继承发生在线程创建时,不是在任务提交时。

4. 用 InheritableThreadLocal 在线程池里传上下文

这是另一个经典坑。

InheritableThreadLocal 的语义是“子线程创建时继承父线程值”,但线程池线程往往早就创建好了。结果就是:

  • 有时拿不到预期值
  • 有时拿到旧值
  • 行为不稳定,很难排查

如果确实需要跨线程传播上下文,应该显式包装任务,或者使用成熟方案,而不是指望 InheritableThreadLocal 在线程池里“自动生效”。

5. 把大对象塞进 ThreadLocal

ThreadLocal 适合放轻量、短生命周期、明确边界的数据。
不适合放:

  • 大集合
  • DTO 树
  • 文件内容
  • 缓存块
  • 数据库连接等重资源

否则即使没有严格意义上的无限泄漏,也会造成线程常驻大对象,占用堆空间。

6. 静态 ThreadLocal 没问题,不代表业务值没问题

很多人会误以为:

“我的 ThreadLocalstatic final,key 不会被回收,所以没有泄漏。”

这话只说对了一半。

  • 是的,静态 ThreadLocal 可以减少 stale key 问题
  • value 残留依然存在
  • 线程池复用导致的串号也依然存在

所以 static final 不是免死金牌。

7. 框架层帮你 set 了,但业务层没意识到边界

例如:

  • 日志 MDC
  • 数据源切换
  • 多租户上下文
  • 安全上下文

这些框架很多底层就是 ThreadLocal。如果你在异步线程、线程池、自定义执行器里切换使用姿势,问题就会冒出来。

所以排查时别只盯你自己写的 ThreadLocal,也要看框架是否在用。


修复实践

说修复,不只是补一个 remove()。我更建议分成三层来做。

第一层:止血方案

最短路径是:

  • 找到所有请求入口
  • 在边界处统一清理上下文
  • 给关键线程池任务加包装器

Web 请求入口统一清理

如果是 Web 项目,可以在 Filter / Interceptor 最外层做:

public class UserContextHolder {
    private static final ThreadLocal<String> USER = new ThreadLocal<>();

    public static void set(String userId) {
        USER.set(userId);
    }

    public static String get() {
        return USER.get();
    }

    public static void clear() {
        USER.remove();
    }
}
public class DemoFilter {
    public void doFilter(String userId, Runnable chain) {
        try {
            UserContextHolder.set(userId);
            chain.run();
        } finally {
            UserContextHolder.clear();
        }
    }
}

核心点只有一个:
在“请求边界”统一 set,在“请求结束”统一 clear。

第二层:线程池任务统一包装

如果项目里有大量异步任务,靠每个开发手写 try-finally 很容易漏。

可以包装 Runnable / Callable

public class ContextAwareRunnable implements Runnable {

    private final Runnable delegate;
    private final String capturedUser;

    public ContextAwareRunnable(Runnable delegate) {
        this.delegate = delegate;
        this.capturedUser = UserContextHolder.get();
    }

    @Override
    public void run() {
        try {
            if (capturedUser != null) {
                UserContextHolder.set(capturedUser);
            }
            delegate.run();
        } finally {
            UserContextHolder.clear();
        }
    }
}

使用方式:

executor.submit(new ContextAwareRunnable(() -> {
    System.out.println("async user = " + UserContextHolder.get());
}));

这样能解决两个问题:

  • 需要传播的上下文显式传入
  • 任务结束后统一清理

第三层:建立规范,避免“自由发挥”

团队里最好明确以下规范:

  1. ThreadLocal 只能封装在 Holder 类中,禁止散落业务代码
  2. 任何 set() 必须对应 try-finally remove()
  3. 不允许往 ThreadLocal 放大对象和资源对象
  4. 线程池异步任务必须使用统一包装器
  5. 代码评审时,把 ThreadLocal 当高风险点检查

安全/性能最佳实践

这部分很重要,因为 ThreadLocal 的坑,不只是“功能错”,还可能带来安全和性能问题。

1. 上下文里不要放敏感数据原文

比如:

  • 明文 token
  • 身份证号
  • 银行卡号
  • 完整权限列表

原因很简单:

  • 串号时可能泄露给其他请求
  • dump 分析时会落到文件里
  • 日志打印时容易误输出

建议:

  • 只放必要标识,如 userId、traceId
  • 敏感信息做脱敏或避免进入上下文

2. 值尽量轻量化

好的 ThreadLocal 值应该是:

  • 小对象
  • 不持有庞大引用图
  • 生命周期清晰

例如:

  • String traceId
  • Long userId
  • 小型上下文 DTO

而不是塞一个“万能上下文对象”,里面挂一堆请求参数、缓存和服务实例。

3. 尽量缩短上下文作用域

不要一进请求就 set,一直到所有流程结束才清。
能缩小作用域的地方就缩小。

例如数据库路由上下文,只在切库操作前后包裹:

try {
    DbContextHolder.set("slave");
    query();
} finally {
    DbContextHolder.clear();
}

而不是整条请求都挂着它。

4. 对线程池做统一治理

如果你们项目线程池多、异步链路复杂,建议:

  • 统一封装线程池创建
  • 统一任务装饰器
  • 统一异常日志和上下文清理
  • 禁止业务自行 Executors.newFixedThreadPool(...)

因为 ThreadLocal 问题本质上不是单点 bug,而是线程复用治理问题

5. 压测时观察“上下文残留”

除了看吞吐和 RT,还应该加一些针对性验证:

  • 同一线程连续处理不同用户请求时是否串号
  • 压测后堆内存是否持续抬升
  • dump 中是否存在明显的 ThreadLocal value 残留

这类问题在低并发测试里常常不明显,但在线上高复用、高异常率场景下很容易暴露。


一张原理图看懂“为什么 remove 必须有”

classDiagram
    class Thread {
        +ThreadLocalMap threadLocals
    }

    class ThreadLocalMap {
        +Entry[] table
    }

    class Entry {
        +WeakReference~ThreadLocal~ key
        +Object value
    }

    class BusinessContext {
        +String traceId
        +Long userId
        +String tenantId
    }

    Thread --> ThreadLocalMap
    ThreadLocalMap --> Entry
    Entry --> BusinessContext

关键理解:

  • 线程池线程活得久
  • ThreadLocalMap 跟着线程活得久
  • remove(),value 就可能长期挂在线程上

一个更稳妥的落地模板

如果你在项目里想快速落地,我建议直接用这种模板。

Context Holder

public final class RequestContextHolder {

    private static final ThreadLocal<RequestContext> CONTEXT = new ThreadLocal<>();

    private RequestContextHolder() {
    }

    public static void set(RequestContext context) {
        CONTEXT.set(context);
    }

    public static RequestContext get() {
        return CONTEXT.get();
    }

    public static void clear() {
        CONTEXT.remove();
    }
}

Context 对象

public class RequestContext {
    private final String traceId;
    private final Long userId;

    public RequestContext(String traceId, Long userId) {
        this.traceId = traceId;
        this.userId = userId;
    }

    public String getTraceId() {
        return traceId;
    }

    public Long getUserId() {
        return userId;
    }
}

边界统一控制

public class RequestHandler {

    public void handle(String traceId, Long userId, Runnable bizLogic) {
        try {
            RequestContextHolder.set(new RequestContext(traceId, userId));
            bizLogic.run();
        } finally {
            RequestContextHolder.clear();
        }
    }
}

这个模板的好处是:

  • 使用入口统一
  • 清理动作固定
  • 便于以后替换实现
  • 便于代码审计

什么时候不该用 ThreadLocal

这点我想单独说一句,因为很多坑并不是“用错了”,而是“本来就不该用”。

如果你的数据:

  • 需要跨线程自然传递
  • 需要异步链路稳定传播
  • 需要可观测、可测试
  • 生命周期复杂

那优先考虑:

  • 显式参数传递
  • 上下文对象作为方法参数
  • 框架级上下文传播机制
  • 任务装饰器而不是隐式 ThreadLocal 读取

ThreadLocal 适合解决“当前线程局部状态”问题,
不适合被当成“全局隐式上下文总线”。


总结

把这篇文章压缩成几句实战结论,就是:

  1. ThreadLocal 在线程池里最大的两个坑:上下文串号和内存残留。
  2. 串号的根因是线程复用后旧值未清理。
  3. 泄漏的根因是长生命周期线程长期持有无用 value。
  4. 真正有效的修复不是“记得 remove”,而是边界统一治理。
  5. 任何 set() 都要放进 try-finally,并在 finally 里 remove()
  6. 线程池异步场景不要迷信 InheritableThreadLocal
  7. 不要把大对象、资源对象、敏感信息放进 ThreadLocal。

如果你现在怀疑项目里已经踩坑,我建议按这个顺序行动:

  • 先全局搜 ThreadLocal
  • 再检查 set/remove 是否成对
  • 给请求入口和线程池任务补统一清理
  • 对关键链路加线程名 + 请求 ID + 上下文日志
  • 必要时抓 heap dump 看 Thread -> ThreadLocalMap -> value 引用链

最后一句很实在的话:
ThreadLocal 不是不能用,但它属于那种“写起来很爽,收尾一定要认真”的工具。在线程池里,谁忘了清理,谁就迟早要为它买单。


分享到:

上一篇
《从原型到生产:基于 RAG 的企业知识库问答系统设计与性能优化实践》
下一篇
《从零搭建企业级 RAG 问答系统:基于向量数据库、重排模型与评测闭环的实战指南》