找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4635

积分

0

好友

603

主题
发表于 1 小时前 | 查看: 8| 回复: 0

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。这也是为什么在多线程和高并发相关面试题里,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(),自己把挖的坑填上。




上一篇:iPhone 不越狱跑 DeepSeek Harness:iSH-ARM64 + Node.js 实测
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-8-28 08:17 , Processed in 0.803026 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表