證明:ThreadLocal的get,set方法無法防止記憶體洩漏

執生發表於2020-07-15

  先給出結論:get,set兩個方法都不能完全防止記憶體洩漏,還是每次用完ThreadLocal都勤奮的remove一下靠譜。 

  前言: 

  看到有的部落格說在把ThreadLocal的所有強引用置空前,呼叫 set 或 get 方法的話,則可以防止這個失去所有強引用的ThreadLocal對應的value記憶體洩漏。  但是文章作者一般沒有接著向下講為什麼get,set 方法能防止記憶體洩漏。 

  本著刨根問底的精神,我們來看看原實現,驗證一下get,set方法是否真的能防止記憶體洩漏。

 

先介紹一下記憶體佈局:

  每個Thread儲存自己獨佔的ThreadLocalMap,ThreadLocalMap包含一個雜湊表(entry陣列),雜湊表裡 entry 繼承WeakReference<ThreadLocal>,並且 entry 的 key 隱式等於ThreadLocal, value 則是顯示用成員變數來儲存。

所以一個執行緒可以用不同的ThreadLocal把不同的值存在這個執行緒獨享的雜湊表的不同位置上。下面這些entry的key就是不同的ThreadLocal。當有外部的強引用 使用ThreadLocal的時候,這個ThreadLocal是有效的,但是如果強引用都置空,則只剩弱引用,GC在記憶體緊張的情況下,可能會把弱引用指向的物件回收掉。

1.ThreadLocal還有效

 

 

 

2.ThreadLocal只剩下弱引用

3.只剩弱引用,回收堆上物件

 

 

 

 

 

這樣的話,就沒有路徑可以訪問這個ThreadLocal了。

但是value還是通過ThreadLocalMap -> entry -> value -> 堆上大物件 的方式強應用著之前的value。這樣導致這塊記憶體無法被使用(如果沒有其他強應用的話),也無法被回收。稱記憶體洩漏。

 

於是ThreadLocalMap的設計者,想出了辦法:

1.在ThreadLocal get,set 的時候順帶把雜湊表中的無效entry 置空,並且把這些entry 的 value也置空,以便value被回收,也就是執行清掃操作

2.在ThreadLocal remove 的時候把對應槽位上的 entry 置空,並且把這 個entry 的 value也置空,以便value被回收。順便執行清掃操作。

 

get,set 方法真的能保證記憶體不洩露麼?

這篇文章想討論的問題是:

1.get,set方法的清掃程度是否足夠徹底,以至於可以防止記憶體洩漏。

2.用什麼方法才能保證記憶體不洩露

 

1如果成立,也即是保證如下場景記憶體不洩露:

使用多個 ThreadLocal,不是每次都使用 remove 方法,並且把一個ThreadLocal對應的所有強應用置空之前只呼叫過 get, set方法,呼叫get,set方法可以防止記憶體洩漏。

為了打破這一假設,模擬記憶體洩漏的情況,舉以下極端的例子:

 

先規定:

 

1.一開始都是有效的entry,並且每個entry的key通過雜湊演算法後算出的位置都是自己所在的位置(都在自己的位置上的話之後的線性清掃中不會造成搬移,因為ThreadLocalMap的雜湊表用的是開放定址法,所以entry可能因為hash衝突而不在自己位置上)

要達成下面的效果,就要一直沒有失效的entry出現,並且一直實現插入,也就是一直執行set方法

假設entry陣列有32個槽位

 

 

如果執行一次remove,把圖中的某個entry無效化。

   

 

 下面是實現,因為每個entry都在自己的位置上,所以下圖的if (e.get() == key) 會在第一個迴圈就成立,也就是remove會

 執行e.clear() 來把弱引用置空,無效化。並且執行一次線性清掃後返回。

 

 

關於線性清掃:

  實現較長,分段看:

 

 

  上來就把要清掃的位置給置空了(灰色entry的槽位置空):

 

接著看:

 

 

 向後遍歷整個陣列,直到遇到空槽為止,並且第一種情況 (k == null) 為真的情況下,會把無效entry置空,防止記憶體洩漏。

 其實就是向後掃描,遇到無效的就順帶幹掉,直到遇到空位置為止。

 第二種情況是 : 遇到的entry是有效的,但是不是在自己原本的位置上,而是被hash衝突所迫而在其他位置上的,則把他們搬去

 離原本位置最近的後邊空槽上。這樣在get的時候,會最快找到這個entry,減少開放定址法遍歷陣列的時間。

 

 因為每個entry都在自己的位置上,並且沒有遇到無效的entry,最終的效果只是把remove的位置置為空槽。

同理,經過幾次remove後,我們可以“挖出”下圖的效果

 

 

 

正巧,這時候有兩個entry的key,也即是ThreadLocal的所有強應用被置空,於是這兩個entry無效。

     

 

 

 

 

如果之後只執行 set 方法,是否會記憶體洩漏呢?是否任意呼叫set之後就保證記憶體不會洩漏了呢?

我們順著 set 方法的邏輯看下去,set方法從當前要set的位置開始向後遍歷,直到:

1.遇到 key 和我們當前 呼叫 set 的 ThreadLocal 相等的 entry,則只用直接把entry的value設定以下就好了,和

HashMap的 put(1, A); put(1, B); 中 A 被替換 成B 同理。(紅色框)

2.遇到無效entry,是我們關注的地方

3.遇到空槽,直接插入,並且嘗試指數清掃,如果指數清掃不成功並且當前entry的使用槽數到達閾值則重雜湊(藍色框)

 

 

 

 

我們重點關注情況2.

假設我們set的位置是下面所指處。

我們接著上面的2分析,2要呼叫到replaceStaleEntry

再假設set進去的ThreadLocal在本陣列中下面綠色位置

綠色代表這個entry不在自己的原本位置上,上面的情況是可以得到的。因為remove時執行的線性清掃是向後清掃,並且遇到空槽停下。

所以不會影響綠色entry

 

 

 

 

 

 方法一開始是找到最靠前的無效entry,直到遇到空槽為止,當然可能會繞陣列一圈繞回來

但是因為使用的槽數如果到達閾值,就會rehash,不可能所有槽都用完。所以會遇到空槽的。

表現在我們的例子中:

 

 

因為沒有找到,所以 slotToExpunge = staleSlot

也就是上圖第二個灰色entry的位置。

 接著向下看:

 

 

 我們關注 k == key 的情況,也就是 i 遍歷圖中綠色槽位的情況。 這種情況下會指向一次線性清掃,然後執行對數清掃。之後返回。

 

 

反應在圖例中:

 

 

從slotToExpunge位置開始,先進行一輪線性清掃:

同之前一樣,一上來先把待清掃槽位置空(第二個灰色的entry的位置),之後遇到第二個灰色後面那個空位,所以停下來。

線性清掃返回空位的下標做為引數傳給對數清掃。

反應到圖例:

對數清掃:清掃次數 = log2(N) ,N是雜湊表大小,本例中是32,所以要清掃5次,每次清掃是通過呼叫線性清掃實現的。

並且只有遇到無效entry時才執行線性清掃。

 

 顯然,五次掃描中都沒有無效entry

 

返回 removed (false);

 cleanSomeSlots要返回,一直返回到replaceStaleEntry,並且繼續返回,最後從set方法返回。

 

 

 結果很明顯,第一個灰色entry未被清除。

結論:set方法的清掃程度不夠深,set方法並不能防止記憶體洩漏。

get方法呢?

 

 

 

 

 

 get 方法比較簡單,在原本屬於當前 key 的位置上找不到當前 key 的 entry 的話,就會根據開放定址法線性遍歷找到 key 對應的 entry 。

順便把路上無效的entry用線性清掃清除掉。

還是剛剛的極端例子:

 

 

 

 

 

 因為是直接取線性清掃開始的位置,所以 k = key 是 true,所以返回綠色entry。查詢成功

 

 

 但是,第一個灰色entry仍然沒有被清除。

 什麼辦法可以保證萬無一失呢???

答:每次置空一個ThreadLocal的所有強引用之後,都呼叫ThreadLocal的remove方法:

 

e.clear是直接置空弱引用,這樣當前這個entry就會無效

 

 

之前說過,線性清掃會直接把第一個無效entry,也就是起點的entry槽位置空,以此達到 100 % 的回收效果。

 

結論:

get,set兩個方法都不能完全防止記憶體洩漏,還是每次用完ThreadLocal都勤奮的remove一下靠譜。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

  

 

 

 

 

 

  

相關文章