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

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

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

背景与问题

很多 Java 项目里都用过 ThreadLocal:保存用户信息、请求追踪 ID、租户标识、数据库路由上下文……写起来很顺手,因为“当前线程可见,别的线程拿不到”。

问题也恰恰出在这里。

一旦你把 ThreadLocal 放进线程池环境里,坑就开始变得隐蔽:

  • 内存泄漏:请求结束了,但 ThreadLocal 里的值没清掉,线程又因为在线程池里长期存活,值也跟着长期存活。
  • 上下文串值:上一个请求留在工作线程里的用户信息,被下一个请求“继承”了,最终出现 A 用户看到了 B 用户数据这种离谱现象。
  • 排查困难:现场通常不是“直接报错”,而是内存缓慢上涨、日志 traceId 混乱、偶发越权、数据串租户。

我自己第一次遇到这个问题时,表面现象是“日志 traceId 偶尔串了”,一开始还以为是链路追踪组件有 bug,最后才发现是线程池里的 ThreadLocal 没有 remove()

这篇文章不讲空泛定义,重点从原理、复现、定位、止血、最佳实践几个角度,把这个坑完整走一遍。


背景场景:为什么在线程池里更容易出事

先看一个常见使用方式:

  • Web 请求进来
  • 在拦截器/过滤器里把当前用户信息放入 ThreadLocal
  • 业务代码从 ThreadLocal 里直接获取
  • 请求结束后理论上应该清理

如果线程是“一次性线程”,任务跑完线程结束,问题还没那么明显。
但在线程池中,线程不会结束,会被复用。于是:

  1. 线程 A 处理请求 1,往 ThreadLocal 放了值
  2. 请求 1 结束,没有清理
  3. 同一个线程 A 又处理请求 2
  4. 请求 2 在某些逻辑里读到请求 1 的遗留值

这就是典型的“串值”。

而内存泄漏则更隐蔽:线程池线程长期存在,线程对象里关联的 ThreadLocalMap 也长期存在,里面的 value 如果一直挂着,就很难被回收。


flowchart TD
    A[请求1进入线程池线程T1] --> B[ThreadLocal.set 用户A]
    B --> C[业务处理结束]
    C --> D[未调用 remove]
    D --> E[线程T1归还线程池]
    E --> F[请求2复用线程T1]
    F --> G[读取到旧上下文 用户A]
    G --> H[发生串值或权限问题]

核心原理

1. ThreadLocal 并不是“全局隔离”,而是“线程维度存储”

ThreadLocal 的本质不是把值存在 ThreadLocal 对象里,而是把值放在当前线程对象的 ThreadLocalMap 中。

可以粗略理解为:

currentThread.threadLocalMap.put(threadLocal, value);

因此:

  • 同一个 ThreadLocal,不同线程读到的是不同值
  • 值的生命周期,很大程度取决于线程生命周期
  • 线程池线程长时间不销毁,值就更容易残留

2. 为什么会内存泄漏

ThreadLocalMap 的 key 是 ThreadLocal弱引用,value 是强引用

这会带来一个经典问题:

  • 如果外部不再持有某个 ThreadLocal 实例
  • 那么 key 可能被 GC 回收,变成 null
  • 但是对应的 value 仍然被 ThreadLocalMap 强引用着
  • 只要线程还活着,这个 value 可能就一直留在内存里

这类 entry 通常被称为“stale entry(陈旧条目)”。

注意一个容易误解的点:
弱引用 key 并不等于不会泄漏。
它只是让 key 更容易被回收,但 value 仍可能滞留。


classDiagram
    class Thread {
        ThreadLocalMap threadLocals
    }

    class ThreadLocalMap {
        Entry[] table
    }

    class Entry {
        WeakReference~ThreadLocal~ key
        Object value
    }

    Thread --> ThreadLocalMap
    ThreadLocalMap --> Entry

3. 为什么线程池下更严重

普通短命线程中,即使没清理,线程结束后整块线程对象都能被回收。
但线程池线程通常是:

  • 核心线程长期驻留
  • 工作线程不断复用
  • 一个线程会处理大量任务

所以没清理的 ThreadLocal 值可能:

  • 在多个任务之间串传
  • 长时间占用内存
  • 让问题变成“慢性病”,很难第一时间被发现

4. InheritableThreadLocal 不是救星

不少人会想到 InheritableThreadLocal:子线程能继承父线程变量,看起来很方便。

但在线程池中它通常更危险:

  • 线程池线程往往早就创建好了
  • 后续提交任务时,并不会重新从父线程复制上下文
  • 结果经常是“你以为继承了,其实没继承对”
  • 再叠加线程复用,还会出现更加诡异的串值

一句话:在线程池里,不要把 InheritableThreadLocal 当作上下文传播方案。


现象复现

先用一个最小可运行示例,把“串值”和“未清理”复现出来。

复现 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_CONTEXT = new ThreadLocal<>();

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

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

        // 第二个任务:不设置值,直接读取
        pool.submit(() -> {
            System.out.println(Thread.currentThread().getName() + " second task get = " + USER_CONTEXT.get());
        }).get();

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

运行结果示例

pool-1-thread-1 set user-A
pool-1-thread-1 get = user-A
pool-1-thread-1 second task get = user-A

第二个任务本来没有设置任何用户上下文,却读到了上一个任务留下来的 user-A
这就是串值。


复现 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_CONTEXT = new ThreadLocal<>();

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

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

        pool.submit(() -> {
            System.out.println(Thread.currentThread().getName() + " second task get = " + USER_CONTEXT.get());
        }).get();

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

运行结果示例

pool-1-thread-1 set user-A
pool-1-thread-1 get = user-A
pool-1-thread-1 second task get = null

这才是我们预期的行为。


实战代码(可运行)

下面给一个更贴近业务场景的示例:
模拟 Web 服务里通过 ThreadLocal 维护 traceId,然后在线程池中执行业务任务。

错误示例:上下文残留

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

public class TraceContextBadCase {

    static class TraceContext {
        private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();

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

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

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

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

        // 模拟请求1
        executor.submit(() -> {
            String traceId = "REQ-1-" + UUID.randomUUID();
            TraceContext.set(traceId);
            log("处理请求1");
            // 这里漏掉 clear()
        }).get();

        // 模拟请求2,没有设置 traceId,直接执行业务
        executor.submit(() -> {
            log("处理请求2");
        }).get();

        executor.shutdown();
    }

    private static void log(String message) {
        System.out.printf("[%s] [%s] %s%n",
                Thread.currentThread().getName(),
                TraceContext.get(),
                message);
    }
}

可能输出

[pool-1-thread-1] [REQ-1-0c1c8f62-xxxx] 处理请求1
[pool-1-thread-1] [REQ-1-0c1c8f62-xxxx] 处理请求2

请求 2 居然带着请求 1 的 traceId。


正确示例:封装上下文执行器,自动清理

如果业务里经常使用 ThreadLocal,我建议不要把 set/remove 散落在每个地方,而是统一封装。

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

public class TraceContextGoodCase {

    static class TraceContext {
        private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();

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

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

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

    static class ContextAwareExecutor {
        private final ExecutorService executorService;

        public ContextAwareExecutor(ExecutorService executorService) {
            this.executorService = executorService;
        }

        public void submit(String traceId, Runnable task) throws Exception {
            executorService.submit(() -> {
                try {
                    TraceContext.set(traceId);
                    task.run();
                } finally {
                    TraceContext.clear();
                }
            }).get();
        }

        public void shutdown() {
            executorService.shutdown();
        }
    }

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

        executor.submit("REQ-1-" + UUID.randomUUID(), () -> log("处理请求1"));
        executor.submit("REQ-2-" + UUID.randomUUID(), () -> log("处理请求2"));

        executor.shutdown();
    }

    private static void log(String message) {
        System.out.printf("[%s] [%s] %s%n",
                Thread.currentThread().getName(),
                TraceContext.get(),
                message);
    }
}

这个版本做了两件事:

  1. 每个任务执行前设置上下文
  2. 无论任务是否异常,都会在 finally 中清理

这是线程池里使用 ThreadLocal 最基本的防线。


定位路径:出了问题该怎么查

很多人知道“要 remove”,但真实线上排查时,难点不是知道原理,而是怎么快速定位

我一般会按下面这条路径查。

1. 先看现象是否符合 ThreadLocal 问题特征

重点关注这些信号:

  • 日志中的 traceIduserIdtenantId 偶发串号
  • 线程池中的任务偶发读到“上一次请求”的上下文
  • 内存持续上涨,但对象分布看起来并不集中在业务缓存里
  • 问题只在线上高并发或压测时明显出现
  • 重启后暂时恢复正常

如果同时出现“线程池 + 上下文变量 + 偶发串值”,十有八九要怀疑 ThreadLocal

2. 全局搜索 ThreadLocal 定义点

直接在代码里搜:

  • new ThreadLocal
  • ThreadLocal.withInitial
  • InheritableThreadLocal
  • 各类上下文类名:UserContextTraceContextTenantContext

重点关注:

  • 是否有 set() 但没有 remove()
  • remove() 是否真的在 finally
  • 是否跨线程读取
  • 是否通过线程池异步执行

3. 检查过滤器、拦截器、AOP、线程池包装器

实际项目里最容易漏清理的地方,不是在 Controller,而是在这些“基础设施层”:

  • Servlet Filter
  • Spring HandlerInterceptor
  • AOP 切面
  • 自定义线程池提交包装
  • 异步任务框架回调

尤其要看有没有这种代码:

context.set(xxx);
chain.doFilter(request, response);
// 没有 finally 清理

或者:

executor.submit(() -> {
    context.set(xxx);
    service.doSomething();
    // 异常时不会走到 clear
    context.clear();
});

后者在发生异常时特别容易漏。


sequenceDiagram
    participant Req1 as 请求1
    participant Pool as 线程池线程T1
    participant TL as ThreadLocal
    participant Req2 as 请求2

    Req1->>Pool: 提交任务
    Pool->>TL: set(userA)
    Pool->>Pool: 执行业务
    Pool-->>Req1: 返回(未remove)
    Req2->>Pool: 提交任务
    Pool->>TL: get()
    TL-->>Pool: userA
    Pool-->>Req2: 读到旧值,发生串值

4. 内存问题排查:看线程对象关联值

如果你怀疑已经有内存泄漏,可以用这些手段:

  • jmap -histo
  • heap dump + MAT / YourKit / JProfiler
  • 查看可疑大对象是否被线程长时间引用
  • 按线程名、线程池名聚类分析

排查思路不是机械地找“ThreadLocal”三个字,而是看:

  • 某些 value 对象为什么没有释放
  • 它们是否被 Thread -> ThreadLocalMap -> Entry -> value 链路引用
  • 线程池线程是否长期存活

在 MAT 里常见路径会类似:

java.lang.Thread
  -> threadLocals
  -> ThreadLocalMap
  -> table
  -> Entry
  -> value

如果这个 value 恰好是:

  • 大对象
  • 用户会话
  • 数据缓存
  • DB 连接包装
  • ByteBuffer
  • 业务上下文对象树

那危害会更大。


常见坑与排查

坑 1:只 set,不 remove

这是最常见、也是最致命的。

错误写法

public void process(String userId) {
    USER_CONTEXT.set(userId);
    doBiz();
}

正确写法

public void process(String userId) {
    try {
        USER_CONTEXT.set(userId);
        doBiz();
    } finally {
        USER_CONTEXT.remove();
    }
}

坑 2:以为设成 null 就等于清理

很多人会写:

USER_CONTEXT.set(null);

这不等于 remove()

区别在于:

  • set(null):条目可能还在,只是 value 为 null
  • remove():真正移除当前线程里对应 entry

实践里,清理一定优先用 remove()


坑 3:异常路径漏清理

比如:

USER_CONTEXT.set(userId);
service.call(); // 这里抛异常
USER_CONTEXT.remove();

一抛异常,后面的 remove() 根本执行不到。

所以一定要写进 finally


坑 4:在线程池任务提交前后传播上下文不完整

比如主线程里有 traceId,你提交到线程池的任务也想带过去。
很多人会这么写:

executor.submit(() -> {
    log.info("traceId={}", TRACE_ID.get());
});

结果发现线程池里是 null,或者更糟,读到旧值。

因为线程池线程不是当前线程,ThreadLocal 不会自动传播。

如果确实要跨线程传递上下文,要么:

  • 显式把参数传进去
  • 要么在任务包装时复制、设置、清理

而不是指望 ThreadLocal 自动帮你传。


坑 5:把大对象放进 ThreadLocal

有时为了图省事,会把整套上下文对象放进去:

ThreadLocal<Map<String, Object>> CTX = new ThreadLocal<>();

甚至里面还挂:

  • 用户权限树
  • 大量缓存数据
  • 大 JSON
  • 连接对象/流对象

一旦泄漏,后果会非常明显。
ThreadLocal 更适合放轻量、短生命周期、明确边界的小对象。


坑 6:使用 static ThreadLocal 却没有生命周期治理

static final ThreadLocal 本身不是问题,很多上下文工具类都这么写。
真正的问题是:

  • 全局可访问
  • 使用点分散
  • 很容易只写 set,不写 remove

所以如果用 static ThreadLocal,更要有统一规范和封装。


坑 7:误用 InheritableThreadLocal 在线程池中传参

这个前面提过,再强调一次:

  • 它更适合“新建子线程时继承”
  • 不适合线程池复用线程的场景

在线程池里,它经常不是“继承失效”,就是“继承脏数据”。


止血方案

如果线上已经出现串值或内存上涨,优先做止血,而不是先做“大重构”。

方案 1:所有 ThreadLocal 使用点加 finally remove

这是第一优先级,收益最大。

可以先从高风险点下手:

  • 用户上下文
  • 租户上下文
  • traceId / MDC
  • 数据源路由上下文

方案 2:在线程池入口统一包装任务

如果项目里线程池提交点比较集中,可以统一做代理:

public class SafeRunnable implements Runnable {
    private final Runnable delegate;
    private final String traceId;

    public SafeRunnable(Runnable delegate, String traceId) {
        this.delegate = delegate;
        this.traceId = traceId;
    }

    @Override
    public void run() {
        try {
            TraceContext.set(traceId);
            delegate.run();
        } finally {
            TraceContext.clear();
        }
    }
}

这样比要求每个业务开发都记得清理更可靠。

方案 3:临时缩短线程存活不是根治

有些场景里,重建线程池或缩短线程存活时间能缓解泄漏,但这只是缓解,不是根治。
根因还是:上下文没有被正确清理。

方案 4:必要时改为显式参数传递

如果上下文只在几层调用链里使用,其实直接传参更清晰:

service.process(userId, traceId);

不要为了“代码看起来优雅”而过度依赖 ThreadLocal
很多时候,显式传参比隐式上下文更可维护。


安全/性能最佳实践

这一节我尽量给“能落地”的建议。

1. ThreadLocal 的使用边界要非常明确

适合放的内容:

  • 请求级 traceId
  • 轻量用户标识
  • 简单租户标识
  • 临时格式化器实例等线程私有轻量对象

不适合放的内容:

  • 大对象
  • 可关闭资源(连接、流、会话)
  • 跨异步链路的业务状态
  • 需要长期缓存的数据

2. 永远配对:set/get/remove

推荐固定模板:

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

如果你在 code review 里看到 set() 没有在同一作用域内配到 finally remove(),就应该警觉。

3. 优先封装,不要散点使用

比如封装成:

  • ContextHolder.runWithContext(...)
  • ContextAwareExecutor
  • Filter/Interceptor 基类
  • AOP 统一切面

这样能把“清理责任”从业务代码中剥离出来。

4. 跨线程时优先显式传参

线程切换以后,ThreadLocal 的语义天然变弱。
如果链路中有:

  • ExecutorService
  • CompletableFuture
  • MQ 消费线程
  • 定时任务线程
  • Reactor / 异步框架

都要优先思考:是不是该传参,而不是 ThreadLocal?

5. 如果用了日志 MDC,也要同样清理

很多日志框架 MDC 底层也是 ThreadLocal 思路。
所以这类代码同样要注意:

try {
    MDC.put("traceId", traceId);
    // ...
} finally {
    MDC.clear();
}

别只清自己的 ThreadLocal,忘了清日志上下文。

6. 做压测时加“串值检测日志”

我很建议在测试环境加一些断言式日志,例如:

  • 当前线程名
  • 当前用户 ID
  • 当前 traceId
  • 请求入口参数

如果一个请求里打印出不属于自己的上下文,很快就能暴露问题。


flowchart LR
    A[提交任务前获取上下文] --> B[包装 Runnable/Callable]
    B --> C[工作线程执行前 set]
    C --> D[执行业务逻辑]
    D --> E[finally 中 remove/clear]
    E --> F[线程归还线程池]

一份实用检查清单

如果你准备对现有项目做一次 ThreadLocal 风险排查,可以直接按这份清单过:

代码检查

  • 是否存在 ThreadLocal / InheritableThreadLocal
  • 每个 set() 后是否都有 finally remove()
  • 是否在线程池任务中使用了 ThreadLocal
  • 是否存在异步执行但未做上下文传递
  • 是否把大对象或资源对象放入 ThreadLocal
  • 是否同时使用了 MDC 但未清理

运行时检查

  • 线程池线程是否长期驻留
  • traceId / userId / tenantId 是否偶发串值
  • heap dump 中是否存在 ThreadLocalMap 持有大对象
  • 问题是否在高并发下更明显
  • 重启后是否短暂恢复正常

总结

ThreadLocal 本身不是洪水猛兽,真正的坑在于:它和线程生命周期绑得太紧,而线程池又让线程活得太久。

所以一旦在线程池中使用 ThreadLocal,你要牢牢记住三件事:

  1. 它不会自动清理
  2. 它不会自动正确跨线程传播
  3. 线程复用会把问题放大成串值和泄漏

最实用的落地建议是:

  • 所有 ThreadLocal 访问都采用 try/finally/remove
  • 在线程池入口统一包装上下文设置与清理
  • 跨线程场景优先显式传参
  • 不要把大对象、连接、流放进 ThreadLocal
  • 对日志 MDC、租户上下文、用户上下文做统一治理

如果你现在就要做一次排查,我建议先从这几个类开始搜:

  • UserContext
  • TraceContext
  • TenantContext
  • DataSourceContextHolder
  • 所有自定义线程池包装器

很多线上“偶发串数据”的根因,最后都会落到一句很朴素的话上:
ThreadLocal 用完没 remove。

这句话听着简单,但真在线上踩到一次,你就会记很久。


分享到:

上一篇
《Spring Boot 中基于 Spring Cache + Redis 的多级缓存实战:一致性、穿透击穿防护与性能调优》
下一篇
《Spring Boot 中基于 Spring Cache 与 Redis 的多级缓存实战:一致性、穿透与热点 Key 处理》