线程池里的“幽灵数据”:ThreadLocal 用完不 remove,为什么下个请求还能看到?

环境:JDK 17.0.12,Windows 11。文中的线程池实验只使用 JDK 标准库,已经在本机编译运行。

快速认识

一个请求进来时,拦截器把当前用户写进 ThreadLocal。请求结束后没有清理。下一个请求进来,业务代码读取这个 ThreadLocal,拿到的却是上一个人的数据。

问题不在"变量忘了赋值",而在线程池复用了同一个线程。ThreadLocal 的值挂在线程自己维护的 ThreadLocalMap 上,请求对象结束了,工作线程还活着。只要这个线程没有执行 remove(),旧值就还在。

修复方式不复杂:请求结束时一定要在 finally 中清理。

bash 复制代码
try {
    UserContext.set(user);
    chain.doFilter(request, response);
} finally {
    UserContext.clear();
}

clear() 最终应该调用 ThreadLocal.remove()。下面用最小实验把数据串用过程复现出来,再看 remove() 到底清掉了什么。

拓展

1. ThreadLocal 的值并没有存在 ThreadLocal 对象里

很多误解来自类名。ThreadLocal 更像一把"当前线程的钥匙",数据实际放在线程对象的 ThreadLocalMap 中。

JDK 17 的 ThreadLocal.get() 会先取得当前线程的 map:

bash 复制代码
ThreadLocalMap m = getMap(Thread.currentThread());

remove() 的逻辑也很直接:

bash 复制代码
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null) {
    m.remove(this);
}

这里的 this 是当前的 ThreadLocal 对象。也就是说,remove() 清理的是当前线程里这一个键的绑定,不是把所有线程的同类数据删掉,也不是简单地把一个全局变量置空。

ThreadLocalMap.Entry 的 key 是 ThreadLocal 的弱引用,value 是普通引用。key 的设计会影响对象回收,但业务代码最先遇到的往往不是 GC,而是线程复用后的旧值可见性。

2. 单线程池复现"下个请求看到上个请求"

下面的实验使用只有一个工作线程的线程池。任务 A 写入 alice,任务 B 在同一个线程对象上读取。

bash 复制代码
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
​
public class ThreadLocalPoolLeakDemo {
    private static final ThreadLocal<String> CURRENT_USER = new ThreadLocal<>();
​
    public static void main(String[] args) throws Exception {
        runScenario(false);
        runScenario(true);
    }
​
    private static void runScenario(boolean removeAfterRequest) throws Exception {
        ExecutorService pool = Executors.newSingleThreadExecutor(
                task -> new Thread(task, "request-worker-1"));
​
        try {
            Future<String> firstRequest = pool.submit(() -> {
                CURRENT_USER.set("alice");
                String result = describe("A stores", "alice");
                if (removeAfterRequest) {
                    CURRENT_USER.remove();
                }
                return result;
            });
            System.out.println("removeAfterRequest=" + removeAfterRequest);
            System.out.println(firstRequest.get());
​
            Future<String> secondRequest = pool.submit(
                    () -> describe("B reads", CURRENT_USER.get()));
            System.out.println(secondRequest.get());
        } finally {
            pool.shutdownNow();
        }
    }
​
    private static String describe(String action, String value) {
        return action + " thread=" + Thread.currentThread().getName()
                + " value=" + value;
    }
}

编译和运行:

bash 复制代码
javac -encoding UTF-8 -Xlint:all ThreadLocalPoolLeakDemo.java
java ThreadLocalPoolLeakDemo

本机输出:

bash 复制代码
removeAfterRequest=false
A stores thread=request-worker-1 value=alice
B reads thread=request-worker-1 value=alice
removeAfterRequest=true
A stores thread=request-worker-1 value=alice
B reads thread=request-worker-1 value=null

两次读取的线程名相同,说明线程对象被复用了。第一个场景中,任务 A 已经结束,但任务 B 仍然读到了 alice。第二个场景在任务 A 结束时调用 remove(),任务 B 读到的就是 null。

这段输出也解释了一个排查细节:如果日志里只打印请求 ID,代码看起来没问题;只有把线程名和上下文值一起打出来,才会发现第二个请求接收了第一个请求的残留状态。

3. 数据串用和内存泄漏是两个问题

线程池里不清理 ThreadLocal,至少有两类风险。

问题 发生条件 直接后果 处理方式
数据串用 同一个工作线程处理多个任务,旧值仍存在 下个请求读到上个请求的用户、租户或追踪信息 在任务边界执行 remove()
内存保留 线程长期存活,value 仍被 map 引用 大对象不能及时回收,内存占用偏高 在 finally 中清理,必要时排查 map 中的残留

key 是弱引用,并不等于 value 会自动消失。ThreadLocal 对象失去外部强引用后,key 可以被回收,但 value 仍可能被 Entry 引用。线程池里的线程往往活得很久,这部分数据就可能一直留下来。

所以"用完 remove"不是只为了防止内存泄漏。对于用户上下文、租户标识、链路信息这类数据,它首先是在保证请求之间的隔离。

4. 清理应该放在哪里

请求链路的清理点通常放在最外层拦截器、过滤器或任务包装器中:

bash 复制代码
public final class UserContext {
    private static final ThreadLocal<Long> USER_ID = new ThreadLocal<>();
​
    private UserContext() {
    }
​
    public static void set(Long userId) {
        USER_ID.set(userId);
    }
​
    public static Long get() {
        return USER_ID.get();
    }
​
    public static void clear() {
        USER_ID.remove();
    }
}

外层代码负责保证 set 和 clear 成对出现:

bash 复制代码
try {
    UserContext.set(loadUserId(request));
    businessService.handle(request);
} finally {
    UserContext.clear();
}

不要只清理当前业务代码判断会用到的那一个值。一个请求链路里可能同时存在用户 ID、租户 ID、语言、追踪 ID 等多个 ThreadLocal,应该由统一入口清理整套上下文。

异步任务要单独检查。线程池不会替你复制 ThreadLocal,也不保证回调线程仍然是原来的工作线程。新线程池中的任务如果需要用户上下文,要么在提交任务时把值作为参数传过去,要么使用成对封装的上下文传递方案,并在任务结束时清理。

5. 什么时候该用 ThreadLocal

适合它的场景有一个共同点:数据属于某个执行线程,而且调用链很深,显式一层层传参成本很高。

常见例子包括请求级用户上下文、链路追踪标识、日志 MDC,以及某些框架内部保存连接或事务状态。它不适合当缓存,也不适合用来绕开明确的参数设计。

如果两个线程需要共享数据,应该使用并发容器、锁或不可变对象传递。如果一层方法能直接接收参数,优先把参数写清楚,反而更容易测试和排查。

6. 面试里可以怎么回答

面试官问"ThreadLocal 为什么可能内存泄漏",只回答"key 是弱引用"不够。更完整的回答是:

  1. 值存在线程的 ThreadLocalMap 中。

  2. key 是 ThreadLocal 的弱引用,value 仍然是强引用。

  3. 线程池中的线程长期存活时,key 被回收也不代表 value 立即消失。

  4. 并发场景下还会出现请求之间的数据串用。

  5. 业务侧应在 finally 中调用 remove(),并解释清楚清理点。

如果继续追问,还可以讨论 ThreadLocalMap 的开放地址法、扩容和陈旧 entry 清理,但不要忽略最直接的应用层问题:任务边界没有清理。

总结

ThreadLocal 不是把数据绑定在"当前请求"上,而是绑定在"当前线程"上。线程池复用线程以后,没有清理的上下文就可能跟到下一个任务。

排查这类问题,我会先记录线程名和上下文值,确认是否发生了线程复用;修复时把 remove() 放进最外层 try/finally,再检查异步任务和子线程是否绕过了清理入口。

真正需要记住的不是一句"记得 remove",而是数据边界由谁负责。线程从哪里被复用,清理就应该从哪里收口。

参考

标签:Java、ThreadLocal、线程池、并发编程、后端开发

相关推荐
量化分析码农1 小时前
【Python量化系统工程实战 #02】每天手动拉数据太烦?用 APScheduler 搭一条「自动采集 + 增量去重」的流水线
后端
YYYing.2 小时前
【设计模式系列 (九) 】装饰器模式
c++·后端·设计模式·装饰器模式·c/c++
蜗牛互联网2 小时前
Gemini 4 Argon的1M输出窗口与长程Agent工程边界
java·人工智能·后端
蜗牛互联网3 小时前
从GitHub动态工作流理解确定性编排与Agent判断边界
java·人工智能·后端·github
鶴哥只手遮天3 小时前
深入理解OpenSceneGraph(五):插件生态与最佳实践
后端
.道阻且长.4 小时前
C++11 :新的类功能,lambda,包装器
开发语言·c++·后端
leo在掘金4 小时前
google/ax单日涨1379星:Agent编排运行时到底难在哪?
后端·架构
王中阳Go4 小时前
简历写「QPS 提升 3 倍」,面试官问「怎么压测的」,我卡在并发数怎么定
人工智能·后端·面试
柠檬味拥抱4 小时前
植物气孔开闭检测数据集 | 3600张YOLO植物生理数据集
后端