引言
大家好,我们又见面了。今天依旧是结合源码为大家分享个人对于 ThreadLocal 的一些理解。今天是第二期,将着重分析 ThreadLocal 内存泄露问题,文章后半篇含重点源码精讲,不容错过。废话不多说,坐稳发车咯!
上期回顾
在上一期,我通过阅读源码的方式带大家学习了 ThreadLocal 常用的 API,并在这个过程中深度剖析了 ThreadLocal 的存储结构。
下面通过我刚刚绘制的一张图来为大家回顾一下上一节所阐述的存储结构。
图片
如果大家对这个存储结构有所疑惑,可以回看第一期《ThreadLocal 源码解读:初识 ThreadLocal》。
引用类型
在 Java 中有四种常用的引用类型,依照引用的强弱排序依次是:强引用、软引用、弱引用、幻引用(虚引用)。
其中强引用就是我们通常所说的引用,所以这里 Java 并没有单独定义一个引用类来表示,并且强引用存在时被引用对象一定不会被垃圾回收器回收。
软引用在 Java 中使用 SoftReference 类表示,被软引用单独引用的对象当系统内存不足的时候会被垃圾回收器所回收,也就是说在发生 OOM 前将会回收软引用对象,试图避免 OOM 的发生。
弱引用在 Java 中使用 WeakReference 类表示,被弱引用单独引用的对象在发生任意垃圾回收时,无论内存是否充足都将会被回收。
幻引用在 Java 中使用 PhantomReference 类表示,是最弱的引用类型,主要用于跟踪对象是否被垃圾回收,并且幻引用的 get 方法永远返回 null。
上述三种引用类均继承 Reference 类,Reference 类通过泛型成员变量 referent 存储引用对象,并提供了 get 方法用于获取引用对象,提供 clear 方法用于清理引用对象。
图片
内存泄露问题剖析
抛出观点
在探究 ThreadLocal 内存泄漏问题之前,我们首先要明确一下,什么是内存泄露?
这里我们直接引用百度百科提供的答案。
内存泄漏(Memory Leak)是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。
那么 ThreadLocal 在使用过程中存在泄露问题吗?答案是肯定的,但是要纠正一点,ThreadLocal 的内存泄露问题与 ThreadLocal 对象的弱引用并无关系!这一点在网上可能存在着误导信息,下面将会为大家论证我的观点。
推理验证
首先我们来看一下 Entry 类的定义。
图片
可以看到 Entry 类继承了 WeakReference 类,并且将弱引用的 ThreadLocal 对象作为了 ThreadLocalMap 的键。
查阅过 ThreadLocal 相关博客的小伙伴可能看过下面种说法。
--start--
ThreadLocal 变量如果未被正确清理,可能会导致内存泄露。因为 ThreadLocalMap 的键是 ThreadLocal 对象的弱引用,值是强引用。
当 ThreadLocal 对象不再被外部引用时,ThreadLocalMap 中的键会被垃圾回收,但值仍然存在,导致无法被垃圾回收,从而引发内存泄露。
--end--
在这个过程中的确存在内存泄露问题,但这和 ThreadLocalMap 的 key 设计并无关系,这是编写程序的不严谨导致的问题,在使用完 ThreadLocal 后,没有调用 remove 方法显式移除值。
任何一个 Java 对象都可能因为使用不当导致内存泄漏,比如声明了一个类的对象用作成员变量,但是却从未在代码里使用过这个成员变量(如下图),这也是内存泄漏。
图片
所以并不是因为 ThreadLocalMap 的 key 的弱引用设计,才导致的内存泄露问题。恰恰相反,ThreadLocalMap 的 key 的弱引用设计一定程度上减少了内存泄露的损失。
首先当 ThreadLocalMap 的 key 不再被外部所引用时,ThreadLocal 对象以及通过 ThreadLocal 存储在 ThreadLocalMap 中的值已经无法在其他地方被获取,已经发生了内存泄漏。那么这时候垃圾回收器回收掉 ThreadLocalMap 的 key,恰恰为我们释放了一部分已经泄露的内存。
这时候有人可能会有疑问,那 value 就不管了吗?当然不是!虽然这是开发者 API 使用不当留下的坑,但是设计者也为我们填了这个坑。
注意看 Entry 类的注释,这里我直接为大家翻译出来。
图片
可以看到官方将 key 为 null 的 Entry 对象称之为“陈旧条目”,也就是我上一期文章所说的过时 Entry,并且官方指出这些过时 Entry 可以从 ThreadLocalMap 中删除。
那么不难猜到,ThreadLocal 在设计时一定在某些时机对这些过时 Entry 进行了清理,尽可能的释放泄露的内存。
这里先给出大家结论,然后我们再去论证:ThreadLocal在调用set(),get(),remove()方法的时候,都可能触发清理过时Entry的逻辑。。
清理方法源码剖析
expungeStaleEntry 方法
在讨论到 ThreadLocalMap 过时 Entry 清理的问题,就绕不开 ThreadLocalMap 的 expungeStaleEntry 这个方法,见名之意这个方法用于删除过时 Entry。
下面我将采用在源码中添加注释的方式剖析这个方法。
/**
* Expunge a stale entry by rehashing any possibly colliding entries
* lying between staleSlot and the next null slot. This also expunges
* any other stale entries encountered before the trailing null. See
* Knuth, Section 6.4
*
* @param staleSlot index of slot known to have null key
* @return the index of the next null slot after staleSlot
* (all between staleSlot and this slot will have been checked
* for expunging).
*/
private int expungeStaleEntry(int staleSlot) {
// 入参 staleSlot: 待清理位置下标
// 获取 ThreadLocalMap 中的 Entry 数组。
ThreadLocal.ThreadLocalMap.Entry[] tab = table;
// len 为当前 Entry 数组容量。
int len = tab.length;
// expunge entry at staleSlot
// 清除当前 staleSlot 位置的过时 Entry。
tab[staleSlot].value = null;
tab[staleSlot] = null;
// 元素数量减一。
size--;
// Rehash until we encounter null
// 因为 ThreadLocalMap 解决哈希冲突采用的是线性探测法,如将当前下标位置赋值为 null ,但不对后续 Entry
// 元素进行 rehash 操作,就可能导致存在哈希冲突的后置元素无法被探测到。所以将当前元素清理后需要
// 对后续元素进行 rehash 操作,直到遇到下一个为 null 的元素。
ThreadLocal.ThreadLocalMap.Entry e;
int i;
// nextIndex 用于向后递增索引 ((i + 1 < len) ? i + 1 : 0)
for (i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
if (k == null) {
// 此时 Entry 不为 null,key 为 null,Entry 为过时 Entry 需清理掉。
e.value = null;
tab[i] = null;
size--;
} else {
// 此时为有效 Entry,需要进行 rehash 操作重新定位。
int h = k.threadLocalHashCode & (len - 1);
if (h != i) {
// 进入到这个分支说明 rehash 后,新的下标与原来下标不等。
// 将当前下标位置清空。
tab[i] = null;
// Unlike Knuth 6.4 Algorithm R, we must scan until
// null because multiple entries could have been stale.
// 从 h 位置开始遍历,直到遇到为 null 的元素,并将 rehash 后的元素插入到该位置。
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
}
}
// i 为 staleSlot 后的第一个 null 元素的位置下标。
return i;
}
了解了 expungeStaleEntry 方法的内部实现细节之后就可以把这个方法当做一个黑盒,作用是清理传入下标位置的过时 Entry,入参为一个过时 Entry 的下标。
cleanSomeSlots 方法
有了 expungeStaleEntry 方法的基础我们就可以攻克下一个和清理过时 Entry 相关的方法:cleanSomeSlots。见名之意,这个方法的作用是清除一些过时 Entry。
同样采用在源码中添加注释的方式剖析这个方法。
/**
* 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.
*
* @param i a position known NOT to hold a stale entry. The
* scan starts at the element after i.
*
* @param n scan control: {@code log2(n)} cells are scanned,
* unless a stale entry is found, in which case
* {@code log2(table.length)-1} additional cells are scanned.
* When called from insertions, this parameter is the number
* of elements, but when from replaceStaleEntry, it is the
* table length. (Note: all this could be changed to be either
* more or less aggressive by weighting n instead of just
* using straight log n. But this version is simple, fast, and
* seems to work well.)
*
* @return true if any stale entries have been removed.
*/
private boolean cleanSomeSlots(int i, int n) {
// 入参 i: 一个已知不为过时 Entry 的下标。扫描从 i 之后的位置开始。
// 入参 n: 扫描次数控制值
// 是否清理了任意过时 Entry 标志,
// 为 false 代表本次方法调用未能清理任何过时 Entry,为 true 代表本次方法调用至少清理了一个过时 Entry。
boolean removed = false;
ThreadLocal.ThreadLocalMap.Entry[] tab = table;
int len = tab.length;
// doWhile 循环,至少执行一次。
do {
i = nextIndex(i, len);
ThreadLocal.ThreadLocalMap.Entry e = tab[i];
if (e != null && e.get() == null) {
// 进入到当前分支说明当前 Entry 为过时 Entry。
// 将 n 置为数组容量,将当前循环遍历次数进行追增(n 变大了)。
n = len;
// 将标志置为 true,证明本次方法调用并不是无功而返。
removed = true;
// 调用清理过时 Entry 方法,并将 expungeStaleEntry 方法返回的 null 元素的下标赋值给 i,
// 在这之间的下标都在 expungeStaleEntry 方法中进行了清理,所以这里直接跳过避免重复操作。
i = expungeStaleEntry(i);
}
// >>>= 无符号右移并赋值,相当于除以 2 操作。
} while ( (n >>>= 1) != 0);
// 返回本次是否至少清理了一个过时 Entry。
return removed;
}
cleanSomeSlots 方法在 set 和 remove 方法调用中会被调用到,这个方法在完全不扫描以及全量扫描中做了一个平衡,采用以对数的方式进行扫描,并且如果发现了过时 Entry 则会再追增对数次扫描,使得在保证 set 方法和 remove 方法的执行效率的情况下一定程度上清理了过时 Entry。
replaceStaleEntry 方法
下面我们来看一下最后一个与清理过时 key 有关的方法:replaceStaleEntry,通过方法名我们可以推测出这个方法的作用是替换过时条目,那么用什么替换呢,是 set 方法传过来的 Entry。
同样采用在源码中添加注释的方式剖析这个方法,这个方法有些许难度,如果大家不理解,可以多阅读几遍。
/**
* Replace a stale entry encountered during a set operation
* with an entry for the specified key. The value passed in
* the value parameter is stored in the entry, whether or not
* an entry already exists for the specified key.
*
* As a side effect, this method expunges all stale entries in the
* "run" containing the stale entry. (A run is a sequence of entries
* between two null slots.)
*
* @param key the key
* @param value the value to be associated with key
* @param staleSlot index of the first stale entry encountered while
* searching for key.
*/
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
int staleSlot) {
// 入参 key: set 操作传过来的 key
// 入参 value: set 操作传过来的 value,与参数 key 相关联
// 入参 staleSlot: 待替换的过时 Entry 的下标
ThreadLocal.ThreadLocalMap.Entry[] tab = table;
int len = tab.length;
ThreadLocal.ThreadLocalMap.Entry e;
// Back up to check for prior stale entry in current run.
// We clean out whole runs at a time to avoid continual
// incremental rehashing due to garbage collector freeing
// up refs in bunches (i.e., whenever the collector runs).
// slotToExpunge 变量目标是存储当前区间段(两个 null 元素之间),第一个过时 Entry 的下标。
// 将入参的过时 Entry 下标赋值给 slotToExpunge。
int slotToExpunge = staleSlot;
// 这里需要格外注意一下,这里并不是递增下标,而是对下标进行递减。
// prevIndex ((i - 1 >= 0) ? i - 1 : len - 1)。
// 向前进行遍历直到遇到为 null 的元素。
for (int i = prevIndex(staleSlot, len);
(e = tab[i]) != null;
i = prevIndex(i, len))
if (e.get() == null)
// 将遍历过程中过时 Entry 的下标赋值给 slotToExpunge 变量。
// 经过当前遍历逻辑,slotToExpunge 将存储两个 null 元素之间第一个过时 key 的下标。
slotToExpunge = i;
// Find either the key or trailing null slot of run, whichever
// occurs first
// 这里是由入参的过时 Entry 下标开始向后遍历,直到遇到 null 元素。
for (int i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
// If we find key, then we need to swap it
// with the stale entry to maintain hash table order.
// The newly stale slot, or any other stale slot
// encountered above it, can then be sent to expungeStaleEntry
// to remove or rehash all of the other entries in run.
if (k == key) {
// 进入当前分支表示在遍历的过程中找到了被 set 的 Entry 对象的本体。
// 将和 key 关联的新 value 值赋值给本体。
e.value = value;
// 操作一
// 将 Entry 对象本体和入参 staleSlot 位置的过时 Entry 进行交换,
// 结果是set操作的 key 与 value,无论之前本体存储在哪里,
// 最终都会存储在入参的 staleSlot 下标,符合方法名中的 replace 含义。
tab[i] = tab[staleSlot];
tab[staleSlot] = e;
// Start expunge at preceding stale entry if it exists
if (slotToExpunge == staleSlot)
// 如果当前区间段第一个过时 Entry 下标仍是 staleSlot 下标,
// 那么需要将当前 i 下标赋值给 slotToExpunge ,因为 staleSlot 下标已经存储了 set 操作的 Entry 对象,
// 导致当前 i 下标变成了第一个过时 Entry 的下标。
slotToExpunge = i;
// 先调用 expungeStaleEntry 方法清除 slotToExpunge 下标的过时 Entry,
// 再从 expungeStaleEntry 方法返回的 null 元素的下标开始执行 cleanSomeSlots 方法。
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
// 已经完成替换过时条目操作,退出当前方法。
return;
}
// If we didn't find stale entry on backward scan, the
// first stale entry seen while scanning for key is the
// first still present in the run.
if (k == null && slotToExpunge == staleSlot)
// 进入当前分支说明当前 Entry 是过时 Entry。
// 如果当前区间段第一个过时 Entry 下标仍是入参的 staleSlot 下标,
// 则需要将当前位置下标赋值给 slotToExpunge,因为最终当前位置的过时 Entry 将是
// 当前区间段的第一个过时 Entry。因为 staleSlot 下标位置的过时 Entry 在之后的逻辑
// 里要么被交换到当前下标之后(上文操作一),要么被新的 set 传入的 Entry 覆盖掉(下文操作二)。
slotToExpunge = i;
}
// If key not found, put new entry in stale slot
// 操作二
// 代码执行到当前位置说明 set 的 key 与 value 是一个新的 Entry,在之前并不存在。
// 以 set 方法传入的 key 和 value 值 new 一个新的 Entry 对象,并覆盖在入参的 staleSlot 下标处。
tab[staleSlot].value = null;
tab[staleSlot] = new ThreadLocal.ThreadLocalMap.Entry(key, value);
// If there are any other stale entries in run, expunge them
if (slotToExpunge != staleSlot)
// slotToExpunge 与 staleSlot 相等则说明当前区间段只有入参 staleSlot 位置有过时 Entry,
// 并且该过时 Entry 已被覆盖,所以无需清理,无需进入当前分支。
// 进入当前分支说明当前区间段,除了被覆盖的过时 Entry,至少还存在一个过时 Entry,
// slotToExpunge 下标为第一个过时 Entry 的下标。
// 先调用 expungeStaleEntry 方法清除 slotToExpunge 下标的过时 Entry,
// 再从 expungeStaleEntry 方法返回的 null 元素的下标开始执行 cleanSomeSlots 方法。
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}
在方法中 slotToExpunge 变量之所以始终要存储当前区间段(两个 null 元素之间)的第一个过时 Entry,是因为每当删除一个过时 Entry 后都会对后续 Entry 进行 rehash 操作,如果清理的不是第一个过时 Entry,那么在后续其他逻辑触发清理第一个过时 Entry 时还会将刚刚 rehash 过的元素再次 rehash 一遍,极大的影响效率。
至此在 ThreadLocalMap 中涉及清理过时Entry的三个方法都已剖析完毕,下面我们来罗列一下什么时候会触发这三个方法。
清理方法调用梳理
为避免截图过多影响阅读体验,这里将只粘出调用的起点,并给调用链路,大家后续可以自己在源码中点一点。
get方法
调用链路:ThreadLocal#get
->ThreadLocalMap#getEntry
->ThreadLocalMap#getEntryAfterMiss
->ThreadLocalMap#expungeStaleEntry
图片
set方法
调用链路 1:ThreadLocal#set->ThreadLocalMap#set->ThreadLocalMap#replaceStaleEntry
调用链路 2:ThreadLocal#set->ThreadLocalMap#set->ThreadLocalMap#cleanSomeSlots
调用链路 3:ThreadLocal#set->ThreadLocalMap#set->ThreadLocalMap#rehash->ThreadLocalMap#expungeStaleEntries->ThreadLocalMap#expungeStaleEntry
图片
remove方法
调用链路:ThreadLocal#remove->ThreadLocalMap#remove->ThreadLocalMap#expungeStaleEntry
图片
总结
通过两期文章的深度剖析,大家应该对 ThreadLocal 的 API 使用以及内存泄露问题有了进一步的理解。
ThreadLocal 优势是无锁化提升并发性能和简化变量的传递逻辑。
在实际业务中使用 ThreadLocal 类时应该在恰当位置调用 remove 方法显式移除值。
尽可能的避免触发 ThreadLocal 清理过时 Entry 的逻辑,从而提高 ThreadLocal 性能。
例如使用继承的 ThreadLocal 类,并重写 finalize 方法,确保 ThreadLocal 对象在被垃圾回收前,remove 方法会被调用。