ThreadLocal 内存泄漏:`Entry` 的 key 都用弱引用了,为什么还会泄漏

前言

ThreadLocal 是个好东西:给每个线程一份独立的变量副本,天然线程隔离,常用来存用户上下文、事务、SimpleDateFormat 这类"每线程一份"的东西。

但它有个著名的坑:用不好会内存泄漏。 更让人困惑的是,很多人知道"ThreadLocalMapEntry 用了弱引用来防泄漏",于是产生一个疑问——既然都用弱引用了,为什么还会泄漏?

这正是这篇文章要讲清楚的。弱引用确实解决了一半问题,但另一半(value)它管不到,而线程池又把这个隐患放大成了实实在在的线上事故。

环境说明:本文基于 JDK 8。涉及的引用类型、GC 概念,属于 Java 内存管理基础。


一、先复现:一个会让内存慢慢涨的接口

设想一个 Web 接口,用 ThreadLocal 缓存一个比较大的上下文对象:

public class UserContextHolder {
    private static final ThreadLocal<UserContext> CONTEXT = new ThreadLocal<>();

    public static void set(UserContext ctx) { CONTEXT.set(ctx); }
    public static UserContext get() { return CONTEXT.get(); }
    // 注意:这里没有提供 remove(),也没人调用
}

// 拦截器里,每个请求进来就 set 一份
public class ContextInterceptor implements HandlerInterceptor {
    public boolean preHandle(HttpServletRequest req, ...) {
        UserContext ctx = buildContext(req);   // 假设这个对象不小
        UserContextHolder.set(ctx);
        return true;
    }
    // 请求结束后,没有 remove
}

这段代码功能上完全正常,测试也没问题。但把它放到生产环境,用 Tomcat 默认的线程池跑一段时间后,你会观察到:堆内存缓慢但持续地增长,老年代越堆越满,最终频繁 Full GC 甚至 OOM。

问题在于:ThreadLocal 用完了没有 remove(),而线程池里的线程一直活着不销毁,那些 UserContext 对象就一直被"挂"在线程上,GC 回收不掉。

要理解为什么,得先看 ThreadLocal 的存储结构。


二、根因/底层:弱引用只保护了 key,value 没人管

2.1 数据到底存在哪:不是存在 ThreadLocal 里

第一个反直觉的点:ThreadLocal.set(value) 的值,并不存在 ThreadLocal 对象里,而是存在"当前线程"身上。

每个 Thread 对象内部有一个字段 threadLocals,类型是 ThreadLocal.ThreadLocalMap。你调用 threadLocal.set(value) 时,实际是以这个 ThreadLocal 实例为 key、你的 value 为 value,存进了当前线程的那个 ThreadLocalMap

Thread(线程对象)
  └─ threadLocals: ThreadLocalMap
        └─ Entry[]  (每个 Entry 是一个 key-value 对)
              key   = ThreadLocal 实例(弱引用)
              value = 你 set 进去的值(强引用)

这个设计的好处是天然隔离:不同线程有各自的 ThreadLocalMap,互不干扰。

在这里插入图片描述

2.2 关键:Entry 的 key 是弱引用,value 是强引用

ThreadLocalMap 里的 Entry 定义是这样的(简化):

static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;
    Entry(ThreadLocal<?> k, Object v) {
        super(k);      // key(ThreadLocal)作为弱引用
        value = v;     // value 是强引用,普通字段
    }
}

注意这个不对称的设计:

  • key(ThreadLocal 实例)是弱引用Entry extends WeakReference<ThreadLocal>,key 被弱引用持有。
  • value(你 set 的值)是强引用value 就是个普通字段,被 Entry 强引用着。

为什么 key 要用弱引用?就是为了防泄漏:当外部不再引用这个 ThreadLocal 时(比如 ThreadLocal 变量被置空或超出作用域),弱引用不阻止 GC,key 就能被回收掉,Entry 的 key 变成 null

设计者的本意是好的,但问题恰恰出在这个"一半弱、一半强"上。

2.3 泄漏是怎么发生的:key 没了,value 还在

设想这样一条引用链,当外部对 ThreadLocal 的强引用消失后:

  • key 是弱引用 → GC 时被回收Entry 的 key 变成 null
  • 但 value 是强引用,它的引用链是:ThreadThreadLocalMapEntryvalue

只要线程还活着,这条强引用链就一直在,value永远回收不掉。结果就是 ThreadLocalMap 里出现一堆 key 为 null、value 还占着内存的"僵尸 Entry"——这就是内存泄漏

在这里插入图片描述

2.4 为什么线程池让问题致命

如果是普通线程,用完就结束,Thread 对象被回收,它的 ThreadLocalMap 连同里面所有 Entry、value 一起被回收,泄漏也就"自愈"了——所以短生命周期的线程,问题不明显。

线程池里的线程是复用的、长期存活的。一个线程处理完请求 A,不会销毁,而是回到池里等着处理请求 B、C、D……它的 ThreadLocalMap 一直存在。于是:

  • 每个请求 set 一个 UserContext,用完不 remove
  • 线程不死,ThreadLocalMap 不释放,僵尸 Entry(或旧 value)越积越多;
  • 内存持续增长,最终 OOM。

“ThreadLocal + 线程池 + 忘记 remove” 是内存泄漏的黄金三角。 这也是为什么这个坑在 Web 应用(Tomcat 线程池)里特别常见。

JDK 其实做了点"补救":ThreadLocalMapset/get/remove 时,会顺带清理一些 key 为 null 的僵尸 Entry(探测式清理)。但这个清理是"碰运气"的、不彻底的,绝不能依赖它。根治办法只有一个:手动 remove


三、正解:用完一定 remove(),最好放在 finally

根治方案非常简单:每次用完 ThreadLocal,显式调用 remove() remove() 会把当前线程 ThreadLocalMap 里对应的整个 Entry(key 和 value)都删掉,斩断强引用链。

关键是要保证 remove() 一定被执行,所以放在 finally 里:

public boolean preHandle(HttpServletRequest req, ...) {
    UserContextHolder.set(buildContext(req));
    return true;
}

// 在请求结束的回调里 remove(Spring 的 afterCompletion)
public void afterCompletion(HttpServletRequest req, ...) {
    UserContextHolder.remove();   // ✓ 请求结束,清理
}

或者在业务代码里用标准的 try-finally 包裹:

try {
    UserContextHolder.set(ctx);
    doBusiness();
} finally {
    UserContextHolder.remove();   // ✓ 无论是否异常,都清理
}

finally 里做清理,正是它的正确用法——可参考上一篇《try-finally 里的 return》。)

几个补充实践:

  • 拦截器/过滤器场景:在 afterCompletionfinally 里统一 remove,别依赖 JDK 的探测式清理。
  • ThreadLocal 声明为 static final:让它跟随类存在,避免它被意外回收(其实反而是"key 不该被过早回收"),也便于统一管理。注意这和"防泄漏"不矛盾——防泄漏靠的是 remove,不是让 key 被回收。
  • 父子线程传递用 InheritableThreadLocal,但线程池下要谨慎:线程复用会导致继承的值错乱,阿里的 TransmittableThreadLocal(TTL)是更完善的方案。

在这里插入图片描述


四、常见误区与面试高频问答

Q:Entry 的 key 用了弱引用,不就是为了防泄漏吗,为什么还漏?

弱引用只解决了 key(ThreadLocal 实例) 的回收,让没人引用的 ThreadLocal 能被 GC。但 value 是强引用,它通过 Thread → ThreadLocalMap → Entry → value 这条链被线程强引用着,只要线程活着就回收不掉。弱引用防了 key,防不了 value——这才是泄漏的根源。

Q:那 key 为什么不干脆也用强引用,或者 value 也用弱引用?

key 用强引用会更糟:ThreadLocalMap 会强引用 ThreadLocal,导致 ThreadLocal 实例本身也回收不掉,泄漏更严重。value 用弱引用又不行:value 通常没有其他强引用,一 GC 就没了,ThreadLocal 就存不住值了。所以现在这个"key 弱、value 强"是权衡后的设计,代价就是需要你手动 remove

Q:JDK 不是会自动清理 null key 的 Entry 吗?

会,但不可靠。set/get/remove 时会触发探测式/启发式清理,顺路清掉一些 key 为 null 的 Entry。但它只清理"碰到的"部分槽位,不保证全清,更不会主动触发。如果后续不再调用这个 ThreadLocal 的方法,僵尸 Entry 就一直留着。不能依赖它,必须手动 remove

Q:为什么普通线程没事,线程池才严重?

普通线程执行完就销毁,Thread 及其 ThreadLocalMap 整个被回收,泄漏自动消失。线程池的线程长期复用、不销毁,ThreadLocalMap 一直存在,不 remove 的话 value 越积越多,泄漏就暴露了。

Q:remove()set(null) 一样吗?

不一样。set(null) 只是把 value 设为 nullEntry 本身(key 和这个 null value)还留在 map 里,是"半清理"。remove() 会把整个 Entry 从 map 中删除,才是彻底清理。要 remove()


总结

“ThreadLocal 的 key 是弱引用,为什么还泄漏”,答案在那个不对称的设计里:

  • ThreadLocal 的值存在线程ThreadLocalMap 里,Entrykey(ThreadLocal)是弱引用、value(你的值)是强引用
  • 弱引用让没人用的 key 能被 GC 回收(key 变 null),但 value 仍被 Thread → ThreadLocalMap → Entry → value 强引用链拴着,只要线程活着就回收不掉,形成"key 为 null、value 常驻"的僵尸 Entry。
  • 线程池里线程长期复用、不销毁,把这个隐患放大成持续的内存泄漏,直至 OOM。
  • 根治办法只有一个:用完 remove(),并放在 finally/afterCompletion 里确保执行。别指望 JDK 的探测式清理。

一句话记忆: 弱引用只保护 key,value 是强引用、被活着的线程拴着回收不掉;ThreadLocal + 线程池 + 忘记 remove = 内存泄漏,用完必须 remove()

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Leighteen

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值