ThreadLocal 内存泄漏:为什么弱引用 key 没有解决问题?
一句话结论(30s)
ThreadLocal 泄漏的根因是 Entry.value 被强引用、而 Entry 挂在 Thread.threadLocals 里,因为线程池线程长期存活导致这条引用链永远不断、value 无法被 GC。关键设计是 key 用弱引用让 ThreadLocal 对象本身可被 GC,配合 get/set/remove 的惰性清理 expungeStaleEntry(含 rehash 维护开放地址法探测链)。权衡是弱引用只解决 key 不解决 value、惰性清理不可靠,唯一金标准是 finally 中手动 remove()。
核心原理(2min)
先留个悬念:key 都已经用了弱引用,为什么内存还会泄漏?弱引用不是「GC 时可回收」吗——问题恰恰出在 value 上,往下看。数据存在 Thread.threadLocals(ThreadLocalMap),ThreadLocal 只是 key。线程结束前 value 的强引用链一直在,线程池复用线程使泄漏累积。get/set/remove 会沿途清理 key==null 的 stale entry,并 rehash 后续 Entry 填补 null 槽、维护线性探测正确性;但未访问到的槽位不会被清理。因此必须 finally remove() 断链。
底层深入(5-10min)
先厘清:ThreadLocal 本身不存数据
Thread 对象 (GC Root)
→ threadLocals (ThreadLocal.ThreadLocalMap)
→ Entry[] table (开放地址法)
→ Entry.key = WeakReference<ThreadLocal<?>>
→ Entry.value = 业务数据 (强引用)
数据存在 Thread.threadLocals 里,ThreadLocal 只是一个 key。 每个线程有自己的 ThreadLocalMap,同一个 ThreadLocal 对象在不同线程的 Map 中对应不同位置,每个线程读到”属于自己的值”。
看 ThreadLocal 的 getMap / createMap,印证了”Map 挂在 Thread 上、ThreadLocal 只负责定位 Map”这个事实:
ThreadLocalMap getMap(Thread t) {
if (this instanceof TerminatingThreadLocal<T>) {
return t.terminatingThreadLocals();
} else {
return t.threadLocals();
}
}
void createMap(Thread t, T firstValue) {
var map = new ThreadLocalMap(this, firstValue);
if (this instanceof TerminatingThreadLocal<T>) {
t.setTerminatingThreadLocals(map);
} else {
t.setThreadLocals(map);
}
}
getMap 直接返回 t.threadLocals(),也就是说 ThreadLocalMap 是 Thread 对象的一个字段,生命周期完全由线程决定。线程还活着,这个 Map 就活着,Map 里存的所有 Entry 就都活着——这就是泄漏能”累积”的物理前提。
💭 思考:为什么 Map 要挂在 Thread 上,而不是反过来挂在 ThreadLocal 上?——如果挂在 ThreadLocal 上,一个 ThreadLocal 就得维护”所有线程 → 值”的 Map(Thread→V),多线程访问要加锁,还要处理线程退出时的清理。挂在 Thread 上,每个线程的 Map 完全私有、无锁,正常线程结束时整个 Map 随线程一起销毁,无需额外清理。但代价也在这:线程池复用线程时,Map 也跟着被复用、不清空,value 就一直累积——这正是本文”泄漏”的物理前提。设计换来了无锁和自动销毁,却埋下了线程池场景的隐患。
Entry:弱引用 key + 强引用 value 的矛盾结构
JDK 源码里 ThreadLocalMap 的内部类 Entry 长这样:
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
Entry extends WeakReference<ThreadLocal<?>>——继承自 WeakReference,把 super(k) 传进去的那个 k(ThreadLocal 对象)作为弱引用指向的 referent,而 value 是一个普通的强引用字段。这造成了一个”半身弱、半身强”的怪结构:key 会被 GC 回收,value 却不会。 只要 Thread → threadLocals → Entry[] table → Entry → value 这条强引用链不中断,value 就永远到不了回收器手里。
💭 思考:为什么不干脆把 value 也做成弱引用,一了百了?——value 是真正的业务数据,ThreadLocal 的语义就是”线程生命周期内持续持有这个值”。若 value 用弱引用,只要外部没别的强引用,它随时可能被 GC 回收,用户下一句
get()拿到的就是 null,业务直接出错。所以 value 必须强引用;弱 key + 强 value 是刻意的设计——让 key(ThreadLocal 对象)可回收、让 value 保持存活,而”value 迟迟不释放”正是这份刻意留下的代价,得靠 remove 主动买单。
弱引用 key 为什么没解决问题?
设计弱引用 key 的意图不是”防止泄漏”,而是”让 ThreadLocal 对象本身能被正常 GC”——如果不加弱引用,Map 会强持有 ThreadLocal,导致 ThreadLocal 对象的生命周期被 Map 绑架(线程不死、ThreadLocal 也回收不了)。加了弱引用后,ThreadLocal 对象一旦失去外部强引用就能被回收,Entry.key 自动变 null。
再追问一层:key 被 GC 回收后,value 会不会自动跟着释放?答案是不会。这只解决了 ThreadLocal 对象本身的回收。value 是强引用字段,和 key 的弱引用毫无关系:key 变 null 之后,value 依然被 Entry 强持有、Entry 依然被 table 数组强持有、table 依然被 Thread 强持有。线程不结束,value 就永远悬在那里,这就是泄漏的真正来源。
get() 的 map 查找:顺路清理 stale entry
ThreadLocal.get() 是三层委托:get() → get(Thread) → ThreadLocalMap.getEntry():
public T get() {
return get(Thread.currentThread());
}
private T get(Thread t) {
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T) e.value;
return result;
}
}
return setInitialValue(t);
}
真正的查找和清理发生在 getEntry 和 getEntryAfterMiss 里:
private Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
if (e != null && e.refersTo(key))
return e;
else
return getEntryAfterMiss(key, i, e);
}
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;
while (e != null) {
if (e.refersTo(key))
return e;
if (e.refersTo(null))
expungeStaleEntry(i);
else
i = nextIndex(i, len);
e = tab[i];
}
return null;
}
注意 getEntryAfterMiss 里的 if (e.refersTo(null)) expungeStaleEntry(i):get 在顺着开放地址法探测链找 key 的过程中,一旦遇到 key 已被 GC(refersTo(null))的 stale entry,就会顺手调用 expungeStaleEntry 把它清理掉。这就是”惰性清理”——不是后台线程定期扫,而是 get/set/remove 走到哪、清到哪。 顺着这个想下去会冒出一个问题:既然 get 能清理,为什么还非得手动 remove?代价就在这里——如果一个 stale entry 所在的槽位从来没被访问到,它就永远躺在 table 里。
set():写入路径同样触发清理
ThreadLocal.set() 的写入路径也做清理,而且比 get 更积极(找不到槽位时会 cleanSomeSlots + rehash):
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.refersTo(key)) {
e.value = value;
return;
}
if (e.refersTo(null)) {
replaceStaleEntry(key, value, i);
return;
}
}
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
遍历探测链时,命中 refersTo(null) 就交给 replaceStaleEntry:它会把新 key 写进 stale 槽,并连同”run”里前后所有的 stale entry 一起 expunge。写入新 Entry 后还会 cleanSomeSlots 做一次对数级扫描,扫描没清干净且 size 超过阈值才触发 rehash() 扩容。可见 set 的清理覆盖面比 get 大,但依然只覆盖”这次写入路过的那一段探测链”,远不是全表清理。
💭 思考:为什么 set 的清理比 get 更积极?——get 只在探测 miss 时顺手 expunge 一个 stale slot;set 却要在写入时找空槽、可能触发 rehash,如果不清掉路上的 stale entry,size 就会虚高、过早触发扩容,还让 stale 越积越多。所以 set 沿途会
replaceStaleEntry+cleanSomeSlots多扫一段。但两者共同的边界是”只覆盖这次路过的那段探测链”——没被访问到的冷槽位上的 stale entry,谁都不会去碰。理解了这条边界,就知道为什么惰性清理救不了泄漏。
expungeStaleEntry:清理 + rehash 的核心
所有清理最终都汇聚到 expungeStaleEntry,它是理解 ThreadLocalMap 正确性的关键:
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
// expunge entry at staleSlot
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;
// Rehash until we encounter null
Entry e;
int i;
for (i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null;
tab[i] = null;
size--;
} else {
int h = k.threadLocalHashCode & (len - 1);
if (h != i) {
tab[i] = null;
// Unlike Knuth 6.4 Algorithm R, we must scan until
// null because multiple entries could have been stale.
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
}
}
return i;
}
它做两件事:第一,把 stale slot 的 value 和槽位都置 null,断掉对业务数据的强引用;第二,从 staleSlot 往后一直扫到下一个 null 槽,对这段探测链里所有还活着的 Entry 做 rehash——k.threadLocalHashCode & (len - 1) 算出理想槽,若当前槽 != 理想槽就把它搬回(或就近)理想位置。
为什么 rehash:开放地址法的连锁效应
为什么清理完一个槽还要 rehash?直接置 null 不就行了吗?答案是:不行。ThreadLocalMap 用线性探测解决哈希冲突。假设 Entry A 的理想槽是 idx=5,但 idx=5 被 B 占了,于是 A 被放到 idx=6。后来 B 被清理,slot 5 变 null,A 还孤零零停在 slot 6。下次 get(ThreadLocal-A) 从理想槽 5 开始线性扫描 → 撞到 null → 认为 A 不存在 → 返回 null,A 就”丢”了。 所以 expungeStaleEntry 必须把 A 搬回靠近理想槽的位置、填掉中间的 null 空档,才能维持开放地址法”遇 null 即停”的查找语义。
remove():唯一主动断链的手段
ThreadLocal.remove() 与 ThreadLocalMap 内部的 remove 配合,是唯一能把”key 和 value 一起断掉”的主动操作:
public void remove() {
remove(Thread.currentThread());
}
private void remove(Thread t) {
ThreadLocalMap m = getMap(t);
if (m != null) {
m.remove(this);
}
}
private void remove(ThreadLocal<?> key) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.refersTo(key)) {
e.clear();
expungeStaleEntry(i);
return;
}
}
}
找到 key 后先 e.clear()(清掉 weak reference 指向的 ThreadLocal),再 expungeStaleEntry(i) 把 value 置 null 并 rehash。这一步同时断了 key 和 value 两条引用链,Entry 和业务数据随后都能被 GC 回收。 注意 remove() 是”确定性、立即”的清理,与 get/set 的”路过才清”有本质区别——所以它才是防泄漏的正确打开方式。
唯一可靠的解决方案
ThreadLocal<User> tl = new ThreadLocal<>();
try {
tl.set(currentUser);
// 业务逻辑
} finally {
tl.remove(); // 必须手动 remove!
// remove 调用链: e.clear() → expungeStaleEntry(i) → value=null, slot=null
// 整条强引用链断掉,Entry 和 value 都能被 GC
}
回到开头那个问题:弱引用没解决的事,谁来兜底?答案只有一个——惰性清理不可靠,finally remove() 是唯一金标准。 因为惰性清理只覆盖被访问到的探测链,而泄漏恰恰发生在”没人再访问”的冷槽位上——你不能把内存安全寄托在运气上。Spring 的事务管理在 TransactionSynchronizationManager 中也是同样的 finally remove 模式。虚拟线程 + ThreadLocal 的组合使用更要谨慎——虚拟线程数量远超平台线程,每个虚拟线程的 ThreadLocalMap 都是一个潜在的泄漏点。
章末提问
- ThreadLocal 用了弱引用 key,为什么还会内存泄漏? —— 因为弱引用只解决 key(ThreadLocal 对象)的回收,value 仍是强引用字段,被
Thread → threadLocals → Entry → value这条强引用链一直抓着,线程不死 value 就回收不了。 - 弱引用 key 的设计意图到底是什么? —— 不是防泄漏,而是让 ThreadLocal 对象在失去外部强引用后能被正常 GC;若不用弱引用,Map 强持有 key 会把 ThreadLocal 的生命周期绑架在线程上。
- 为什么不能依赖 get/set 的惰性清理来防泄漏? —— 因为惰性清理只覆盖「这次访问路过的探测链」,而泄漏恰恰发生在没人再访问的冷槽位上,无法保证清到每一个 stale entry。
- expungeStaleEntry 清理完为什么还要 rehash? —— 因为 ThreadLocalMap 用开放地址法「遇 null 即停」查找,清理留下的 null 槽会把后续存活 Entry 与理想槽隔断、导致查不到,rehash 把它们搬回理想位置才能维持查找正确性。
- remove() 和 get/set 的清理有什么本质区别? —— remove() 是确定性、立即的主动清理,同时断掉 key 和 value 两条引用链;get/set 是被动「路过才清」,所以唯一可靠方案是 finally 中 remove()。