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

《Java开发踩坑实战:ThreadLocal 在线程池中的内存泄漏与上下文串值排查指南》

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

背景与问题

ThreadLocal 是很多 Java 项目里“看起来很优雅”的工具:把用户信息、TraceId、租户 ID、数据库路由键塞进去,方法签名就能清爽不少。

但我自己在线上排查过几次事故后,对它的态度就变成了:能用,但一定要带着敬畏心用

最典型的两个坑:

  1. 线程池复用导致上下文串值

    • 上一个请求写进 ThreadLocal 的值没清掉
    • 下一个请求刚好复用到同一条线程
    • 结果 A 用户看到了 B 用户的上下文,或者日志 TraceId 串了
  2. 线程长期存活导致内存泄漏

    • 在线程池中,线程不是“用完就死”
    • ThreadLocal 里的 value 可能一直挂在线程对象上
    • 如果 value 很大、或者关联大量对象,就会慢慢把堆吃满

很多人知道“用完要 remove()”,但真正出问题时,往往不是因为不知道这句话,而是:

  • 不知道为什么会泄漏
  • 不知道为什么在线程池里更容易出事
  • 不知道怎么复现、怎么定位、怎么止血

这篇文章我就按“排障实战”的思路,带你走一遍。


背景现象复现

先看几个常见线上症状,你大概率会遇到其中一个:

  • 日志里偶发出现错误的 TraceId
  • 同一个接口偶发读到别人的租户 ID / 用户 ID
  • 老年代对象持续增长,Full GC 频繁
  • 堆转储里能看到大量业务对象被 Thread 引用链持有
  • 重启服务后恢复正常,运行一段时间后又慢慢恶化

这些现象背后,很多时候都指向同一个组合:

ThreadLocal + 线程池 + 未清理上下文


核心原理

先把原理讲清楚,不然后面的排查会像猜谜。

ThreadLocal 到底把值存哪了?

很多人下意识以为是“ThreadLocal 对象里存了线程对应的值”。其实不是。

更准确地说:

  • 每个 Thread 对象内部都有一个 ThreadLocalMap
  • ThreadLocal#set() 时,会把当前线程作为宿主,把值存进这个 ThreadLocalMap
  • keyThreadLocal 对象本身(准确说是弱引用)
  • value 是你放进去的对象

可以画成这样:

classDiagram
class Thread {
  ThreadLocalMap threadLocals
}
class ThreadLocalMap {
  Entry[] table
}
class Entry {
  WeakReference~ThreadLocal~ key
  Object value
}
class ThreadLocal
class UserContext

Thread --> ThreadLocalMap
ThreadLocalMap --> Entry
Entry --> ThreadLocal : weak key
Entry --> UserContext : strong value

关键点有两个:

  • key 是弱引用
  • value 是强引用

这两个点合起来,就是很多内存泄漏问题的根源。

为什么会内存泄漏?

假设你写了这样的代码:

new ThreadLocal<BigObject>().set(bigObject);

如果这个 ThreadLocal 实例本身没有外部强引用了,GC 后可能出现这种情况:

  • Entry.key == null
  • Entry.value 还在

因为 value 仍然被当前线程的 ThreadLocalMap 强引用着。只要线程不结束,这个 value 就可能一直活着。

而在线程池里,线程通常是长期存活的,所以问题特别明显。

为什么线程池里会串值?

因为线程池会复用线程。

请求 A 在 pool-1-thread-1 上执行时:

  • ThreadLocal.set(userA)
  • 忘了 remove()

请求 B 恰好也跑到 pool-1-thread-1

  • 如果代码里读取了同一个 ThreadLocal
  • 就可能直接读到 userA

这不是概率很低的“玄学 bug”,而是线程池的正常行为叠加上下文没清理的必然结果。


两类问题的执行路径

flowchart TD
    A[请求进入线程池] --> B[线程执行任务]
    B --> C[ThreadLocal set 上下文]
    C --> D{任务结束是否 remove}
    D -- 否 --> E[线程被归还线程池]
    E --> F[下个请求复用同一线程]
    F --> G[读到旧上下文 串值]
    E --> H[线程长期存活]
    H --> I[ThreadLocalMap 持有 value]
    I --> J[对象无法及时释放]
    J --> K[内存泄漏/GC压力上升]
    D -- 是 --> L[上下文清理完成]

实战代码(可运行)

下面我们用一段简单代码,分别复现:

  1. 上下文串值
  2. 正确清理方式

示例 1:在线程池中复现串值

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 pool = Executors.newFixedThreadPool(1);

        pool.submit(() -> {
            USER_HOLDER.set("user-A");
            System.out.println(Thread.currentThread().getName() + " set user-A");
            // 故意不清理
        }).get();

        pool.submit(() -> {
            String value = USER_HOLDER.get();
            System.out.println(Thread.currentThread().getName() + " read value = " + value);
        }).get();

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

预期输出

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

第二个任务明明没有 set,却读到了上一个任务的值,这就是典型串值。

这里故意把线程池大小设为 1,是为了稳定复现。在线上只是“线程复用”变成了不稳定复现而已,本质一样。


示例 2:正确写法,必须在 finally 中清理

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 pool = Executors.newFixedThreadPool(1);

        pool.submit(() -> {
            try {
                USER_HOLDER.set("user-A");
                System.out.println(Thread.currentThread().getName() + " set user-A");
            } finally {
                USER_HOLDER.remove();
            }
        }).get();

        pool.submit(() -> {
            String value = USER_HOLDER.get();
            System.out.println(Thread.currentThread().getName() + " read value = " + value);
        }).get();

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

预期输出

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

这才是线程池环境下 ThreadLocal 的基本用法。


示例 3:模拟“大对象不清理”的风险

这个例子不一定马上 OOM,但能说明问题:线程池线程长期不死,大对象就会长期挂着

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

public class ThreadLocalLeakDemo3 {

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

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

        for (int i = 0; i < 1000; i++) {
            pool.submit(() -> {
                List<byte[]> data = new ArrayList<>();
                for (int j = 0; j < 5; j++) {
                    data.add(new byte[1024 * 1024]); // 1MB
                }
                LOCAL.set(data);

                // 模拟业务逻辑
                try {
                    Thread.sleep(50);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }

                // 故意不 remove
            });
        }

        // 演示用,不主动 shutdown,观察进程内存变化
    }
}

如果你把它改成下面这样,风险会明显降低:

try {
    LOCAL.set(data);
    // do business
} finally {
    LOCAL.remove();
}

定位路径:出问题时怎么查

真正棘手的不是“知道原理”,而是线上已经报警了,怎么一步步定位。

排查思路总览

sequenceDiagram
    participant App as 应用线程池
    participant Log as 日志/监控
    participant JVM as JVM/GC
    participant MAT as Heap Dump分析

    Log->>App: 发现TraceId串值/用户上下文异常
    Log->>JVM: 发现堆使用持续增长
    JVM->>MAT: 导出Heap Dump
    MAT->>App: 查看Thread对象引用链
    MAT->>App: 定位ThreadLocalMap.Entry.value
    App->>App: 回溯未remove的业务代码

1. 先从“现象”判断是串值还是泄漏

如果是串值,常见信号有:

  • 某些请求日志里的 TraceId 不属于当前请求
  • 用户 A 的请求带出了用户 B 的上下文
  • 多租户场景下 SQL 跑到了错误的数据源
  • 问题通常是“偶发”,重试可能消失

如果是泄漏,常见信号有:

  • 堆使用率缓慢爬升
  • 老年代回收效果差
  • Full GC 次数增多
  • 重启后恢复,运行一段时间再次出现

这两类问题经常是一起出现的,只是表现先后不同。


2. 日志里加线程名和上下文值

如果你怀疑上下文串值,第一步不要急着 dump 堆,先把日志打透。

比如打印:

System.out.printf("thread=%s, traceId=%s, userId=%s%n",
        Thread.currentThread().getName(),
        TRACE_ID.get(),
        USER_ID.get());

观察有没有这种情况:

  • 同一线程在不同请求之间,出现不应该继承的旧值
  • 某次请求入口没有设置上下文,但业务层却读到了值

如果看到了,基本就能判断是清理缺失。


3. 查线程池包装层有没有兜底清理

我排查这类问题时,最常看的地方不是业务代码本身,而是这些公共层:

  • 线程池任务提交封装
  • Runnable / Callable 装饰器
  • Web Filter / Interceptor
  • RPC Filter
  • 异步框架回调封装
  • 日志 MDC 透传组件

因为真正漏掉 remove() 的,往往不是“一个 set 一个 remove”这么简单,而是:

  • 中间抛异常了
  • 有多层 ThreadLocal
  • 某个分支 return 了
  • 异步任务提交后复制了上下文,但没清空

4. 用 jmap / MAT 看堆里的引用链

如果怀疑内存泄漏,可以抓 Heap Dump 分析。

常见步骤:

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

用 MAT 打开后,重点关注:

  • java.lang.Thread
  • threadLocals
  • ThreadLocal$ThreadLocalMap
  • ThreadLocal$ThreadLocalMap$Entry
  • 你的业务对象是否被 value 持有

典型引用链通常长这样:

Thread
  -> threadLocals
    -> ThreadLocalMap
      -> Entry
        -> value
          -> YourBigObject

如果你发现:

  • Entry.key 已经是 null
  • value 很大

那就是典型的 stale entry 问题。


常见坑与排查

下面这些坑,都是我见过或者自己踩过的。

坑 1:只 set()remove()

这是最直接也最常见的坑。

错误写法

public void process() {
    USER_CONTEXT.set("u123");
    doBusiness();
}

正确写法

public void process() {
    try {
        USER_CONTEXT.set("u123");
        doBusiness();
    } finally {
        USER_CONTEXT.remove();
    }
}

边界条件set(null) 不等于 remove()
set(null) 只是把值设为 null,Entry 还在;remove() 才会真正清理映射关系。


坑 2:在静态 ThreadLocal 上放大对象

比如:

  • 大型 DTO
  • SQL 结果集
  • 文件内容
  • 缓存对象
  • 含外部连接的上下文对象

ThreadLocal 更适合放:

  • 小型上下文标识
  • TraceId
  • 用户 ID
  • 租户 ID
  • 轻量级路由键

不适合放“重对象容器”。


坑 3:父线程设置了值,以为子线程也能安全继承

很多人会用 InheritableThreadLocal,想让子线程自动继承上下文。

这在线程池里尤其危险。

原因是:

  • 线程池中的工作线程通常提前创建
  • 不是“每次任务提交都新建子线程”
  • 继承时机和你想象的不一样
  • 还可能造成更隐蔽的串值

所以:

  • 在线程池中,不要把 InheritableThreadLocal 当作万能透传方案
  • 需要上下文透传时,用明确的任务包装器,且执行后清理

坑 4:MDC、数据源路由、用户上下文分别各用一套 ThreadLocal,清理不一致

这是企业项目里非常常见的混乱场景:

  • 日志 MDC 一套
  • 数据源路由一套
  • 用户信息一套
  • 权限上下文一套

结果就是:

  • 有的地方清理了
  • 有的地方没清理
  • 有的在 Filter 清
  • 有的在 AOP 清
  • 有的根本没人清

建议统一收口,做一个上下文生命周期管理器,至少保证:

  • 设置入口统一
  • 清理出口统一
  • 异常路径统一

坑 5:异步任务复制上下文后忘记清理

比如你自己写了一个任务包装器:

public class ContextTask implements Runnable {
    private final Runnable delegate;
    private final String userId;

    public ContextTask(Runnable delegate, String userId) {
        this.delegate = delegate;
        this.userId = userId;
    }

    @Override
    public void run() {
        USER_CONTEXT.set(userId);
        delegate.run();
    }
}

看起来做了上下文透传,但还是有问题:没清理

应该这么写:

public class ContextTask implements Runnable {
    private final Runnable delegate;
    private final String userId;

    public ContextTask(Runnable delegate, String userId) {
        this.delegate = delegate;
        this.userId = userId;
    }

    @Override
    public void run() {
        try {
            USER_CONTEXT.set(userId);
            delegate.run();
        } finally {
            USER_CONTEXT.remove();
        }
    }
}

止血方案

如果线上已经出问题,先别追求“最优雅”,先止血。

方案 1:统一在任务执行外层做 finally 清理

如果你们项目里线程池是统一封装的,可以直接包一层:

import java.util.concurrent.Executor;

public class CleaningExecutor implements Executor {

    private final Executor delegate;

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

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

配套的 ContextHolder

public class ContextHolder {
    private static final ThreadLocal<String> USER_ID = new ThreadLocal<>();
    private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();

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

    public static String getUserId() {
        return USER_ID.get();
    }

    public static void setTraceId(String traceId) {
        TRACE_ID.set(traceId);
    }

    public static String getTraceId() {
        return TRACE_ID.get();
    }

    public static void clearAll() {
        USER_ID.remove();
        TRACE_ID.remove();
    }
}

这个方案的好处是:不用指望每个业务开发都记得手动 remove。


方案 2:Web / RPC 入口统一清理

在请求入口和出口统一做生命周期控制。

例如 Web Filter:

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

public class ContextCleanupFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        try {
            chain.doFilter(request, response);
        } finally {
            ContextHolder.clearAll();
        }
    }
}

注意这只能覆盖请求处理线程,如果内部又提交到线程池,异步任务仍然要单独处理。


方案 3:短期内无法彻底修复时,减少 ThreadLocal 存储内容

如果问题已经影响内存,短期止血可以先做:

  • 只存 ID,不存大对象
  • 上下文对象去掉大字段
  • 避免缓存集合、字节数组、查询结果
  • 拆成轻量级标识 + 显式查询

这不能替代 remove(),但能降低事故半径。


安全/性能最佳实践

这一节给“能落地”的建议,不讲空话。

1. 任何 ThreadLocal.set() 都要和 remove() 配对

最稳妥的模板就是:

try {
    THREAD_LOCAL.set(value);
    // business logic
} finally {
    THREAD_LOCAL.remove();
}

不要侥幸,不要靠调用方记忆。


2. 优先把 ThreadLocal 当作“请求级轻量上下文”,不要当缓存

适合放:

  • TraceId
  • RequestId
  • UserId
  • TenantId
  • Locale

不适合放:

  • 大对象
  • 可共享缓存
  • 连接、流、会话资源
  • 生命周期不清晰的业务对象

3. 在线程池场景下,优先做统一封装

比起让每个人自己写:

threadLocal.set();
try {
   ...
} finally {
   threadLocal.remove();
}

更推荐:

  • 封装线程池执行器
  • 封装任务装饰器
  • 封装上下文管理器

这样能把“规范”变成“机制”。


4. 对上下文透传要谨慎,特别是跨线程

如果业务允许,最安全的方式其实是:

  • 显式传参

比如把 userIdtraceId 作为方法参数传下去。
虽然代码啰嗦一点,但可读性和可控性通常更高。

ThreadLocal 更适合解决“横切关注点”,不适合替代正常的数据流。


5. 给关键 ThreadLocal 做命名和归档

实际项目中,最怕的是没人知道系统里有多少个 ThreadLocal

建议做这些动作:

  • 统一定义在 ContextHolder 一类中
  • 写清楚用途、设置入口、清理入口
  • 禁止业务代码随手 new ThreadLocal<>()
  • 定期扫描 ThreadLocal 使用点

这对排障帮助非常大。


6. 关注第三方框架也可能带来的 ThreadLocal 残留

别只盯着自己代码。常见框架也会使用 ThreadLocal,例如:

  • 日志 MDC
  • ORM / 数据源路由
  • Web 请求上下文
  • RPC 框架上下文

如果框架版本有 bug,或者接入姿势不对,也可能残留上下文。
所以排查时不要只搜自己项目代码,也要看依赖链。


一个推荐的落地模板

如果你们团队经常用上下文,我建议直接做成下面这种“统一入口、统一清理”的模式。

public final class RequestContext {
    private RequestContext() {}

    private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();
    private static final ThreadLocal<Long> USER_ID = new ThreadLocal<>();

    public static void setTraceId(String traceId) {
        TRACE_ID.set(traceId);
    }

    public static String getTraceId() {
        return TRACE_ID.get();
    }

    public static void setUserId(Long userId) {
        USER_ID.set(userId);
    }

    public static Long getUserId() {
        return USER_ID.get();
    }

    public static void clear() {
        TRACE_ID.remove();
        USER_ID.remove();
    }
}

配合统一执行模板:

public class RequestContextRunner {

    public static void runWithContext(String traceId, Long userId, Runnable task) {
        try {
            RequestContext.setTraceId(traceId);
            RequestContext.setUserId(userId);
            task.run();
        } finally {
            RequestContext.clear();
        }
    }
}

使用时:

public class DemoApp {
    public static void main(String[] args) {
        RequestContextRunner.runWithContext("trace-1001", 42L, () -> {
            System.out.println(RequestContext.getTraceId());
            System.out.println(RequestContext.getUserId());
        });
    }
}

这种方式虽然不花哨,但很稳定。


总结

ThreadLocal 本身不是洪水猛兽,真正危险的是:

  • 在线程池中复用线程
  • 业务把上下文写进去却不清理
  • value 又比较大或者生命周期失控

你可以把这篇文章记成三句话:

  1. ThreadLocal 的值是挂在线程上的,不是挂在请求上的
  2. 线程池会复用线程,所以不清理就会串值
  3. key 是弱引用,value 是强引用,所以线程长期存活时可能泄漏

最后给你一组可执行建议:

  • 所有 ThreadLocal.set() 后面必须有 finally remove()
  • 在线程池、异步任务、回调场景下尤其严格
  • 不要在 ThreadLocal 里放大对象
  • 统一封装上下文管理和清理机制
  • 出现串值先查日志和线程名,出现泄漏再抓 dump 看 ThreadLocalMap

如果你的系统已经在线上跑了很久,我真心建议你现在就全局搜一遍:

ThreadLocal
InheritableThreadLocal
MDC
set(
remove(

很多坑,都是“代码一直在那里,只是事故还没发生”。


分享到:

上一篇
《Web3 中级实战:基于 Solidity 与 OpenZeppelin 构建可升级智能合约的设计、部署与安全避坑》
下一篇
《分布式架构中基于消息队列与幂等设计的订单系统最终一致性实战》