ThreadLocal 内存泄露风险解析

发布于:2025-09-10 ⋅ 阅读:(17) ⋅ 点赞:(0)

最近在b站看到一个博主分享的ThreadLocal内存泄露的观点,和之前学习到的有一些差别,因此作一个记录。原视频在这里

面试题:ThreadLocal 会导致内存泄露吗?

ThreadLocal 使用不当会导致内存泄露。
在 Thread 类里,每个线程都维护一个 ThreadLocalMap,它的 Entry 是这样的:

static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;
}

这里 key 是对 ThreadLocal 的弱引用,value 是强引用。
所以会有两种典型场景:
1. 经典内存泄露场景(ThreadLocal 非 static,全局未持有引用)
如果 ThreadLocal 不是 static(强引用),又没有被全局持有引用:当外部不再引用 ThreadLocal 对象时,GC 会回收 ThreadLocal,导致 Entry.key = null。但是 Entry.value 是强引用,并且被 ThreadLocalMap 持有。如果线程是线程池里的长期存活线程,就算 key 变成 null,value 依然无法回收,造成内存泄露。

虽然 ThreadLocal.get() 和 set() 方法内部会调用 expungeStaleEntry() 自动清理 key=null 的条目,但只有访问 ThreadLocal 的时候才会清理。如果线程长时间闲置不访问,value 会一直残留。

2. 声明为 static ThreadLocal 的情况

如果把 ThreadLocal 声明为 static,比如:

private static final ThreadLocal<SimpleDateFormat> formatter =
        ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

这时 ThreadLocal 对象生命周期与类一致,几乎等同于应用程序的生命周期,不会被轻易 GC 掉。因此不会出现 “key 被回收、value 残留” 的典型内存泄露问题。但任然存在两个风险点:
(1) 线程复用导致 value 残留:在线程池场景中,线程不会销毁,而是被反复复用。如果某个线程在线程池中长期存活,而我们没有在使用完 ThreadLocal 后调用 remove(),后续 set(),旧 value 会被替换,旧值可以被 GC 回收,问题不大;但如果线程很久都不更新 value(比如 value 是大对象),它会被这个线程的 ThreadLocalMap 长期持有,导致堆内存占用越来越大。

(2) 类卸载导致的残留问题:在 Tomcat、Jetty 这种支持热部署的容器里,类加载器会动态卸载旧的 Web 应用。

  • 如果ThreadLocal 声明在应用内部的类里,且是 static;
  • 卸载前没有显式调用 remove();
  • 线程是容器线程池中的线程(不会被销毁);

那 ThreadLocalMap 里会残留旧类加载器加载的 value 对象,导致旧类无法被 GC,最终形成典型的类加载器泄露。


补充

1. ThreadLocal作用

ThreadLocal 提供了一种线程隔离的变量存储方式。每个线程都有自己独立的副本,互不干扰,适合存储线程上下文信息。

// 不用在多线程环境下频繁创建 SimpleDateFormat 对象,也避免了线程安全问题
private static final ThreadLocal<SimpleDateFormat> DATE_FORMAT =
    ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

public static String format(Date date) {
    return DATE_FORMAT.get().format(date);
}

2. 实现原理

在每个 Thread 对象内部,都有一个 ThreadLocalMap

// Thread.java
ThreadLocal.ThreadLocalMap threadLocals;

每次调用 ThreadLocal.set()get(),其实就是在当前线程的 ThreadLocalMap 中存取数据。

ThreadLocalMap 的核心是内部类 Entry

static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;
    Entry(ThreadLocal<?> k, Object v) {
        super(k);    // key = ThreadLocal 弱引用
        value = v;   // value = 我们存储的值
    }
}
  • keyThreadLocal 对象,使用 弱引用(WeakReference)
  • value:实际存储的值,使用 强引用
  • ThreadLocalMap 挂在线程上 → 线程存活期间,Map也会存活

3. 元素清理机制

视频的评论区有小伙伴提到“get set本身会扫描null值,自动删除”,我看了JDK17中ThreadLocal的源码,确实存在两个元素清理相关的方法。

3.1 探测式清理(expungeStaleEntry)

探测式清理是一种局部连续清理策略:当遇到一个被 GC 回收的 ThreadLocal(即 Entry.key == null)时,从该位置开始,向后遍历整个哈希表,持续清理过期元素,直到遇到第一个 null 空槽为止。

private int expungeStaleEntry(int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;
    // 清理当前过期元素
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;

    Entry e;
    int i;
    // 从 staleSlot 的下一个索引开始向后遍历
    for (i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {

        ThreadLocal<?> k = e.get();
        if (k == null) {
            // 遇到 key 已被 GC 回收的 Entry,继续清理
            e.value = null;
            tab[i] = null;
            size--;
        } else {
            // key 有效,需要重新计算 hash,确保 rehash 后存储在正确位置
            int h = k.threadLocalHashCode & (len - 1);
            if (h != i) {
                tab[i] = null;
                // 找到新的空槽插入 Entry
                while (tab[h] != null)
                    h = nextIndex(h, len);
                tab[h] = e;
            }
        }
    }
    return i;
}

3.2 启发式清理(cleanSomeSlots)

启发式清理是一种试探性扫描策略:在新增元素或删除元素后,会调用该方法,扫描有限数量的槽位,尝试清理被 GC 回收的 ThreadLocal

/*
Heuristically scan some cells looking for stale entries. This is invoked when either a new element is added, or another stale one has been expunged. It performs a logarithmic number of scans, as a balance between no scanning (fast but retains garbage) and a number of scans proportional to number of elements, that would find all garbage but would cause some insertions to take O(n) time.
大意;试探的扫描一些单元格,寻找过期元素,也就是被垃圾回收的元素。当添加新元素或删除另一个过时元素时,将调用此函数。它执行对数扫描次数,作为不扫描(快速但保留垃圾)和与元素数量成比例的扫描次数之间的平衡,这将找到所有垃圾,但会导致一些插入花费O(n)时间。
*/
private boolean cleanSomeSlots(int i, int n) {
    boolean removed = false;
    Entry[] tab = table;
    int len = tab.length;

    do {
        i = nextIndex(i, len);
        Entry e = tab[i];
        // 遇到 key == null 的 Entry,调用探测式清理
        if (e != null && e.get() == null) {
            n = len;
            removed = true;
            i = expungeStaleEntry(i);
        }
    // 通过右移不断缩小扫描范围,最终退出
    } while ((n >>>= 1) != 0);

    return removed;
}

while 循环中不断的右移进行寻找需要被清理的过期元素,最终都会使用 expungeStaleEntry 进行处理。

  • 探测式清理(expungeStaleEntry):彻底清理连续的过期元素,重新定位有效 Entry。
  • 启发式清理(cleanSomeSlots):插入或删除时触发,扫描少量槽位,兼顾性能。
  • 启发式清理发现垃圾后,会调用探测式清理进行深度清理。

如果一直不 get()set()remove(),清理逻辑不会被触发,造成隐性内存泄露。比如线程池里的线程会长时间存活,导致 value 悬挂在线程上,


网站公告

今日签到

点亮在社区的每一天
去签到