ThreadLocal源碼解讀:內存泄露問題分析
引言
大家好,我們又見面了。今天依舊是結合源碼為大家分享個人對于 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 方法會被調用。