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

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

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

背景与问题

ThreadLocal 这东西,很多 Java 开发都用过:保存用户上下文、链路 ID、租户信息、日期格式化器,写起来顺手,取值也方便。

但它有两个特别容易在生产里“悄悄出事”的坑:

  1. 在线程池里造成内存泄漏
  2. 请求之间发生上下文串扰

我第一次遇到这个问题,是线上某个服务出现了两个奇怪现象:

  • 堆内存持续上涨,Full GC 越来越频繁
  • 某些日志里偶发打印出“上一个请求的用户 ID”

当时看起来像两个不相关的问题,最后追到根上,都是 ThreadLocal 和线程池一起用时没有收尾导致的。

这篇文章不只讲“记得 remove()”,而是带你从现象复现 → 原理理解 → 排查路径 → 止血修复走一遍。对中级 Java 开发来说,这类问题非常值得真正吃透。


现象复现

先说最常见的两个症状。

症状一:上下文串扰

业务代码里我们可能会这样放用户上下文:

public class UserContext {
    private static final ThreadLocal<String> CURRENT_USER = new ThreadLocal<>();

    public static void set(String user) {
        CURRENT_USER.set(user);
    }

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

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

如果在普通单线程、短生命周期线程里,这没什么问题。但一旦放到线程池里,线程会被复用。假如某个任务设置了值却没清理,下一个任务复用同一个线程时,就可能读到脏数据。

症状二:内存不释放

另一个更隐蔽。很多人以为:

ThreadLocal 对象没引用了,应该就会自动回收,不会有问题。

但实际不是这么简单。ThreadLocalMap 的 key 是弱引用,value 不是。
也就是说,key 可能被 GC 回收掉,但 value 还挂在线程对象里。如果线程又是线程池里的核心线程,长期不销毁,这些 value 就可能长时间留在内存里。


核心原理

先把原理捋顺,后面排查才不会只停留在“背口诀”。

ThreadLocal 和线程的关系

ThreadLocal 的值不是存在 ThreadLocal 对象里,而是存在 当前线程 Thread 的 ThreadLocalMap 里。

可以粗略理解成:

Thread
 └── ThreadLocalMap
      ├── Entry(ThreadLocalA -> valueA)
      ├── Entry(ThreadLocalB -> valueB)
      └── ...

每个线程有自己的一份 Map,所以它天然适合“线程隔离”。

关键陷阱:弱引用 key,强引用 value

ThreadLocalMap.Entry 本质上类似:

  • key:WeakReference<ThreadLocal<?>>
  • value:普通强引用对象

这意味着:

  • 如果 ThreadLocal 实例本身没有强引用了,key 会变成 null
  • 但 value 仍然可能留在 ThreadLocalMap
  • 只有在线程后续再次访问 ThreadLocalMap 时,JDK 才有机会顺手清理这些“陈旧 entry”

如果线程是线程池里的工作线程,它可能活很久,于是 value 会滞留很久。

为什么线程池会放大问题

线程池的核心特点是:线程复用

  • 请求 A 在线程 pool-1-thread-1 上执行,设置了 ThreadLocal
  • 没有 remove()
  • 请求 B 之后也跑到 pool-1-thread-1
  • B 读到 A 的上下文,串了

同时,如果 A 放进去的是大对象,线程一直不销毁,这块内存也会一直挂着。


图 1:ThreadLocal 在线程中的存储关系

classDiagram
    class Thread {
      +ThreadLocalMap threadLocals
    }

    class ThreadLocalMap {
      +Entry[] table
    }

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

    class ThreadLocal {
      +set()
      +get()
      +remove()
    }

    Thread --> ThreadLocalMap
    ThreadLocalMap --> Entry
    Entry --> ThreadLocal : weak key

图 2:线程池导致上下文串扰的过程

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

    ReqA->>T1: 执行业务
    T1->>TL: set("userA")
    Note over T1: 未调用 remove()

    ReqB->>T1: 复用同一线程
    T1->>TL: get()
    TL-->>T1: "userA"
    Note over ReqB,T1: 请求B读到了请求A的上下文

实战代码(可运行)

下面我给两个可以直接运行的示例:

  1. 复现上下文串扰
  2. 模拟ThreadLocal 在线程池里的滞留问题

示例一:复现上下文串扰

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

public class ThreadLocalLeakDemo1 {

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

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

        executor.submit(() -> {
            USER_HOLDER.set("Alice");
            System.out.println("任务1设置用户: " + USER_HOLDER.get());
            // 故意不 remove()
        }).get();

        executor.submit(() -> {
            System.out.println("任务2读取用户: " + USER_HOLDER.get());
            // 输出很可能是 Alice,而不是 null
        }).get();

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

运行结果示例

任务1设置用户: Alice
任务2读取用户: Alice

如果线程池大小改成 2,这个问题可能偶发,不容易稳定复现;改成 1 基本就稳定了,因为任务一定复用同一线程。


示例二:正确写法

真正稳妥的方式不是“记得清理”,而是把 set / 使用 / clear 放在同一个边界里

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

public class ThreadLocalLeakDemo2 {

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

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

        executor.submit(() -> {
            try {
                USER_HOLDER.set("Alice");
                System.out.println("任务1设置用户: " + USER_HOLDER.get());
            } finally {
                USER_HOLDER.remove();
            }
        }).get();

        executor.submit(() -> {
            System.out.println("任务2读取用户: " + USER_HOLDER.get());
        }).get();

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

运行结果示例

任务1设置用户: Alice
任务2读取用户: null

示例三:模拟内存滞留风险

这个例子不一定马上 OOM,但足够说明问题:在线程池线程上放大对象,不清理,就会被线程长期持有。

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

public class ThreadLocalLeakDemo3 {

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

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

        for (int i = 0; i < 50; i++) {
            final int index = i;
            executor.submit(() -> {
                LOCAL.set(new byte[1024 * 1024]); // 1MB
                System.out.println("提交任务: " + index);
                // 故意不 remove()
            });
        }

        Thread.sleep(5000);
        System.out.println("任务提交完成,进程保持运行,方便观察堆内存");
    }
}

如何观察

启动时可以加上较小堆参数:

java -Xms64m -Xmx64m ThreadLocalLeakDemo3

再配合:

  • jvisualvm
  • jmap -histo
  • jcmd GC.class_histogram
  • MAT 分析 heap dump

你会发现某些对象明明业务逻辑已经结束,但仍然被工作线程链路引用着。

注意:这个 demo 的现象受 JDK 版本、任务执行节奏、线程池行为影响,未必每次都“炸得很明显”。但作为风险演示是足够的。


定位路径

线上问题一般不是“看代码一眼就知道”,更常见的是从现象倒查。

1. 先判断是内存泄漏,还是对象滞留

严格来说,很多 ThreadLocal 场景更接近“长期滞留”而不是传统意义上的永久泄漏。

判断方法:

  • Full GC 后内存仍然明显降不下来
  • 某类上下文对象数量异常稳定偏高
  • 引用链能追到 java.lang.Thread

如果 heap dump 里看到:

  • Thread
  • threadLocals
  • ThreadLocalMap
  • Entry.value

这条链路很长概率就说明问题了。

2. 看线程池中的工作线程是否长期存活

重点看这些线程:

  • http-nio-*
  • pool-*
  • ForkJoinPool-*
  • 自定义业务线程池名称

如果它们是核心线程、不超时回收,那么 ThreadLocal value 的生命周期就会被线程生命周期“拖长”。

3. 查代码里谁 set 了,谁没 remove

这个排查最容易漏掉两类代码:

第一类:异常分支没清理

try {
    CONTEXT.set(data);
    doSomething();
} catch (Exception e) {
    throw e;
}

上面就有问题,因为没有 finally

第二类:框架封装层 set 了,业务层以为框架会清

比如:

  • 拦截器里 set
  • 过滤器里 set
  • AOP 切面里 set
  • 日志 MDC 包装里 set

但请求中途超时、异步切换、提前返回时,清理动作没走到。


常见坑与排查

这部分我按“最容易误判”的顺序讲。

坑一:用了线程池,就把 ThreadLocal 当请求上下文

这是最常见的误用。

ThreadLocal 只保证同线程隔离,不保证同请求隔离
一旦请求执行过程跨线程,比如:

  • CompletableFuture
  • 异步线程池
  • MQ 回调
  • Reactor / WebFlux
  • 并行流

上下文要么丢失,要么串到别的地方。

错误认知

请求 = 线程

在早期同步 Servlet 模型里,这个认知还勉强成立;但现在很多系统已经不是了。


坑二:只写 set,不写 remove

这类问题往往在测试环境不明显,因为:

  • 并发低
  • 请求少
  • 线程复用程度低
  • 对象不大

一到线上,工作线程长期复用,问题才慢慢浮出来。

经验建议

任何 ThreadLocal.set(),都应该在同一段代码里看到 try/finally + remove()
如果 set 在 A 方法,remove 在 B 方法,中间跨多个层级,后期维护非常容易断掉。


坑三:把大对象、连接对象塞进 ThreadLocal

尤其危险的对象包括:

  • 大数组
  • 大 Map
  • 用户权限快照
  • 数据库连接 / Session
  • HTTP 请求对象派生的大上下文

这些对象一旦残留在线程里,影响会很直接:

  • 堆占用上涨
  • 老年代压力增大
  • Full GC 变频繁
  • 线程长期持有外部资源

坑四:以为 InheritableThreadLocal 能解决线程池上下文传递

很多人想当然地用 InheritableThreadLocal

但它只在创建子线程时拷贝父线程上下文。线程池的线程通常是提前创建并复用的,所以并不能可靠解决问题,反而可能让上下文来源更难追踪。


坑五:MDC、用户上下文、租户上下文一起用,清理顺序混乱

很多项目里不止一个 ThreadLocal

  • 用户 ID
  • TraceId
  • TenantId
  • 权限上下文
  • 日志 MDC

如果只清理了其中一个,剩下几个照样会出问题。
所以线上串扰经常表现为:

  • 日志 traceId 串了
  • 用户信息串了
  • 租户数据串了

实际上是同一类问题。


图 3:典型排查流程

flowchart TD
    A[发现异常现象] --> B{现象类型}
    B -->|内存上涨/GC频繁| C[抓 heap dump]
    B -->|日志串用户/traceId| D[检查线程池复用]
    C --> E[看引用链是否到 Thread.threadLocals]
    D --> F[全局搜索 ThreadLocal.set]
    E --> G[定位未 remove 的上下文]
    F --> G
    G --> H[加 finally remove 或统一装饰器]
    H --> I[回归验证与压测]

止血方案

如果这是线上正在发生的问题,我一般会分三步。

1. 先止串扰

最优先修复所有入口边界:

  • Web Filter
  • Spring MVC Interceptor
  • AOP 切面
  • 线程池任务包装器

核心原则:在哪个边界 set,就在哪个边界 clear。

例如在过滤器里统一处理:

import javax.servlet.*;
import java.io.IOException;

public class UserContextFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        try {
            UserContext.set("mock-user");
            chain.doFilter(request, response);
        } finally {
            UserContext.clear();
        }
    }
}

2. 再清理线程池任务上下文

如果是自定义线程池,可以统一包装任务。

import java.util.concurrent.Executor;

public class ContextSafeExecutor implements Executor {

    private final Executor delegate;

    public ContextSafeExecutor(Executor delegate) {
        this.delegate = delegate;
    }

    @Override
    public void execute(Runnable command) {
        delegate.execute(() -> {
            try {
                command.run();
            } finally {
                UserContext.clear();
            }
        });
    }
}

这个方式不一定能解决“跨线程传递上下文”的需求,但至少能防止任务执行后脏数据残留。

3. 最后处理存量线程

如果线上已经有脏上下文残留,而你又不能马上重启:

  • 可以临时下线实例滚动重启
  • 或者降流后逐步替换
  • 对可控线程池可考虑回收旧线程、重建线程池

因为已经残留在线程里的 value,不一定会立刻消失。


安全/性能最佳实践

这部分给一些我更推荐落地的做法。

1. ThreadLocal 只存“轻量、短生命周期、强边界”的数据

适合放:

  • traceId
  • userId
  • tenantId
  • 少量只读上下文

不适合放:

  • 大对象
  • 可变共享状态
  • 连接型资源
  • 缓存集合

2. 永远使用 try/finally 清理

标准模板如下:

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

这个规则不要靠“自觉”,要靠代码评审规范统一封装


3. 在线程池提交任务时做装饰,而不是在业务里散着写

尤其是这些场景:

  • 异步日志上下文
  • 统一安全上下文
  • 多租户信息
  • 审计信息

可以通过 Executor 包装、TaskDecorator、框架拦截器做集中治理。

如果是 Spring,常见做法是给线程池配置 TaskDecorator,统一复制与清理上下文。


4. 不要把 ThreadLocal 当跨线程传参工具

跨线程上下文传递,优先考虑:

  • 显式参数传递
  • 框架级上下文机制
  • 专门的上下文传播库

因为显式传参虽然“麻烦一点”,但边界清晰、可测试性好,也更不容易埋雷。


5. 监控上要盯住这几个信号

建议线上持续观察:

  • 老年代使用率
  • Full GC 次数与耗时
  • 活跃线程数
  • 线程池队列堆积
  • traceId / userId 串扰日志样本

如果一个服务同时出现:

  • 线程池工作线程长期稳定
  • 内存回收异常
  • 日志上下文偶发串扰

那就很值得怀疑 ThreadLocal 使用方式了。


图 4:推荐的安全使用模式

stateDiagram-v2
    [*] --> EnterBoundary
    EnterBoundary --> SetContext
    SetContext --> ExecuteBusiness
    ExecuteBusiness --> ClearContext: finally
    ClearContext --> [*]

一个更稳妥的封装示例

如果你希望业务代码少写重复模板,可以把上下文使用封成工具方法。

public class ThreadLocalGuard {

    public static <T> void runWithContext(ThreadLocal<T> local, T value, Runnable task) {
        try {
            local.set(value);
            task.run();
        } finally {
            local.remove();
        }
    }
}

使用方式:

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

    public static void main(String[] args) {
        ThreadLocalGuard.runWithContext(USER, "Alice", () -> {
            System.out.println("当前用户: " + USER.get());
        });
    }
}

这个封装虽然简单,但好处很明显:

  • set/remove 成对出现
  • 降低遗漏概率
  • 代码审查更容易发现问题

边界条件:什么时候 ThreadLocal 仍然值得用?

说了这么多坑,不是说 ThreadLocal 不能用,而是要知道它适用在哪,危险在哪

适合用的场景:

  • 同线程内传递少量上下文
  • 调用链很清楚
  • 生命周期边界明确
  • 有统一清理机制

不适合用的场景:

  • 大量异步切换线程
  • 响应式编程
  • 复杂任务编排
  • 上下文对象很大
  • 团队里没有统一治理能力

如果你的服务已经大量依赖异步和线程池,而上下文又很复杂,那么显式传参、统一上下文对象、框架级治理,通常比散落的 ThreadLocal 更可靠。


总结

把这次踩坑浓缩成几句最实用的话:

  1. ThreadLocal 的值存在 Thread 里,不在线程池里及时清理就会残留。
  2. 线程池复用线程,会放大两个问题:内存滞留和上下文串扰。
  3. ThreadLocalMap 的 key 是弱引用,但 value 不是,所以“key 被回收”不代表 value 立即释放。
  4. 任何 set() 都要和 finally remove() 成对出现。
  5. 不要用 ThreadLocal 承担跨线程上下文传递。

如果你最近正好遇到:

  • 某些日志串了用户、租户、traceId
  • 服务内存缓慢上涨
  • heap dump 看到对象被 Thread 持有

那就优先回头检查:是不是某个 ThreadLocal 在线程池里没清干净。

这是个非常典型、也非常“阴”的 Java 生产坑。踩过一次,后面你对线程边界、上下文边界、资源生命周期的感觉会明显更扎实。


分享到:

上一篇
《微服务架构下的分布式事务实战:基于 Seata 的一致性设计与落地优化》
下一篇
《Web3 中级实战:基于 Solidity 与 OpenZeppelin 构建可升级智能合约的设计、部署与安全避坑》