跳至主要內容

程序员小富大约 4 分钟

大家好,我是小富。

《十万个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(强引用)
        └── ...

注意两个关键点:

  1. Entry 的 Key 是 ThreadLocal 的弱引用
  2. 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 也会一起被回收,根本不存在泄漏问题。

但生产环境中几乎都用线程池。线程池里的核心线程默认是长期存活的,一个线程可能在整个应用生命周期内都不会销毁。这意味着:

  1. 线程不销毁 → ThreadLocalMap 不销毁
  2. ThreadLocalMap 不销毁 → 里面的 Entry 不销毁
  3. Entry 的 Key 虽然被 GC 回收了(弱引用),但 Value 还被 Entry 强引用着
  4. 每次请求进来,可能又往 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。


我是小富,下期见。

上次编辑于: