ThreadLocal源碼解讀:內(nèi)存泄露問題分析
引言
大家好,我們又見面了。今天依舊是結(jié)合源碼為大家分享個(gè)人對于 ThreadLocal 的一些理解。今天是第二期,將著重分析 ThreadLocal 內(nèi)存泄露問題,文章后半篇含重點(diǎn)源碼精講,不容錯(cuò)過。廢話不多說,坐穩(wěn)發(fā)車咯!
上期回顧
在上一期,我通過閱讀源碼的方式帶大家學(xué)習(xí)了 ThreadLocal 常用的 API,并在這個(gè)過程中深度剖析了 ThreadLocal 的存儲(chǔ)結(jié)構(gòu)。
下面通過我剛剛繪制的一張圖來為大家回顧一下上一節(jié)所闡述的存儲(chǔ)結(jié)構(gòu)。
圖片
如果大家對這個(gè)存儲(chǔ)結(jié)構(gòu)有所疑惑,可以回看第一期《ThreadLocal 源碼解讀:初識 ThreadLocal》。
引用類型
在 Java 中有四種常用的引用類型,依照引用的強(qiáng)弱排序依次是:強(qiáng)引用、軟引用、弱引用、幻引用(虛引用)。
其中強(qiáng)引用就是我們通常所說的引用,所以這里 Java 并沒有單獨(dú)定義一個(gè)引用類來表示,并且強(qiáng)引用存在時(shí)被引用對象一定不會(huì)被垃圾回收器回收。
軟引用在 Java 中使用 SoftReference 類表示,被軟引用單獨(dú)引用的對象當(dāng)系統(tǒng)內(nèi)存不足的時(shí)候會(huì)被垃圾回收器所回收,也就是說在發(fā)生 OOM 前將會(huì)回收軟引用對象,試圖避免 OOM 的發(fā)生。
弱引用在 Java 中使用 WeakReference 類表示,被弱引用單獨(dú)引用的對象在發(fā)生任意垃圾回收時(shí),無論內(nèi)存是否充足都將會(huì)被回收。
幻引用在 Java 中使用 PhantomReference 類表示,是最弱的引用類型,主要用于跟蹤對象是否被垃圾回收,并且幻引用的 get 方法永遠(yuǎn)返回 null。
上述三種引用類均繼承 Reference 類,Reference 類通過泛型成員變量 referent 存儲(chǔ)引用對象,并提供了 get 方法用于獲取引用對象,提供 clear 方法用于清理引用對象。
圖片
內(nèi)存泄露問題剖析
拋出觀點(diǎn)
在探究 ThreadLocal 內(nèi)存泄漏問題之前,我們首先要明確一下,什么是內(nèi)存泄露?
這里我們直接引用百度百科提供的答案。
內(nèi)存泄漏(Memory Leak)是指程序中已動(dòng)態(tài)分配的堆內(nèi)存由于某種原因程序未釋放或無法釋放,造成系統(tǒng)內(nèi)存的浪費(fèi),導(dǎo)致程序運(yùn)行速度減慢甚至系統(tǒng)崩潰等嚴(yán)重后果。
那么 ThreadLocal 在使用過程中存在泄露問題嗎?答案是肯定的,但是要糾正一點(diǎn),ThreadLocal 的內(nèi)存泄露問題與 ThreadLocal 對象的弱引用并無關(guān)系!這一點(diǎn)在網(wǎng)上可能存在著誤導(dǎo)信息,下面將會(huì)為大家論證我的觀點(diǎn)。
推理驗(yàn)證
首先我們來看一下 Entry 類的定義。
圖片
可以看到 Entry 類繼承了 WeakReference 類,并且將弱引用的 ThreadLocal 對象作為了 ThreadLocalMap 的鍵。
查閱過 ThreadLocal 相關(guān)博客的小伙伴可能看過下面種說法。
--start--
ThreadLocal 變量如果未被正確清理,可能會(huì)導(dǎo)致內(nèi)存泄露。因?yàn)?ThreadLocalMap 的鍵是 ThreadLocal 對象的弱引用,值是強(qiáng)引用。
當(dāng) ThreadLocal 對象不再被外部引用時(shí),ThreadLocalMap 中的鍵會(huì)被垃圾回收,但值仍然存在,導(dǎo)致無法被垃圾回收,從而引發(fā)內(nèi)存泄露。
--end--
在這個(gè)過程中的確存在內(nèi)存泄露問題,但這和 ThreadLocalMap 的 key 設(shè)計(jì)并無關(guān)系,這是編寫程序的不嚴(yán)謹(jǐn)導(dǎo)致的問題,在使用完 ThreadLocal 后,沒有調(diào)用 remove 方法顯式移除值。
任何一個(gè) Java 對象都可能因?yàn)槭褂貌划?dāng)導(dǎo)致內(nèi)存泄漏,比如聲明了一個(gè)類的對象用作成員變量,但是卻從未在代碼里使用過這個(gè)成員變量(如下圖),這也是內(nèi)存泄漏。
圖片
所以并不是因?yàn)?ThreadLocalMap 的 key 的弱引用設(shè)計(jì),才導(dǎo)致的內(nèi)存泄露問題。恰恰相反,ThreadLocalMap 的 key 的弱引用設(shè)計(jì)一定程度上減少了內(nèi)存泄露的損失。
首先當(dāng) ThreadLocalMap 的 key 不再被外部所引用時(shí),ThreadLocal 對象以及通過 ThreadLocal 存儲(chǔ)在 ThreadLocalMap 中的值已經(jīng)無法在其他地方被獲取,已經(jīng)發(fā)生了內(nèi)存泄漏。那么這時(shí)候垃圾回收器回收掉 ThreadLocalMap 的 key,恰恰為我們釋放了一部分已經(jīng)泄露的內(nèi)存。
這時(shí)候有人可能會(huì)有疑問,那 value 就不管了嗎?當(dāng)然不是!雖然這是開發(fā)者 API 使用不當(dāng)留下的坑,但是設(shè)計(jì)者也為我們填了這個(gè)坑。
注意看 Entry 類的注釋,這里我直接為大家翻譯出來。
圖片
可以看到官方將 key 為 null 的 Entry 對象稱之為“陳舊條目”,也就是我上一期文章所說的過時(shí) Entry,并且官方指出這些過時(shí) Entry 可以從 ThreadLocalMap 中刪除。
那么不難猜到,ThreadLocal 在設(shè)計(jì)時(shí)一定在某些時(shí)機(jī)對這些過時(shí) Entry 進(jìn)行了清理,盡可能的釋放泄露的內(nèi)存。
這里先給出大家結(jié)論,然后我們再去論證:ThreadLocal在調(diào)用set(),get(),remove()方法的時(shí)候,都可能觸發(fā)清理過時(shí)Entry的邏輯。。
清理方法源碼剖析
expungeStaleEntry 方法
在討論到 ThreadLocalMap 過時(shí) Entry 清理的問題,就繞不開 ThreadLocalMap 的 expungeStaleEntry 這個(gè)方法,見名之意這個(gè)方法用于刪除過時(shí) Entry。
下面我將采用在源碼中添加注釋的方式剖析這個(gè)方法。
/**
* 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) {
// 入?yún)?staleSlot: 待清理位置下標(biāo)
// 獲取 ThreadLocalMap 中的 Entry 數(shù)組。
ThreadLocal.ThreadLocalMap.Entry[] tab = table;
// len 為當(dāng)前 Entry 數(shù)組容量。
int len = tab.length;
// expunge entry at staleSlot
// 清除當(dāng)前 staleSlot 位置的過時(shí) Entry。
tab[staleSlot].value = null;
tab[staleSlot] = null;
// 元素?cái)?shù)量減一。
size--;
// Rehash until we encounter null
// 因?yàn)?ThreadLocalMap 解決哈希沖突采用的是線性探測法,如將當(dāng)前下標(biāo)位置賦值為 null ,但不對后續(xù) Entry
// 元素進(jìn)行 rehash 操作,就可能導(dǎo)致存在哈希沖突的后置元素?zé)o法被探測到。所以將當(dāng)前元素清理后需要
// 對后續(xù)元素進(jìn)行 rehash 操作,直到遇到下一個(gè)為 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) {
// 此時(shí) Entry 不為 null,key 為 null,Entry 為過時(shí) Entry 需清理掉。
e.value = null;
tab[i] = null;
size--;
} else {
// 此時(shí)為有效 Entry,需要進(jìn)行 rehash 操作重新定位。
int h = k.threadLocalHashCode & (len - 1);
if (h != i) {
// 進(jìn)入到這個(gè)分支說明 rehash 后,新的下標(biāo)與原來下標(biāo)不等。
// 將當(dāng)前下標(biāo)位置清空。
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 后的第一個(gè) null 元素的位置下標(biāo)。
return i;
}
了解了 expungeStaleEntry 方法的內(nèi)部實(shí)現(xiàn)細(xì)節(jié)之后就可以把這個(gè)方法當(dāng)做一個(gè)黑盒,作用是清理傳入下標(biāo)位置的過時(shí) Entry,入?yún)橐粋€(gè)過時(shí) Entry 的下標(biāo)。
cleanSomeSlots 方法
有了 expungeStaleEntry 方法的基礎(chǔ)我們就可以攻克下一個(gè)和清理過時(shí) Entry 相關(guān)的方法:cleanSomeSlots。見名之意,這個(gè)方法的作用是清除一些過時(shí) Entry。
同樣采用在源碼中添加注釋的方式剖析這個(gè)方法。
/**
* 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) {
// 入?yún)?i: 一個(gè)已知不為過時(shí) Entry 的下標(biāo)。掃描從 i 之后的位置開始。
// 入?yún)?n: 掃描次數(shù)控制值
// 是否清理了任意過時(shí) Entry 標(biāo)志,
// 為 false 代表本次方法調(diào)用未能清理任何過時(shí) Entry,為 true 代表本次方法調(diào)用至少清理了一個(gè)過時(shí) Entry。
boolean removed = false;
ThreadLocal.ThreadLocalMap.Entry[] tab = table;
int len = tab.length;
// doWhile 循環(huán),至少執(zhí)行一次。
do {
i = nextIndex(i, len);
ThreadLocal.ThreadLocalMap.Entry e = tab[i];
if (e != null && e.get() == null) {
// 進(jìn)入到當(dāng)前分支說明當(dāng)前 Entry 為過時(shí) Entry。
// 將 n 置為數(shù)組容量,將當(dāng)前循環(huán)遍歷次數(shù)進(jìn)行追增(n 變大了)。
n = len;
// 將標(biāo)志置為 true,證明本次方法調(diào)用并不是無功而返。
removed = true;
// 調(diào)用清理過時(shí) Entry 方法,并將 expungeStaleEntry 方法返回的 null 元素的下標(biāo)賦值給 i,
// 在這之間的下標(biāo)都在 expungeStaleEntry 方法中進(jìn)行了清理,所以這里直接跳過避免重復(fù)操作。
i = expungeStaleEntry(i);
}
// >>>= 無符號右移并賦值,相當(dāng)于除以 2 操作。
} while ( (n >>>= 1) != 0);
// 返回本次是否至少清理了一個(gè)過時(shí) Entry。
return removed;
}
cleanSomeSlots 方法在 set 和 remove 方法調(diào)用中會(huì)被調(diào)用到,這個(gè)方法在完全不掃描以及全量掃描中做了一個(gè)平衡,采用以對數(shù)的方式進(jìn)行掃描,并且如果發(fā)現(xiàn)了過時(shí) Entry 則會(huì)再追增對數(shù)次掃描,使得在保證 set 方法和 remove 方法的執(zhí)行效率的情況下一定程度上清理了過時(shí) Entry。
replaceStaleEntry 方法
下面我們來看一下最后一個(gè)與清理過時(shí) key 有關(guān)的方法:replaceStaleEntry,通過方法名我們可以推測出這個(gè)方法的作用是替換過時(shí)條目,那么用什么替換呢,是 set 方法傳過來的 Entry。
同樣采用在源碼中添加注釋的方式剖析這個(gè)方法,這個(gè)方法有些許難度,如果大家不理解,可以多閱讀幾遍。
/**
* 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) {
// 入?yún)?key: set 操作傳過來的 key
// 入?yún)?value: set 操作傳過來的 value,與參數(shù) key 相關(guān)聯(lián)
// 入?yún)?staleSlot: 待替換的過時(shí) Entry 的下標(biāo)
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 變量目標(biāo)是存儲(chǔ)當(dāng)前區(qū)間段(兩個(gè) null 元素之間),第一個(gè)過時(shí) Entry 的下標(biāo)。
// 將入?yún)⒌倪^時(shí) Entry 下標(biāo)賦值給 slotToExpunge。
int slotToExpunge = staleSlot;
// 這里需要格外注意一下,這里并不是遞增下標(biāo),而是對下標(biāo)進(jìn)行遞減。
// prevIndex ((i - 1 >= 0) ? i - 1 : len - 1)。
// 向前進(jìn)行遍歷直到遇到為 null 的元素。
for (int i = prevIndex(staleSlot, len);
(e = tab[i]) != null;
i = prevIndex(i, len))
if (e.get() == null)
// 將遍歷過程中過時(shí) Entry 的下標(biāo)賦值給 slotToExpunge 變量。
// 經(jīng)過當(dāng)前遍歷邏輯,slotToExpunge 將存儲(chǔ)兩個(gè) null 元素之間第一個(gè)過時(shí) key 的下標(biāo)。
slotToExpunge = i;
// Find either the key or trailing null slot of run, whichever
// occurs first
// 這里是由入?yún)⒌倪^時(shí) Entry 下標(biāo)開始向后遍歷,直到遇到 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) {
// 進(jìn)入當(dāng)前分支表示在遍歷的過程中找到了被 set 的 Entry 對象的本體。
// 將和 key 關(guān)聯(lián)的新 value 值賦值給本體。
e.value = value;
// 操作一
// 將 Entry 對象本體和入?yún)?staleSlot 位置的過時(shí) Entry 進(jìn)行交換,
// 結(jié)果是set操作的 key 與 value,無論之前本體存儲(chǔ)在哪里,
// 最終都會(huì)存儲(chǔ)在入?yún)⒌?staleSlot 下標(biāo),符合方法名中的 replace 含義。
tab[i] = tab[staleSlot];
tab[staleSlot] = e;
// Start expunge at preceding stale entry if it exists
if (slotToExpunge == staleSlot)
// 如果當(dāng)前區(qū)間段第一個(gè)過時(shí) Entry 下標(biāo)仍是 staleSlot 下標(biāo),
// 那么需要將當(dāng)前 i 下標(biāo)賦值給 slotToExpunge ,因?yàn)?staleSlot 下標(biāo)已經(jīng)存儲(chǔ)了 set 操作的 Entry 對象,
// 導(dǎo)致當(dāng)前 i 下標(biāo)變成了第一個(gè)過時(shí) Entry 的下標(biāo)。
slotToExpunge = i;
// 先調(diào)用 expungeStaleEntry 方法清除 slotToExpunge 下標(biāo)的過時(shí) Entry,
// 再從 expungeStaleEntry 方法返回的 null 元素的下標(biāo)開始執(zhí)行 cleanSomeSlots 方法。
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
// 已經(jīng)完成替換過時(shí)條目操作,退出當(dāng)前方法。
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)
// 進(jìn)入當(dāng)前分支說明當(dāng)前 Entry 是過時(shí) Entry。
// 如果當(dāng)前區(qū)間段第一個(gè)過時(shí) Entry 下標(biāo)仍是入?yún)⒌?staleSlot 下標(biāo),
// 則需要將當(dāng)前位置下標(biāo)賦值給 slotToExpunge,因?yàn)樽罱K當(dāng)前位置的過時(shí) Entry 將是
// 當(dāng)前區(qū)間段的第一個(gè)過時(shí) Entry。因?yàn)?staleSlot 下標(biāo)位置的過時(shí) Entry 在之后的邏輯
// 里要么被交換到當(dāng)前下標(biāo)之后(上文操作一),要么被新的 set 傳入的 Entry 覆蓋掉(下文操作二)。
slotToExpunge = i;
}
// If key not found, put new entry in stale slot
// 操作二
// 代碼執(zhí)行到當(dāng)前位置說明 set 的 key 與 value 是一個(gè)新的 Entry,在之前并不存在。
// 以 set 方法傳入的 key 和 value 值 new 一個(gè)新的 Entry 對象,并覆蓋在入?yún)⒌?staleSlot 下標(biāo)處。
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 相等則說明當(dāng)前區(qū)間段只有入?yún)?staleSlot 位置有過時(shí) Entry,
// 并且該過時(shí) Entry 已被覆蓋,所以無需清理,無需進(jìn)入當(dāng)前分支。
// 進(jìn)入當(dāng)前分支說明當(dāng)前區(qū)間段,除了被覆蓋的過時(shí) Entry,至少還存在一個(gè)過時(shí) Entry,
// slotToExpunge 下標(biāo)為第一個(gè)過時(shí) Entry 的下標(biāo)。
// 先調(diào)用 expungeStaleEntry 方法清除 slotToExpunge 下標(biāo)的過時(shí) Entry,
// 再從 expungeStaleEntry 方法返回的 null 元素的下標(biāo)開始執(zhí)行 cleanSomeSlots 方法。
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}
在方法中 slotToExpunge 變量之所以始終要存儲(chǔ)當(dāng)前區(qū)間段(兩個(gè) null 元素之間)的第一個(gè)過時(shí) Entry,是因?yàn)槊慨?dāng)刪除一個(gè)過時(shí) Entry 后都會(huì)對后續(xù) Entry 進(jìn)行 rehash 操作,如果清理的不是第一個(gè)過時(shí) Entry,那么在后續(xù)其他邏輯觸發(fā)清理第一個(gè)過時(shí) Entry 時(shí)還會(huì)將剛剛 rehash 過的元素再次 rehash 一遍,極大的影響效率。
至此在 ThreadLocalMap 中涉及清理過時(shí)Entry的三個(gè)方法都已剖析完畢,下面我們來羅列一下什么時(shí)候會(huì)觸發(fā)這三個(gè)方法。
清理方法調(diào)用梳理
為避免截圖過多影響閱讀體驗(yàn),這里將只粘出調(diào)用的起點(diǎn),并給調(diào)用鏈路,大家后續(xù)可以自己在源碼中點(diǎn)一點(diǎn)。
get方法
調(diào)用鏈路:ThreadLocal#get
->ThreadLocalMap#getEntry
->ThreadLocalMap#getEntryAfterMiss
->ThreadLocalMap#expungeStaleEntry
圖片
set方法
調(diào)用鏈路 1:ThreadLocal#set->ThreadLocalMap#set->ThreadLocalMap#replaceStaleEntry
調(diào)用鏈路 2:ThreadLocal#set->ThreadLocalMap#set->ThreadLocalMap#cleanSomeSlots
調(diào)用鏈路 3:ThreadLocal#set->ThreadLocalMap#set->ThreadLocalMap#rehash->ThreadLocalMap#expungeStaleEntries->ThreadLocalMap#expungeStaleEntry
圖片
remove方法
調(diào)用鏈路:ThreadLocal#remove->ThreadLocalMap#remove->ThreadLocalMap#expungeStaleEntry
圖片
總結(jié)
通過兩期文章的深度剖析,大家應(yīng)該對 ThreadLocal 的 API 使用以及內(nèi)存泄露問題有了進(jìn)一步的理解。
ThreadLocal 優(yōu)勢是無鎖化提升并發(fā)性能和簡化變量的傳遞邏輯。
在實(shí)際業(yè)務(wù)中使用 ThreadLocal 類時(shí)應(yīng)該在恰當(dāng)位置調(diào)用 remove 方法顯式移除值。
盡可能的避免觸發(fā) ThreadLocal 清理過時(shí) Entry 的邏輯,從而提高 ThreadLocal 性能。
例如使用繼承的 ThreadLocal 類,并重寫 finalize 方法,確保 ThreadLocal 對象在被垃圾回收前,remove 方法會(huì)被調(diào)用。