ThreadLocal 内存泄露风险解析

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

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

ThreadLocal 使用不当会导致内存泄露。

在 Thread 类里,每个线程都维护一个 ThreadLocalMap,它的 Entry 是这样的:

java 复制代码
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,比如:

java 复制代码
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 提供了一种线程隔离的变量存储方式。每个线程都有自己独立的副本,互不干扰,适合存储线程上下文信息。

java 复制代码
// 不用在多线程环境下频繁创建 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

java 复制代码
// Thread.java
ThreadLocal.ThreadLocalMap threadLocals;

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

ThreadLocalMap 的核心是内部类 Entry

java 复制代码
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 空槽为止。

java 复制代码
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

java 复制代码
/*
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 悬挂在线程上,

相关推荐
萌新小码农‍3 小时前
Java分页 Element—UI
java·开发语言·ui
在未来等你3 小时前
Kafka面试精讲 Day 15:跨数据中心复制与灾备
大数据·分布式·面试·kafka·消息队列
鼠鼠我捏,要死了捏3 小时前
深入实践G1垃圾收集器调优:Java应用性能优化实战指南
java·g1·gc调优
书院门前细致的苹果3 小时前
ArrayList、LinkedList、Vector 的区别与底层实现
java
再睡亿分钟!3 小时前
Spring MVC 的常用注解
java·开发语言·spring boot·spring
qq_195551694 小时前
代码随想录70期day7
java·开发语言
GISer_Jing4 小时前
滴滴二面准备(一)
前端·javascript·面试·ecmascript
Sam-August4 小时前
【分布式架构实战】Spring Cloud 与 Dubbo 深度对比:从架构到实战,谁才是微服务的王者?
java·spring cloud·dubbo