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

《Java 开发踩坑实录:排查 ThreadLocal 内存泄漏与线程池复用导致数据串脏的实战指南》

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

背景与问题

ThreadLocal 是 Java 里一个很常见、也很容易“用顺手了就出事”的工具。

很多项目里都会这么用:

  • 存当前登录用户
  • 存请求链路 TraceId
  • 存数据库路由 key
  • 存一些上下文变量,避免层层传参

刚开始看起来很优雅:代码干净、调用方便、封装也漂亮。
但线上一跑久,问题就来了:

  1. 内存占用持续上涨,GC 后也降不明显
  2. 线程池中的请求上下文串了,A 用户的数据出现在 B 请求里
  3. 日志 TraceId 偶发错乱,定位问题像抽盲盒

我自己第一次踩这个坑时,表面现象是“日志偶发串号”,一开始还以为是日志框架异步输出的问题。查到最后才发现,根因是:

  • ThreadLocal 用完没清理
  • 线程池线程被复用
  • 新任务复用旧线程时,读到了上一个任务残留的数据

这篇文章就从问题现象、底层原理、可运行复现代码、排查路径、止血方案几个角度,把这个坑完整走一遍。


背景与问题:这类故障通常怎么出现

先看两个最典型的线上症状。

症状一:数据串脏

比如你在 Web 请求入口把用户 ID 放进 ThreadLocal

UserContext.setUserId("userA");

后续业务代码都通过:

String uid = UserContext.getUserId();

来拿当前用户。

如果线程池里的线程执行完 userA 的请求后没有清理 ThreadLocal,下一次这个线程被拿去执行 userB 的请求,而 userB 的入口代码又因为异常分支、过滤器遗漏、异步切换等原因没有正确 set 或 remove,那它就可能直接读到 userA 的残留值。

这就是典型的数据串脏

症状二:内存泄漏

另一个更隐蔽的问题是:ThreadLocal 不清理时,值对象可能一直挂在线程内部的 ThreadLocalMap 里。

如果线程是线程池核心线程,它们生命周期很长,那么这些 value 也就可能长期存活。
尤其当 value 里放的是:

  • 大对象
  • 缓存
  • DB 连接包装对象
  • 用户会话上下文
  • 大量 Map/List

内存就会被一点点吃掉。

这里要特别注意:很多人以为“ThreadLocal 用的是弱引用,所以不会泄漏”。这句话只说对了一半,甚至容易误导,后面我们会详细拆开。


核心原理

先记住一句话

ThreadLocal 真正存数据的地方,不在 ThreadLocal 对象本身,而在 Thread 对象内部的 ThreadLocalMap

也就是说,结构更像这样:

flowchart LR
    A[业务代码中的 ThreadLocal] --> B[当前线程 Thread]
    B --> C[ThreadLocalMap]
    C --> D[key: ThreadLocal 弱引用]
    C --> E[value: 实际业务对象 强引用]

关键点有两个:

  • key 是对 ThreadLocal弱引用
  • value 是对业务对象的强引用

为什么会泄漏

当外部不再持有 ThreadLocal 引用时,GC 可能把这个 ThreadLocal 回收掉,于是:

  • key == null
  • value 还在

只要这个线程还活着,尤其在线程池中长期存活,value 就可能一直挂着,变成“key 丢了,但 value 还在”的脏数据。

flowchart TD
    A[创建 ThreadLocal 并 set 大对象] --> B[数据进入 ThreadLocalMap]
    B --> C[外部 ThreadLocal 引用丢失]
    C --> D[GC 回收 ThreadLocal key]
    D --> E[key 变成 null]
    E --> F[value 仍被 ThreadLocalMap 强引用]
    F --> G[线程池线程长期存活]
    G --> H[内存无法及时释放]

为什么线程池更容易出问题

因为普通线程执行完就死了,线程对象一销毁,ThreadLocalMap 也跟着没了。
但线程池不是这样:它会复用线程

这带来两个副作用:

  1. 旧请求残留数据会被后续请求读到
  2. 线程一直活着,ThreadLocalMap 里的 value 也活得很久

下面这张时序图可以直观看到“串脏”的过程:

sequenceDiagram
    participant ReqA as 请求A
    participant Pool as 线程池线程T1
    participant TL as ThreadLocal
    participant ReqB as 请求B

    ReqA->>Pool: 执行任务
    Pool->>TL: set("userA")
    ReqA-->>Pool: 业务完成
    Note over Pool,TL: 未调用 remove()

    ReqB->>Pool: 线程T1再次被复用
    Pool->>TL: get()
    TL-->>Pool: 返回残留的 "userA"
    Note over ReqB,Pool: 请求B读到了请求A的数据

现象复现

下面我们直接用一段可以运行的代码复现线程池复用导致的数据串脏

复现代码 1:数据串脏

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

public class ThreadLocalDirtyDataDemo {

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

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

        // 第一个任务:设置用户,但不清理
        pool.submit(() -> {
            USER_HOLDER.set("user-A");
            System.out.println(Thread.currentThread().getName() + " set user = " + USER_HOLDER.get());
            // 故意不 remove
        }).get();

        // 第二个任务:不设置用户,直接读取
        pool.submit(() -> {
            String user = USER_HOLDER.get();
            System.out.println(Thread.currentThread().getName() + " read user = " + user);
        }).get();

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

预期输出

pool-1-thread-1 set user = user-A
pool-1-thread-1 read user = user-A

第二个任务本来不该有用户信息,但因为线程被复用,直接读到了上一个任务遗留的值。


实战代码(可运行)

接着我们给出一套更接近真实项目的写法:先展示错误示例,再展示正确示例。

错误示例:异常分支遗漏清理

这是很多线上问题的真实来源:
主流程里写了 set,但一旦中途异常,就忘了清理。

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

public class BadThreadLocalUsageDemo {

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

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

        pool.submit(() -> handleRequest("trace-1001", true)).get();
        pool.submit(() -> handleRequest("trace-1002", false)).get();

        pool.shutdown();
    }

    private static void handleRequest(String traceId, boolean fail) {
        TRACE_ID_HOLDER.set(traceId);
        System.out.println("start request, traceId = " + TRACE_ID_HOLDER.get());

        if (fail) {
            throw new RuntimeException("mock exception");
        }

        System.out.println("finish request, traceId = " + TRACE_ID_HOLDER.get());

        // 这里如果没执行到,就不会清理
        TRACE_ID_HOLDER.remove();
    }
}

这段代码的问题非常典型:
remove() 没写在 finally 里,一旦抛异常,清理逻辑就丢了。

正确示例:必须用 try-finally

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

public class GoodThreadLocalUsageDemo {

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

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

        try {
            pool.submit(() -> handleRequest("trace-2001", true)).get();
        } catch (Exception e) {
            System.out.println("first request failed: " + e.getMessage());
        }

        pool.submit(() -> handleRequest("trace-2002", false)).get();

        pool.shutdown();
    }

    private static void handleRequest(String traceId, boolean fail) {
        try {
            TRACE_ID_HOLDER.set(traceId);
            System.out.println("start request, traceId = " + TRACE_ID_HOLDER.get());

            if (fail) {
                throw new RuntimeException("mock exception");
            }

            System.out.println("finish request, traceId = " + TRACE_ID_HOLDER.get());
        } finally {
            TRACE_ID_HOLDER.remove();
            System.out.println("clean thread local");
        }
    }
}

这个版本里,即使业务异常,ThreadLocal 也会被清理掉。


再进一步:线程池统一兜底清理

如果你的系统里 ThreadLocal 用得比较多,只靠“开发同学自觉写 finally”通常不够稳。
更实际的做法是:在线程池执行边界统一兜底

方案一:包装任务

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

public class ThreadLocalTaskWrapperDemo {

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

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

        pool.submit(wrap(() -> {
            CONTEXT.set("job-1");
            System.out.println("task1 context = " + CONTEXT.get());
        })).get();

        pool.submit(wrap(() -> {
            System.out.println("task2 context = " + CONTEXT.get());
        })).get();

        pool.shutdown();
    }

    private static Runnable wrap(Runnable task) {
        return () -> {
            try {
                task.run();
            } finally {
                CONTEXT.remove();
            }
        };
    }
}

这种方式适合你自己封装统一提交入口。

方案二:自定义 ThreadPoolExecutor

import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class CleanThreadPoolDemo {

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

    public static void main(String[] args) throws Exception {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                1, 1,
                60, TimeUnit.SECONDS,
                new LinkedBlockingQueue<>()) {

            @Override
            protected void afterExecute(Runnable r, Throwable t) {
                try {
                    CONTEXT.remove();
                } finally {
                    super.afterExecute(r, t);
                }
            }
        };

        executor.submit(() -> {
            CONTEXT.set("request-A");
            System.out.println("task1 = " + CONTEXT.get());
        }).get();

        executor.submit(() -> {
            System.out.println("task2 = " + CONTEXT.get());
        }).get();

        executor.shutdown();
    }
}

注意:这种方式只能清理当前你知道的那些 ThreadLocal
如果系统里有第三方库也在偷偷用 ThreadLocal,它们的上下文不一定能被你一起清掉。


定位路径:线上怎么查

遇到这类问题,我一般按下面的顺序查,不会一上来就 dump 堆,因为那样成本比较高。

1. 先看现象是否与“线程复用”强相关

典型特征:

  • 问题只在使用线程池的链路出现
  • 单线程/同步直调时不出现
  • 请求量高时更容易复现
  • 同一工作线程名下出现“上下文穿越”

比如日志里出现这种信息:

[pool-3-thread-7] request=/api/order traceId=aaa userId=u100
[pool-3-thread-7] request=/api/pay   traceId=aaa userId=u100

第二条请求如果本不该继承第一条上下文,那就很可疑了。

2. 搜索所有 ThreadLocal 定义点

全局搜索这些关键词:

  • new ThreadLocal
  • withInitial
  • InheritableThreadLocal
  • 日志 MDC 的 put/remove
  • 自定义上下文类,如 UserContext, TraceContext, TenantContext

然后重点看:

  • 有没有 remove()
  • remove() 是否写在 finally
  • 是否有异常/提前 return 分支
  • 是否存在嵌套调用覆盖上下文却不恢复的问题

3. 检查过滤器、拦截器、AOP 切面

很多项目把上下文写在这些位置:

  • Servlet Filter
  • Spring HandlerInterceptor
  • AOP around advice
  • RPC provider / consumer filter

这些地方最容易出现:

  • 入口 set 了
  • 出口 remove 漏了
  • 异常路径没走到清理逻辑
  • 异步线程根本没传递上下文

4. 必要时做堆分析

如果怀疑内存泄漏,可以导出堆:

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

再用 MAT 或类似工具看:

  • java.lang.Thread
  • threadLocals
  • ThreadLocalMap$Entry
  • 某些业务大对象是否被线程长期持有

一个常见线索是:

  • 某个线程池线程对象存活很久
  • 它的 ThreadLocalMap 下挂着很多 value
  • key 已经是 null

这通常就是经典 ThreadLocal 泄漏图谱。


常见坑与排查

坑一:以为 set(null) 等于 remove()

不是一回事。

threadLocal.set(null);

只是把 value 设成 null,Entry 还在。
而:

threadLocal.remove();

才是把当前线程中的这条映射清掉。

建议:清理永远用 remove()

坑二:静态 ThreadLocal 就一定没问题

很多人说:把 ThreadLocal 定义成 static final,key 就不会被 GC,所以不会泄漏。

这只能缓解“key 变 null”的那一类问题,但不能解决线程复用导致的串脏
也就是说:

  • static final ThreadLocal
    可以降低弱引用 key 丢失后的“伪泄漏”风险
  • 但如果你不 remove(),旧数据仍然会留在复用线程里

所以核心原则没变:用完必须清理

坑三:把 ThreadLocal 当全局变量用

ThreadLocal 适合存线程内短生命周期上下文,不适合:

  • 存大缓存
  • 存跨请求状态
  • 存需要显式共享的数据
  • 存连接池、会话池等复杂资源

如果你把它当“线程级全局变量”,出问题只是时间早晚。

坑四:误用 InheritableThreadLocal

InheritableThreadLocal 会把父线程值传给子线程。
听起来方便,但在线程池里常常更危险,因为线程池线程不是“现创建的子线程”,而是复用的已有线程。

结果通常是:

  • 你以为会传
  • 实际不稳定
  • 或者传了旧值,造成更隐蔽的串脏

在使用线程池的服务端应用里,这个类要非常谨慎。

坑五:MDC 也会踩同样的坑

日志框架里的 MDC,本质上很多实现也是基于 ThreadLocal
所以如果你只做了:

MDC.put("traceId", traceId);

但没有:

MDC.clear();

那日志串 TraceId 的表现,和本文说的问题本质一样。


安全/性能最佳实践

这一节给的是“能落地”的建议,不只是原则。

1. 固定套路:set 后必须 finally remove

这是第一优先级。

try {
    CONTEXT.set(value);
    // 业务逻辑
} finally {
    CONTEXT.remove();
}

别图省事,也别相信“这个方法不会异常”。

2. ThreadLocal 中只放轻量对象

推荐放:

  • 用户 ID
  • TraceId
  • 租户 ID
  • 小型配置标记

不推荐放:

  • 大 List/Map
  • DTO 聚合对象
  • 大缓存
  • 数据库连接/文件句柄/网络资源

原则很简单:
ThreadLocal 里放的东西,应该是小、短、可丢失、易重建。

3. 在线程池边界做统一治理

如果项目规模已经比较大,建议至少选一种:

  • 提交任务时统一包装
  • 自定义线程池做 afterExecute 清理
  • Web/RPC 框架层做统一过滤和清理
  • 对 MDC、用户上下文、租户上下文做集中管理

不要把希望寄托在“每个人都记得写 finally”。

4. 异步任务不要默认依赖主线程 ThreadLocal

比如在 Web 请求线程里设置了用户信息,然后异步:

executor.submit(() -> {
    // 这里不要默认认为能拿到主线程 ThreadLocal
});

默认情况下,线程池里的工作线程拿不到当前请求线程的 ThreadLocal
如果确实要传,建议显式传参,或者使用受控的上下文传播方案。

我个人建议是:

  • 能传参,就传参
  • 必须做上下文传播时,明确生命周期和清理责任

5. 监控线程池线程与上下文异常

可以加一些低成本监控:

  • 线程池活跃线程数、队列长度
  • 同一线程名下多请求上下文冲突日志
  • TraceId/UserId 缺失或异常重复的统计
  • Full GC 次数、老年代增长趋势

这些指标不能直接证明是 ThreadLocal 问题,但很适合做预警。


止血方案

如果你已经在线上发现问题,先别急着全面重构,可以按下面三步止血。

止血 1:在入口/出口加统一清理

对 Web 应用,优先在过滤器、拦截器里补齐:

  • 请求开始 set
  • 请求结束 finally remove
  • 异常也必须 remove

止血 2:对线程池提交入口做包装

如果串脏来自异步任务,先把所有提交入口统一收口:

public void executeSafe(Runnable task) {
    executor.execute(() -> {
        try {
            task.run();
        } finally {
            UserContext.clear();
            TraceContext.clear();
        }
    });
}

先保命,再优化。

止血 3:快速排查大对象是否误入 ThreadLocal

如果内存涨得很厉害,优先搜:

  • ThreadLocal<Map<...>>
  • ThreadLocal<List<...>>
  • ThreadLocal<SomeBigDTO>
  • 各种缓存型 holder

这类通常收益最大,改掉后效果也最明显。


一张总览图:从问题到修复

flowchart TD
    A[发现现象: 串脏/内存涨] --> B{是否使用线程池}
    B -- 是 --> C[检查 ThreadLocal/MDC/上下文类]
    B -- 否 --> D[检查普通线程生命周期与异常路径]
    C --> E{是否所有路径都 finally remove}
    E -- 否 --> F[立即补齐清理逻辑]
    E -- 是 --> G[检查异步上下文传递与三方库]
    G --> H[必要时导出 heap dump 分析 ThreadLocalMap]
    F --> I[统一封装线程池与入口过滤器]
    H --> I
    I --> J[压测验证串脏消失、内存恢复稳定]

总结

ThreadLocal 本身不是洪水猛兽,问题在于它太好用,以至于大家容易忽略它的两个边界:

  1. 数据跟着线程走,不跟着请求
  2. 线程池会复用线程,所以残留数据会被后续任务看见

把这两个前提记牢,很多坑就能提前避开。

最后给你几个最实用的结论:

  • 凡是 ThreadLocal.set(),几乎都应该配对 try-finally + remove()
  • 线程池场景下,最容易出现数据串脏
  • 弱引用的是 key,不是 value,不 remove 仍可能泄漏
  • MDC、租户上下文、用户上下文,本质上都要按 ThreadLocal 问题来治理
  • 能传参就传参,别把 ThreadLocal 当万能上下文仓库

如果你现在正在线上排查“用户串号”“TraceId 乱跳”“内存慢涨”这类问题,优先从线程池和 ThreadLocal 清理开始查,命中率通常比想象中高。


分享到:

上一篇
《分布式架构中基于一致性哈希与服务发现的微服务流量路由实战》
下一篇
《大模型应用落地指南:从RAG检索增强到Agent编排的关键技术与实践陷阱》