最近在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 = 我们存储的值
}
}
- key :
ThreadLocal
对象,使用 弱引用(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 悬挂在线程上,