【JUC】Thread 内存泄露

ThreadLocal 的内存泄露,根源不在于"弱引用",而在于 "线程生命周期过长"与"过期条目未被主动清理"的叠加 。弱引用是 JDK 提供的兜底优化,它让 key 能被 GC 回收,但 value 的强引用使得过期 Entry 无法自动消失,必须依赖后续操作触发清理或显式调用 remove()。


数据结构与引用链:泄露的结构性前提

每个 Thread 对象内部持有 threadLocals 字段,指向一个 ThreadLocalMap。ThreadLocalMap 内部是一个 Entry 数组,Entry 继承自 WeakReference<ThreadLocal<?>>。

java 复制代码
static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;
    Entry(ThreadLocal<?> k, Object v) {
        super(k);    // key 是弱引用,指向 ThreadLocal 实例
        value = v;   // value 是强引用
    }
}

由此形成两条截然不同的引用路径:

  • key 路径 :Entry → WeakReference.referent → ThreadLocal,弱引用。
  • value 路径 :Thread → ThreadLocalMap → Entry[] → Entry → value,全程强引用。

关键点在于:ThreadLocalMap 的生命周期依附于 Thread。如果线程长期存活(例如线程池核心线程),ThreadLocalMap 就不会被回收,其中的 Entry 数组也会一直存在。只要 Entry 还在,它对 value 的强引用就始终有效,即使 key 已经被 GC 回收为 null。


泄露如何发生:key 变 null 之后

当外部不再持有对某个 ThreadLocal 实例的强引用时(例如方法结束、或仅被 ThreadLocalMap 以弱引用持有),下一次 GC 会回收该 ThreadLocal 实例。Entry.get() 随之返回 null,这个 Entry 成为所谓的 "stale entry"(过期条目)。

此时:

  • key 侧:ThreadLocal 已被回收,Entry 不再持有对它的有效引用。
  • value 侧:Entry.value 仍然被 Entry 强引用,而 Entry 又挂在长生命周期线程的 ThreadLocalMap 上。

如果此后该线程不再调用 与这个 ThreadLocalMap 相关的 get()、set() 或 remove(),JDK 内部的清理逻辑就没有机会运行,value 对应的对象将一直无法被 GC 回收。这就是内存泄露的完整路径。


源码层的清理机制:为什么弱引用不够

JDK 并非对过期条目完全放任。ThreadLocalMap 在 set()、get()、remove() 等操作中嵌入了清理逻辑,主要有两种。

探测式清理:expungeStaleEntry(int staleSlot)

这是最核心的清理方法。它从 staleSlot 位置开始,清除该位置的过期 Entry,然后向后遍历,直到遇到 null 槽位为止 ,沿途清除所有 key 为 null 的过期条目,并对仍然有效的条目重新哈希归位。

java 复制代码
private int expungeStaleEntry(int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;

    // 清除 staleSlot 处的过期 Entry
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;

    Entry e;
    int i;
    for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();
        if (k == null) {
            // 沿途的过期 Entry,同样断开 value 引用
            e.value = null;
            tab[i] = null;
            size--;
        } else {
            // 有效 Entry 重新哈希归位
            int h = k.threadLocalHashCode & (len - 1);
            if (h != i) {
                tab[i] = null;
                while (tab[h] != null) h = nextIndex(h, len);
                tab[h] = e;
            }
        }
    }
    return i;
}

注意循环的终止条件:(e = tab[i]) != null。一旦遇到 null 槽位,清理就停止 。如果 stale entry 分散在数组中,并且某个 null 槽位之后仍然存在其他 stale entry,那些位于 null 之后的过期条目在这次调用中不会被清理 。这意味着清理是局部性的,不是全局扫描。

启发式清理:cleanSomeSlots(int i, int n)

cleanSomeSlots 以"扫描 log₂(n) 次"的方式启发式地尝试清理。它从位置 i 开始,每隔一定步数检查并清理 stale entry,但不保证覆盖整个表。如果在扫描窗口内没有发现 stale entry,清理就提前结束。这是一种概率性、非强制性 的清理策略,目的是在性能与清理效果之间取得平衡,不能替代显式 remove()。

插入时的特殊处理:replaceStaleEntry

当 set() 过程中在线性探测路径上遇到 key 为 null 的槽位时,会调用 replaceStaleEntry。该方法会在 stale slot 之前 寻找是否已有相同 key 的有效 Entry,如果有则交换位置并更新 value;如果没有,则用新 Entry 占据该 stale slot,并尽可能清理沿途的过期条目。这保证了在发生 stale 命中的插入路径上,至少会清理掉触发本次操作的那个 stale entry 以及一定范围内的其他 stale entry,但仍然不保证全局清理。

小结:弱引用的定位

弱引用让 key 能在外部强引用消失后被 GC 回收,使 Entry 有机会被识别为 stale entry,从而进入清理逻辑的"视野"。但弱引用本身不删除 Entry,也不断开对 value 的强引用 。清理是触发式、局部性、非强制性 的。只要后续没有任何操作触发清理,value 就会一直保留。因此,弱引用是降低风险的兜底措施,而不是防止泄露的充分条件。


线程池:把"条件"变成"常态"

在普通短生命周期线程中,线程结束后 ThreadLocalMap 随线程一起被回收,即使没有 remove(),泄露窗口也有限。

线程池改变了一切:核心线程默认长期存活,ThreadLocalMap 在线程复用的整个生命周期内持续存在。每一次任务中通过 ThreadLocal.set() 写入的 value,如果没有在任务结束时清理,都会挂在线程的 ThreadLocalMap 上。随着任务不断提交,同一个线程的 ThreadLocalMap 中可能累积多个不同 ThreadLocal 留下的过期 value (如果 ThreadLocal 实例本身在任务结束后失去强引用),或者(更常见地)同一个 ThreadLocal 的 value 被子sequent 任务反复覆盖,但上一次的 value 对象因为不再被引用本应可回收,却因其他 stale entry 的存在而增加了 map 的整体压力 ;更严重的是,如果 ThreadLocal 是每次任务 new 出来的临时对象,其 value 在 key 被 GC 后会成为无人认领的 stale value,在线程存活期间持续占用内存。

线程池场景真正放大的不是"单次泄露的大小",而是泄露持续的时间尺度:只要线程不死,泄露就不会因线程终止而自然消失。


根治方案与工程实践

强制规范:finally 中 remove()

唯一能确定地、立即地 断开 Thread → ThreadLocalMap → Entry → value 整条强引用链的操作,就是显式调用 remove()。remove() 会定位到对应 Entry,调用 e.clear() 清除 key 的弱引用,然后执行 expungeStaleEntry(i),将该位置的 value 置为 null,Entry 置为 null,size--。

java 复制代码
try {
    threadLocal.set(value);
    // 业务逻辑
} finally {
    threadLocal.remove();
}

remove() 同时解决了两个问题:内存泄露 和线程池中的脏数据串用 。没有 remove(),复用的线程可能让下一个任务读到上一个任务残留的 value。

声明为 private static final

将 ThreadLocal 声明为 static final,使 ThreadLocal 实例本身的生命周期与类一致,key 不会因外部强引用消失而被 GC 回收 ,从而避免了"key 变 null、value 悬空"这一种 stale entry 的产生路径。但这不替代 remove() :value 仍然可能因未清理而在线程池线程上持续保留,尤其是大对象 value 或每次任务写入新 value 的场景。static final 降低的是 key 侧的风险,value 侧的泄露仍需 remove() 来根治。

工具封装与架构级约束

依赖每个开发者手动写 finally { remove(); } 是脆弱的。工程上更可靠的做法是将 ThreadLocal 的写入与清理封装在框架层:

  • 拦截器 / Filter / AOP :在请求或任务入口处 set(),在出口处统一 remove(),业务代码不直接触碰 ThreadLocal 的生命周期。
  • 上下文持有者类 :只暴露 set/get/remove 静态方法,将 ThreadLocal 字段私有化,避免散落各处的直接操作。
  • 线程池场景的上下文传递 :如果需要在父子线程或线程池任务间传递上下文,标准 ThreadLocal 并不适合(InheritableThreadLocal 在线程池复用场景下也会失效),应考虑 TransmittableThreadLocal(TTL)等专门方案,但它解决的是传递问题,清理责任依然存在。

一句话总结 :ThreadLocal 的内存泄露不是"弱引用写错了",而是长生命周期线程 + 过期 Entry 未被强制清理 的必然结果。弱引用只负责让 key 可回收,不负责让 value 释放;remove() 才是断开强引用链、同时消除泄露与脏数据的确定性手段。

相关推荐
freejackman1 小时前
【架构篇】双遍历 + 多栈协同:手撸 SQL 血缘引擎的架构思考
java·大数据·数据库·hive·sql·架构·spark
企业数字化笔记1 小时前
业务单据怎样接入流程中心?启动条件、实例关联与失败回退
java·spring boot·mysql
挖掘狂人1 小时前
同一个端口能不能被两个程序同时监听?
java·后端·程序员
codigger1 小时前
同一个端口能不能被两个程序同时监听?
java·后端·程序员·编程
西索斯coding1 小时前
qwen3.8-max-0902 和 minimax-m3 改真实仓库差多少?2026 年 50 个缺陷复现实测框架
java·服务器·网络·ai
2501_933923251 小时前
SpringCloud:OpenFeign快速实现
java·后端·spring cloud·openfeign
denlaku2 小时前
JDK 26 新特性详解
java
SL_staff2 小时前
3分钟完成业务规则调整:告别代码修改,实现拖拽式逻辑配置
java·低代码·全栈
Flynn Liang2 小时前
让 AI 查订单、查库存、发消息:Spring AI 自定义工具(Tool)实战与工具描述工程
java·人工智能·spring·函数调用·springai·tool·自定义工具