环境: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 是弱引用"不够。更完整的回答是:
-
值存在线程的
ThreadLocalMap中。 -
key 是
ThreadLocal的弱引用,value 仍然是强引用。 -
线程池中的线程长期存活时,key 被回收也不代表 value 立即消失。
-
并发场景下还会出现请求之间的数据串用。
-
业务侧应在
finally中调用remove(),并解释清楚清理点。
如果继续追问,还可以讨论 ThreadLocalMap 的开放地址法、扩容和陈旧 entry 清理,但不要忽略最直接的应用层问题:任务边界没有清理。
总结
ThreadLocal 不是把数据绑定在"当前请求"上,而是绑定在"当前线程"上。线程池复用线程以后,没有清理的上下文就可能跟到下一个任务。
排查这类问题,我会先记录线程名和上下文值,确认是否发生了线程复用;修复时把 remove() 放进最外层 try/finally,再检查异步任务和子线程是否绕过了清理入口。
真正需要记住的不是一句"记得 remove",而是数据边界由谁负责。线程从哪里被复用,清理就应该从哪里收口。
参考
标签:Java、ThreadLocal、线程池、并发编程、后端开发