ThreadLocal 内存泄漏
候选人小任在面试美团 P7 时,面试官问道:
"ThreadLocal 为什么会内存泄漏?你知道完整的泄漏链路吗?"
小任说:"因为 Entry 用弱引用..."面试官追问:"Entry 用弱引用了,ThreadLocal 已经被 GC 了,泄漏的到底是什么?"
小任答不上来。面试官继续:"ThreadLocalMap 的 expungeStaleEntry 什么时候被调用?"
小任彻底卡住了...
一、核心问题:ThreadLocal 内存泄漏全链路 🔴
1.1 问题拆解
第一层:引用类型(为什么用弱引用?)
"Entry 为什么要用 WeakReference<ThreadLocal>?"
考察点:WeakReference vs StrongReference、GC 对弱引用的处理
第二层:泄漏链路(漏了什么?)
"ThreadLocal 被 GC 后,什么还在泄漏?"
考察点:Entry.value、ThreadLocalMap、Thread 对象生命周期
第三层:清理机制(什么时候清理?)
"ThreadLocalMap 什么时候清理过期 Entry?"
考察点:expungeStaleEntry、set/get 时惰性清理
第四层:防御策略(怎么避免?)
"生产环境中如何正确使用 ThreadLocal?"
考察点:remove()、try-finally、TransmittableThreadLocal
1.2 ❌ 错误示范
候选人原话 A:"ThreadLocal 内存泄漏是因为弱引用,GC 时会回收弱引用,Entry 就空了。"
问题诊断:大错特错!GC 时会回收 WeakReference 的 referent(即 ThreadLocal 对象),但 Entry.value(Object 类型,是强引用)不会被 GC 回收!泄漏的正是 value。
候选人原话 B:"ThreadLocal 用完只要设为 null 就行了。"
问题诊断:ThreadLocal 设为 null 后,ThreadLocal 对象可以被 GC,但 Entry 仍然在 ThreadLocalMap 中,value 仍然被 Entry 持有。必须调用 remove() 才能彻底清理。
1.3 标准回答
P5 级别:引用类型基础
四种引用类型:
// 强引用(默认)
Object obj = new Object(); // 只要有强引用,GC 永不回收
// 软引用(SoftReference)
SoftReference<Object> soft = new SoftReference<>(new Object());
// GC 时,内存不足才会回收软引用对象
// 弱引用(WeakReference)
WeakReference<Object> weak = new WeakReference<>(new Object());
// 下一次 GC 时必定回收弱引用对象
// 虚引用(PhantomReference)
PhantomReference<Object> phantom = new PhantomReference<>(new Object());
// GC 时直接回收,无法通过 get() 获取
ThreadLocal 的 Entry 使用弱引用的原因:
如果使用强引用,当 ThreadLocal 对象不再被需要时(业务代码不再引用它),它仍然被 Entry.key 持有,无法被 GC。这会导致 ThreadLocal 对象和它持有的 value 一起泄漏。
使用弱引用后,ThreadLocal 对象在不再被需要时可以被 GC,Entry.key 变为 null。但 value 仍然是强引用,被 Entry 持有。
P6 级别:完整泄漏链路
泄漏的四个环节:
1. threadLocal.set(value) → Entry(ThreadLocal, value) 存入 ThreadLocalMap
2. threadLocal = null(外部不再引用 ThreadLocal 对象)
3. GC 发生 → Entry.key 被清空(WeakReference 被回收)
但 Entry.value 仍然是强引用 → 如果线程是长生命周期的,value 无法被 GC
4. Thread 对象的 threadLocals 持久存在 → value 持续占用内存
关键:线程池导致 ThreadLocalMap 长寿
ExecutorService pool = Executors.newFixedThreadPool(4);
// 线程 1 处理请求 A
pool.submit(() -> {
threadLocal.set(userA); // userA 存入线程 1 的 ThreadLocalMap
// ... 处理请求 A
// 如果忘记 remove(),userA 泄漏
});
// 线程 1 继续处理请求 B
pool.submit(() -> {
// 线程 1 被复用
// threadLocal.get() 仍然能获取 userA(数据错乱!)
// 或者 userA 因为 Entry.key 为 null 而被清理
});
泄漏的真正罪魁祸首:
不是弱引用,而是线程对象的生命周期和** ThreadLocalMap 没有自动清理机制**。
P7 级别:expungeStaleEntry 清理机制
惰性清理:
ThreadLocalMap 不是每次 set/get 都全量清理,而是遇到过期 Entry 时进行惰性清理:
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
// 1. 删除 stale 元素
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;
// 2. 重新散列:清理后续的过期元素
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
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;
}
清理触发的时机:
set() 时:如果遇到过期 Entry,调用 replaceStaleEntry()
get() 时:如果探测到过期 Entry,调用 expungeStaleEntry()
remove() 时:显式删除并清理后续过期元素
rehash() 时:全量扫描清理(size 超过阈值时触发)
为什么清理是惰性的?
为了减少每次操作的性能开销。ThreadLocal 通常数量很少,过期 Entry 也不多,惰性清理的均摊成本很低。
防御策略——必须使用 remove():
// 正确做法:在 finally 中清理
threadLocal.set(value);
try {
doWork();
} finally {
threadLocal.remove(); // 显式清理
}
// remove() 的实现
public void remove() {
ThreadLocalMap m = Thread.currentThread().threadLocals;
if (m != null) {
m.remove(this); // 移除 Entry 并清理后续过期元素
}
}
【面试官心理】
这道题我能问到 P7 级别,是因为内存泄漏链路涉及了引用类型、GC 机制、线程池生命周期、惰性清理等多个维度。能画出完整泄漏链路的候选人说明他有系统性理解。能说出 expungeStaleEntry 清理逻辑的候选人说明他看过 JDK 源码。
1.4 追问升级
追问 1:ThreadLocalMap 会不会在 Entry 过多时 OOM?
会。ThreadLocalMap 的初始容量是 16,负载因子 2/3。最多存储约 10 个有效 Entry(超过阈值会扩容)。如果 value 持有大对象且不断泄漏,最终会 OOM。
追问 2:Alibaba TransmittableThreadLocal 是什么?
淘宝开源的库,解决了线程池复用时 ThreadLocal 值不传递的问题。它在线程池任务提交时自动复制父线程的 ThreadLocal 值到子线程,在任务完成后自动清理。
TtlRunnable runnable = TtlRunnable.get(() -> {
// 子线程可以获取父线程的 ThreadLocal 值
// 任务完成后自动清理
});
executor.submit(runnable);
二、生产避坑
2.1 用错了 Reference 类型导致泄漏
场景:自定义 Entry 类时错误使用强引用:
// 错误:自定义 Entry 使用强引用
class MyEntry {
ThreadLocal<?> key; // 强引用
Object value;
MyEntry(ThreadLocal<?> k, Object v) {
key = k;
value = v;
}
}
2.2 ThreadLocal 作为缓存导致 OOM
// 错误:用 ThreadLocal 做缓存(大量 Entry)
ThreadLocal<Cache> cache = new ThreadLocal<>();
cache.set(new Cache()); // Cache 可能很大
如果 Cache 对象很大且线程池中的线程数很多,很快就会 OOM。
三、正确使用 ThreadLocal 的 checklist 🟡
3.1 必须执行的规则
3.2 自动化清理方案
// 方案 1:包装 Runnable/Callable
class ThreadLocalCleaner implements Runnable {
private final Runnable task;
private final ThreadLocal<?>[] threadLocals;
ThreadLocalCleaner(Runnable task, ThreadLocal<?>[] threadLocals) {
> this.task = task;
> this.threadLocals = threadLocals;
> }
>
> public void run() {
> try {
> task.run();
> } finally {
> for (ThreadLocal<?> tl : threadLocals) {
> tl.remove();
> }
> }
> }
}
💡
面试加分点:能说出"JDK 9 的 Thread::stackDepth 方法和 ThreadLocal 的实现无关,但 JDK 内部使用 ThreadLocal 存储 JVM 统计数据(如分配计数),这些数据如果泄漏会导致内存占用持续增长",说明他对 JVM 内部有了解。
⚠️
面试陷阱:被问到"ThreadLocal 的 Entry 如果 value 也被 GC 了会怎样",很多人会说"不会"。准确答案是:Entry 的 value 是强引用,只有在 ThreadLocalMap 中被显式清除或 Entry 被清理时,value 才能被 GC。GC 无法主动回收被 Entry.value 强引用的对象。