先給出結論: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一下靠譜。