
环境JDK 17.0.12Windows 11。文中的线程池实验只使用 JDK 标准库已经在本机编译运行。快速认识一个请求进来时拦截器把当前用户写进ThreadLocal。请求结束后没有清理。下一个请求进来业务代码读取这个ThreadLocal拿到的却是上一个人的数据。问题不在“变量忘了赋值”而在线程池复用了同一个线程。ThreadLocal的值挂在线程自己维护的ThreadLocalMap上请求对象结束了工作线程还活着。只要这个线程没有执行remove()旧值就还在。修复方式不复杂请求结束时一定要在finally中清理。try { UserContext.set(user); chain.doFilter(request, response); } finally { UserContext.clear(); }clear()最终应该调用ThreadLocal.remove()。下面用最小实验把数据串用过程复现出来再看remove()到底清掉了什么。拓展1. ThreadLocal 的值并没有存在 ThreadLocal 对象里很多误解来自类名。ThreadLocal更像一把“当前线程的钥匙”数据实际放在线程对象的ThreadLocalMap中。JDK 17 的ThreadLocal.get()会先取得当前线程的 mapThreadLocalMap m getMap(Thread.currentThread());remove()的逻辑也很直接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 在同一个线程对象上读取。import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; public class ThreadLocalPoolLeakDemo { private static final ThreadLocalString 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 { FutureString 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()); FutureString 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; } }编译和运行javac -encoding UTF-8 -Xlint:all ThreadLocalPoolLeakDemo.java java ThreadLocalPoolLeakDemo本机输出removeAfterRequestfalse A stores threadrequest-worker-1 valuealice B reads threadrequest-worker-1 valuealice removeAfterRequesttrue A stores threadrequest-worker-1 valuealice B reads threadrequest-worker-1 valuenull两次读取的线程名相同说明线程对象被复用了。第一个场景中任务 A 已经结束但任务 B 仍然读到了alice。第二个场景在任务 A 结束时调用remove()任务 B 读到的就是null。这段输出也解释了一个排查细节如果日志里只打印请求 ID代码看起来没问题只有把线程名和上下文值一起打出来才会发现第二个请求接收了第一个请求的残留状态。3. 数据串用和内存泄漏是两个问题线程池里不清理ThreadLocal至少有两类风险。问题发生条件直接后果处理方式数据串用同一个工作线程处理多个任务旧值仍存在下个请求读到上个请求的用户、租户或追踪信息在任务边界执行remove()内存保留线程长期存活value 仍被 map 引用大对象不能及时回收内存占用偏高在finally中清理必要时排查 map 中的残留key 是弱引用并不等于 value 会自动消失。ThreadLocal对象失去外部强引用后key 可以被回收但 value 仍可能被Entry引用。线程池里的线程往往活得很久这部分数据就可能一直留下来。所以“用完 remove”不是只为了防止内存泄漏。对于用户上下文、租户标识、链路信息这类数据它首先是在保证请求之间的隔离。4. 清理应该放在哪里请求链路的清理点通常放在最外层拦截器、过滤器或任务包装器中public final class UserContext { private static final ThreadLocalLong 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 成对出现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”而是数据边界由谁负责。线程从哪里被复用清理就应该从哪里收口。参考OpenJDK 17 ThreadLocal.javaJava 17 ThreadLocal APIJava 17 Thread API标签Java、ThreadLocal、线程池、并发编程、后端开发