ThreadLocal 是 Java 并发编程里非常常用的工具,很多人也知道它内部的 ThreadLocalMap 对 Key 使用了弱引用。按理说,弱引用指向的对象在 GC 时会被自动回收,内存泄漏应该不存在才对。但不管面试还是线上排查,ThreadLocal 内存泄漏都是一个高频问题。
为什么用了弱引用,还是会泄漏?
先搞清楚 ThreadLocal 的存储结构
每个线程 Thread 对象内部都有一个 ThreadLocalMap 类型的字段 threadLocals。这个 Map 的结构大致是:
Thread
└── ThreadLocalMap
├── Entry[0]: Key(WeakReference<ThreadLocal>) → Value(强引用)
├── Entry[1]: Key(WeakReference<ThreadLocal>) → Value(强引用)
└── ...
注意两个关键点:
- Entry 的 Key 是 ThreadLocal 的弱引用
- Entry 的 Value 是业务对象的强引用
这个结构设计就是泄漏的根源。
弱引用只管 Key,不管 Value
假设代码里这样用:
public void process() {
ThreadLocal<byte[]> tl = new ThreadLocal<>();
tl.set(new byte[1024 * 1024]); // 放了 1MB 数据
// 方法结束,tl 这个局部变量出栈,没有其他地方引用这个 ThreadLocal 对象
}
方法执行完后,局部变量 tl 出栈了,没有任何强引用指向这个 ThreadLocal 对象。下一次 GC 时,弱引用指向的 ThreadLocal 对象确实会被回收。
但问题来了:Key 被回收变成了 null,Value 还在。
ThreadLocalMap 里现在有这么一个 Entry:Key 是 null,Value 还是那个 1MB 的 byte 数组。因为 Entry 本身还被 ThreadLocalMap 持有,ThreadLocalMap 又被 Thread 持有,所以只要线程还活着,这个 1MB 的数据就永远不会被回收。
这就是所谓的「Key 为 null 的 Entry」,也叫「过期 Entry」(stale entry)。
为什么说弱引用并没解决内存泄漏
很多人会问:那如果 Key 用强引用呢?那更惨。
如果 Key 是强引用,即使业务代码已经不再使用某个 ThreadLocal 对象,但因为 ThreadLocalMap 对它还有强引用,这个 ThreadLocal 对象本身也无法被回收。也就是说 Key 和 Value 都泄漏了。
用弱引用至少保证了 ThreadLocal 对象本身能被回收,只是 Value 还残留着。所以弱引用只是把「Key + Value 都泄漏」降级成了「只有 Value 泄漏」,并没有从根本上消除问题。
ThreadLocalMap 自己有一定的清理机制
JDK 的开发者当然也意识到了这个问题,所以在 ThreadLocalMap 的 set()、get()、remove() 方法里都埋了清理逻辑。当这些方法执行时,会顺带扫描到 Key 为 null 的 Entry,然后把对应的 Value 也置为 null,帮助 GC 回收。
// ThreadLocalMap.set() 中的部分逻辑
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)]) {
ThreadLocal<?> k = e.get();
if (k == key) {
e.value = value;
return;
}
if (k == null) {
// 发现过期 Entry,触发清理
replaceStaleEntry(key, value, i);
return;
}
}
// ...
}
但这个清理是被动触发的,而且只能清理到线性探测路径上遇到的那些过期 Entry。如果某个过期 Entry 恰好不在探测路径上,它可能长时间不被清理。
线程池是问题根源
如果线程用完就销毁,Thread 对象被回收时,它持有的 ThreadLocalMap 也会一起被回收,根本不存在泄漏问题。
但生产环境中几乎都用线程池。线程池里的核心线程默认是长期存活的,一个线程可能在整个应用生命周期内都不会销毁。这意味着:
- 线程不销毁 → ThreadLocalMap 不销毁
- ThreadLocalMap 不销毁 → 里面的 Entry 不销毁
- Entry 的 Key 虽然被 GC 回收了(弱引用),但 Value 还被 Entry 强引用着
- 每次请求进来,可能又往 ThreadLocalMap 里塞新的数据,旧的过期 Entry 越积越多
在高并发场景下,如果忘了调用 remove(),线程池里每个线程的 ThreadLocalMap 都会慢慢膨胀,最终导致 OOM。这也是为什么在多线程和高并发相关面试题里,ThreadLocal 内存泄漏总被反复追问。
唯一的解决方案:用完就 remove
ThreadLocal<UserContext> userContext = new ThreadLocal<>();
try {
userContext.set(getCurrentUser());
// 业务逻辑
} finally {
userContext.remove(); // 必须在 finally 里清理
}
这不是建议,是强制要求。很多公司的代码规范里都会明确规定:ThreadLocal 使用完毕必须在 finally 块中调用 remove()。阿里巴巴 Java 开发手册里也有这条。
如果用的是 Web 框架,通常在 Filter 或 Interceptor 的 afterCompletion 里做统一清理:
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) {
UserContextHolder.clear();
}
说在最后
ThreadLocalMap 里的每个 Entry 依然紧紧攥着 Value 的强引用。如果是在传统单次短生命周期的线程里,线程跑完就死了,Map 自然跟着被回收,这都不算事。
主要还是后端开发离不开线程池,核心线程常驻不销毁,导致 ThreadLocalMap 永远活在内存里。就算弱引用的 Key 早就被 GC 掉了,ThreadLocalMap 里还留下一堆 key == null 的废弃 Entry,它对应的 Value 依然稳稳挂在引用链上。虽然 JDK 设计了顺手清理的启发式算法,但这种被动探测不仅覆盖不全,更靠不住。
所以解决内存泄漏从来没有捷径,唯一的根治手段,就是业务代码用完后老老实实在 finally 块里调用 remove(),自己把挖的坑填上。