先看 ThreadLocal 的存储结构ThreadLocal 本身不存数据数据存在每个Thread对象里的一个ThreadLocalMap字段上。ThreadLocalMap是ThreadLocal的内部类结构类似 HashMapkey 是ThreadLocal实例的弱引用value 是你放进去的对象。Thread └── ThreadLocalMap ├── Entry(WeakRefThreadLocal1, value1) ├── Entry(WeakRefThreadLocal2, value2) └── ...这里有个关键设计key 是弱引用value 是强引用。泄漏发生的条件泄漏需要同时满足两件事条件一ThreadLocal 实例本身不再被强引用。// 这个 ThreadLocal 定义为局部变量方法返回后就没有强引用了 public void doSomething() { ThreadLocalBigObject tl new ThreadLocal(); tl.set(new BigObject()); // ... 方法结束tl 这个局部变量消失ThreadLocal 实例只剩 ThreadLocalMap 里的弱引用 }static 字段持有的 ThreadLocal 不会出现这种情况因为 static 字段的强引用一直在。条件二这个线程没有结束还在被复用。这就是为什么泄漏主要发生在线程池里。线程池里的线程不会结束Thread对象一直存活它里面的ThreadLocalMap也一直存活。把两个条件合在一起看ThreadLocal 实例被 GC 了对应的 Entry 的 key 变成 null但 Entry 本身还挂在 ThreadLocalMap 里value 里的对象被 Entry 强引用着GC 回收不了。线程活多久这个对象就占多久内存。如果线程池有 200 个线程每次处理请求都往 ThreadLocal 里放了一个 200KB 的对象处理完没有removeThreadLocal 实例也被 GC 了那 ThreadLocalMap 里就会慢慢积累一批 key 为 null 的 Entry每个 Entry 的 value 都挂着 200KB 的数据不会被回收。为什么 WeakReference 没有解决这个问题弱引用只解决了 key 的问题ThreadLocal 实例本身可以被 GC 回收不会因为 ThreadLocalMap 里的引用而活着。但 value 还是强引用。key 被 GC 后value 就成了孤儿——没有任何途径从外部访问到它也没有任何东西会主动清理它只有等这个线程的ThreadLocalMap被整体回收即线程结束。ThreadLocal 自己做了一些防御get()、set()、remove()的时候会顺带清理 key 为 null 的 EntryexpungeStaleEntry。但这是被动清理且不是每次都触发依赖概率不能依赖它来防泄漏。正确的用法用完显式调用remove()放在finally块里确保执行private static final ThreadLocalRequestContext CONTEXT new ThreadLocal(); public void handleRequest(Request request) { try { CONTEXT.set(new RequestContext(request)); doWork(); } finally { CONTEXT.remove(); // 必须在 finally 里保证异常时也执行 } }Web 框架里常见的 MDCMapped Diagnostic Context也要记得清理// 在 Filter 或 Interceptor 里 try { MDC.put(requestId, UUID.randomUUID().toString()); MDC.put(userId, String.valueOf(userId)); chain.doFilter(request, response); } finally { MDC.clear(); // 底层也是 ThreadLocal }MDC 很容易被忽略。很多团队用了 MDC 打 traceId但没有在请求处理完之后 clear线程池里的线程带着上一个请求的 MDC 数据处理下一个请求日志里的 traceId 对不上但很难察觉。常见的踩坑场景用 Spring 的Async方法时调用方线程里的 ThreadLocal 数据不会自动传递到新线程。需要显式传递Async public void asyncTask() { // 这里拿不到调用方线程里的 ThreadLocal 数据 // 如果需要要在提交任务时手动把数据传过来 }如果业务上确实需要跨线程传递上下文比如 traceId 要传到异步线程用InheritableThreadLocal——子线程创建时会从父线程复制一份数据。但在线程池场景里线程不是每次都新建InheritableThreadLocal 的复制逻辑不会触发仍然需要手动处理。Alibaba 开源的transmittable-thread-localTTL解决了这个问题在Runnable/Callable被提交到线程池时捕获当前线程的上下文在任务执行时恢复执行完清理。如果系统里有大量跨线程传递上下文的需求TTL 是更可靠的方案。
ThreadLocal 用了 WeakReference,为什么还会内存泄漏
发布时间:2026/5/26 19:41:53
先看 ThreadLocal 的存储结构ThreadLocal 本身不存数据数据存在每个Thread对象里的一个ThreadLocalMap字段上。ThreadLocalMap是ThreadLocal的内部类结构类似 HashMapkey 是ThreadLocal实例的弱引用value 是你放进去的对象。Thread └── ThreadLocalMap ├── Entry(WeakRefThreadLocal1, value1) ├── Entry(WeakRefThreadLocal2, value2) └── ...这里有个关键设计key 是弱引用value 是强引用。泄漏发生的条件泄漏需要同时满足两件事条件一ThreadLocal 实例本身不再被强引用。// 这个 ThreadLocal 定义为局部变量方法返回后就没有强引用了 public void doSomething() { ThreadLocalBigObject tl new ThreadLocal(); tl.set(new BigObject()); // ... 方法结束tl 这个局部变量消失ThreadLocal 实例只剩 ThreadLocalMap 里的弱引用 }static 字段持有的 ThreadLocal 不会出现这种情况因为 static 字段的强引用一直在。条件二这个线程没有结束还在被复用。这就是为什么泄漏主要发生在线程池里。线程池里的线程不会结束Thread对象一直存活它里面的ThreadLocalMap也一直存活。把两个条件合在一起看ThreadLocal 实例被 GC 了对应的 Entry 的 key 变成 null但 Entry 本身还挂在 ThreadLocalMap 里value 里的对象被 Entry 强引用着GC 回收不了。线程活多久这个对象就占多久内存。如果线程池有 200 个线程每次处理请求都往 ThreadLocal 里放了一个 200KB 的对象处理完没有removeThreadLocal 实例也被 GC 了那 ThreadLocalMap 里就会慢慢积累一批 key 为 null 的 Entry每个 Entry 的 value 都挂着 200KB 的数据不会被回收。为什么 WeakReference 没有解决这个问题弱引用只解决了 key 的问题ThreadLocal 实例本身可以被 GC 回收不会因为 ThreadLocalMap 里的引用而活着。但 value 还是强引用。key 被 GC 后value 就成了孤儿——没有任何途径从外部访问到它也没有任何东西会主动清理它只有等这个线程的ThreadLocalMap被整体回收即线程结束。ThreadLocal 自己做了一些防御get()、set()、remove()的时候会顺带清理 key 为 null 的 EntryexpungeStaleEntry。但这是被动清理且不是每次都触发依赖概率不能依赖它来防泄漏。正确的用法用完显式调用remove()放在finally块里确保执行private static final ThreadLocalRequestContext CONTEXT new ThreadLocal(); public void handleRequest(Request request) { try { CONTEXT.set(new RequestContext(request)); doWork(); } finally { CONTEXT.remove(); // 必须在 finally 里保证异常时也执行 } }Web 框架里常见的 MDCMapped Diagnostic Context也要记得清理// 在 Filter 或 Interceptor 里 try { MDC.put(requestId, UUID.randomUUID().toString()); MDC.put(userId, String.valueOf(userId)); chain.doFilter(request, response); } finally { MDC.clear(); // 底层也是 ThreadLocal }MDC 很容易被忽略。很多团队用了 MDC 打 traceId但没有在请求处理完之后 clear线程池里的线程带着上一个请求的 MDC 数据处理下一个请求日志里的 traceId 对不上但很难察觉。常见的踩坑场景用 Spring 的Async方法时调用方线程里的 ThreadLocal 数据不会自动传递到新线程。需要显式传递Async public void asyncTask() { // 这里拿不到调用方线程里的 ThreadLocal 数据 // 如果需要要在提交任务时手动把数据传过来 }如果业务上确实需要跨线程传递上下文比如 traceId 要传到异步线程用InheritableThreadLocal——子线程创建时会从父线程复制一份数据。但在线程池场景里线程不是每次都新建InheritableThreadLocal 的复制逻辑不会触发仍然需要手动处理。Alibaba 开源的transmittable-thread-localTTL解决了这个问题在Runnable/Callable被提交到线程池时捕获当前线程的上下文在任务执行时恢复执行完清理。如果系统里有大量跨线程传递上下文的需求TTL 是更可靠的方案。