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() 才是断开强引用链、同时消除泄露与脏数据的确定性手段。