大家好,我是小富。
《十万个why》系列持续更新中
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。
解决方案只有一个:用完就 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(); // 内部调用 threadLocal.remove()
}
总结
| 问题 | 原因 |
|---|---|
| 为什么用了弱引用还会泄漏 | 弱引用只作用于 Key(ThreadLocal),Value 仍是强引用 |
| 为什么不用强引用 | 强引用连 Key 都无法回收,泄漏更严重 |
| 为什么生产环境容易出问题 | 线程池导致线程长期存活,过期 Entry 无法随线程一起回收 |
| 为什么 JDK 的自动清理不够 | 清理是被动触发的,且只在探测路径上生效,覆盖不全 |
| 怎么根治 | 用完必须在 finally 里调用 remove() |
所以这个问题的本质是:弱引用解决了 Key 的回收问题,但没有解决 Value 的回收问题。在线程池场景下,线程不死,Value 就不会被 GC 清除,只能靠开发者自己手动 remove。
我是小富,下期见。
