我驚了!!!ThreadLocal 源碼存在內(nèi)存泄露的 Bug!!!

點擊上方老周聊架構(gòu)關(guān)注我
一、前言
寫這篇文章的目的是因為現(xiàn)在網(wǎng)上很多關(guān)于 ThreadLocal 的文章,很大一部分都不太準確。
比如說:
ThreadLocal 內(nèi)部有個 map,鍵為線程對象;
ThreadLocal 的數(shù)據(jù)結(jié)構(gòu)是個數(shù)組;
還有說 ThreadLocal 存在內(nèi)存泄露,但里面的 get、set 以及 remove 方法能防止 ThreadLocal 內(nèi)存泄露問題。
都是不準確的哈,太誤導人了。這里老周先來點開胃小菜,先說一下第一個問題。
1.1 ThreadLocal 的內(nèi)部 ThreadLocalMap,鍵為 ThreadLocal。
那些說鍵為 Thread 對象的,它們不看源碼的嗎?


事實真的是這樣嗎?源碼也要看仔細點吧,繼續(xù)跟 createMap 方法,你會發(fā)現(xiàn):

t.threadLocals 其實是 Thread 內(nèi)部的 ThreadLocalMap,這里正在給 Thread 的 ThreadLocalMap 賦值呢,而且 ThreadLocalMap 的 key 是 this,也就是當前 ThreadLocal,而不是 Thread。
好了第一個錯誤我們搞清楚了,我們繼續(xù)來搞清楚第二個錯誤。
1.2 ThreadLocal 的數(shù)據(jù)結(jié)構(gòu)是個環(huán)形數(shù)組
說 ThreadLocal 的數(shù)據(jù)結(jié)構(gòu)是個數(shù)組也是沒有仔細看源碼的,好多文章畫的圖說 ThreadLocal 的數(shù)據(jù)結(jié)構(gòu)是數(shù)組,太誤導人了。
這里老周給出正確的 ThreadLocal 的數(shù)據(jù)結(jié)構(gòu)
1.2.1 ThreadLocal 的數(shù)據(jù)結(jié)構(gòu)

ThreadLocalMap 維護了 Entry 環(huán)形數(shù)組,數(shù)組中元素 Entry 的邏輯上的 key 為某個 ThreadLocal 對象(實際上是指向該 ThreadLocal 對象的弱引用),value 為代碼中該線程往該 ThreadLoacl 變量實際塞入的值。
1.2.2 ThreadLocal 類結(jié)構(gòu)

1.2.3 Thread、ThreadLocal、ThreadLocalMap 的關(guān)系

1.3 ThreadLocal 里的 get、set 以及 remove 方法能保證不內(nèi)存泄露嗎?
這個問題是我們本文重點分析的問題,這里老周先說下結(jié)論。
get,set 兩個方法都不能完全防止內(nèi)存泄漏,還是每次用完 ThreadLocal 都勤奮的 remove 一下靠譜。
在詳細分析這個問題之前,我們下面會來看下所需的前置知識。
二、內(nèi)存泄露
2.1 什么是內(nèi)存泄露?
首先你得知道什么叫內(nèi)存泄露吧,不然后面分析會很吃力。
內(nèi)存泄漏(Memory Leak)是指程序中已動態(tài)分配的堆內(nèi)存由于某種原因程序未釋放或無法釋放,造成系統(tǒng)內(nèi)存的浪費,導致程序運行速度減慢甚至系統(tǒng)崩潰等嚴重后果。
站在 Java 的角度來說,就是 JVM 創(chuàng)建的對象永遠都無法訪問到,但是 GC 又不能回收對象所占用的內(nèi)存。少量的內(nèi)存泄漏并不會出現(xiàn)什么嚴重問題,無非是浪費了一些內(nèi)存資源罷了,但是隨著時間的積累,內(nèi)存泄漏的越來越多就會導致內(nèi)存溢出,程序崩潰。
2.2 Java 四中引用類型
在 JDK1.2 之前,“引用”的概念過于狹隘,如果 Reference 類型的數(shù)據(jù)存儲的是另外一塊內(nèi)存的起始地址,就稱該 Reference 數(shù)據(jù)是某塊地址、對象的引用,對象只有兩種狀態(tài):被引用、未被引用。
這樣的描述未免過于僵硬,對于這一類對象則無法描述:內(nèi)存足夠時暫不回收,內(nèi)存吃緊時進行回收。例如:緩存數(shù)據(jù)。
在 JDK1.2 之后,Java 對引用的概念做了一些擴充,將引用分為四種,由強到弱依次為:
強引用
在 Java 中最常見的就是強引用,把一個對象賦給一個引用變量,這個引用變量就是一個強引用。當一個對象被強引用變量引用時,它處于可達狀態(tài),它是不可能被垃圾回收機制回收的,即使該對象以后永遠都不會被用到 JVM 也不會回收。因此強引用是造成 Java 內(nèi)存泄漏的主要原因之一。
軟引用
軟引用需要用 SoftReference 類來實現(xiàn),對于只有軟引用的對象來說,當系統(tǒng)內(nèi)存足夠時它不會被回收,當系統(tǒng)內(nèi)存空間不足時它會被回收。軟引用通常用在對內(nèi)存敏感的程序中。
弱引用
弱引用需要用 WeakReference 類來實現(xiàn),它比軟引用的生存期更短,對于只有弱引用的對象來說,只要垃圾回收機制一運行,不管 JVM 的內(nèi)存空間是否足夠,總會回收該對象占用的內(nèi)存。
虛引用
虛引用需要 PhantomReference 類來實現(xiàn),它不能單獨使用,必須和引用隊列聯(lián)合使用。虛引用的主要作用是跟蹤對象被垃圾回收的狀態(tài)。
三、為什么 ThreadLocalMap 采用開放地址法來解決哈希沖突
JDK 中大多數(shù)的類都是采用了鏈地址法來解決 hash 沖突,為什么 ThreadLocalMap 采用開放地址法來解決哈希沖突呢?首先我們來看看這兩種不同的方式:
3.1 鏈地址法
這種方法的基本思想是將所有哈希地址為 i 的元素構(gòu)成一個稱為同義詞鏈的單鏈表,并將單鏈表的頭指針存在哈希表的第 i 個單元中,因而查找、插入和刪除主要在同義詞鏈中進行。例如對于關(guān)鍵字集合{28, 93, 90, 3, 21, 11, 19, 31, 18},我們假如數(shù)組的長度為 8,那我們用 8 為除數(shù),進行除留余數(shù)法:

3.2 開放地址法
這種方法的基本思想是一旦發(fā)生了沖突,就去尋找下一個空的散列地址(這非常重要,源碼都是根據(jù)這個特性,必須理解這里才能往下走),只要散列表足夠大,空的散列地址總能找到,并將記錄存入。
我們還是以上面的關(guān)鍵字集合 {28, 93, 90, 3, 21, 11, 19, 31, 18} 來演示,我們用散列函數(shù) f(key) = key mod 16。當計算前 S 個數(shù) {28, 93, 90, 3, 21, 11} 時,都是沒有沖突的散列地址,直接存入(藍色下標代表已存入了數(shù)據(jù),空白的下標可以存放數(shù)據(jù)):

當計算到集合中的 19 的時候,發(fā)現(xiàn) f(19) = 3,此時就與 3 所在的位置沖突。
于是我們應用上面的公式f(19) = (f(19)+1) mod 10 = 4。于是將 19 存入下標為 4 的位置。這其實就是房子被人買了于是買下一間的做法,以此類推。

3.3 鏈地址法和開放地址法的優(yōu)缺點
3.3.1 鏈地址法
優(yōu)點:
處理沖突簡單,且無堆積現(xiàn)象,平均查找長度短;
鏈表中的結(jié)點是動態(tài)申請的,適合構(gòu)造表不能確定長度的情況;
相對而言,拉鏈法的指針域可以忽略不計,因此較開放地址法更加節(jié)省空間;
插入結(jié)點應該在鏈首,刪除結(jié)點比較方便,只需調(diào)整指針而不需要對其他沖突元素作調(diào)整。
缺點:
指針占用較大空間時,會造成空間浪費。
3.3.2 開放地址法
優(yōu)點:
當節(jié)點規(guī)模較少,或者裝載因子較少的時候,使用開發(fā)尋址較為節(jié)省空間,如果將鏈式表的指針用于擴大散列表的規(guī)模時,可使得裝載因子變小從而減少了開放尋址中的沖突,從而提高平均查找效率。
缺點:
容易產(chǎn)生堆積問題;
不適于大規(guī)模的數(shù)據(jù)存儲;
結(jié)點規(guī)模很大時會浪費很多空間;
散列函數(shù)的設(shè)計對沖突會有很大的影響;
插入時可能會出現(xiàn)多次沖突的現(xiàn)象,刪除的元素是多個沖突元素中的一個,需要對后面的元素作處理,實現(xiàn)較復雜。
3.4 ThreadLocalMap 采用開放地址法原因??
ThreadLocal 中看到一個屬性 HASH_INCREMENT = 0x61c88647 ,0x61c88647 是一個神奇的數(shù)字,讓哈希碼能均勻的分布在 2 的 N 次方的數(shù)組里, 即 Entry[] table,關(guān)于這個神奇的數(shù)字網(wǎng)上有很多解析,這里就不多說了。
ThreadLocal 往往存放的數(shù)據(jù)量不會特別大(而且key 是弱引用又會被垃圾回收,及時讓數(shù)據(jù)量更小),這個時候開放地址法簡單的結(jié)構(gòu)會顯得更省空間,同時數(shù)組的查詢效率也是非常高,加上第一點的保障,沖突概率也低。
四、ThreadLocal 內(nèi)存泄露
上面我們已經(jīng)說了內(nèi)存泄露的概念,這里我還是再說下 ThreadLocal 的內(nèi)存泄露是怎么回事。
根據(jù) ThreadLocal 的源碼,可以畫出一些對象之間的引用關(guān)系圖,實線表示強引用,虛線表示弱引用:

Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value
永遠無法回收,造成內(nèi)存泄露。
那 Java 源碼團隊就沒有啥解決方案嗎?當然有,前面已經(jīng)說過,由于 key 是弱引用,因此 ThreadLocal 可以通過 key.get()==null 來判斷 key 是否已經(jīng)被回收,如果 key 被回收,就說明當前 Entry 是一個廢棄的過期節(jié)點,ThreadLocal 會自發(fā)的將其清理掉。
ThreadLocal 會在以下過程中清理過期節(jié)點:
調(diào)用 set() 方法時,采樣清理、全量清理,擴容時還會繼續(xù)檢查。
調(diào)用 get() 方法,沒有直接命中,向后環(huán)形查找。
調(diào)用 remove() 時,除了清理當前 Entry,還會向后繼續(xù)清理。
那么正好回到我們前言的第三個問題:
還有說 ThreadLocal 存在內(nèi)存泄露,但里面的 get、set 以及 remove 方法能防止 ThreadLocal 內(nèi)存泄露問題。
那么老周的問題是:get、set 以及 remove 方法真的能防止 ThreadLocal 內(nèi)存泄露嗎?
這里你自己可以翻看源碼思考下再來看我接下寫的,這樣有思考收獲才更大。
這里分界線假裝你思考完了哈,那我們就來講本文的最重要的一部分了。
事先約定:

4.1 remove 方法能否防止內(nèi)存泄露?
一開始都是有效的 entry,并且每個 entry 的 key 通過散列算法后算出的位置都是自己所在的位置(都在自己的位置上的話之后的線性清掃中不會造成搬移,因為 ThreadLocalMap 的散列表用的是開放地址法,所以 entry 可能因為 hash 沖突而不在自己位置上)
要達成下面的效果,就要一直沒有失效的 entry 出現(xiàn),并且一直實現(xiàn)插入,也就是一直執(zhí)行 set 方法。
假設(shè) entry 循環(huán)數(shù)組有 16 個槽位


如果執(zhí)行一次 remove,把圖中的某個 entry 無效化。
??

然后我們來看下 ThreadLocal#remove 方法的實現(xiàn):
因為每個 entry 都在自己的位置上,所以下圖的 if (e.get() == key) 會在第一個循環(huán)就成立,也就是 remove 會執(zhí)行 e.clear() 來把弱引用置空,無效化,并且執(zhí)行一次線性清掃后返回。

我們跟著 expungeStaleEntry 方法進去看:



向后遍歷整個數(shù)組,直到遇到空槽為止,并且第一種情況
(k == null) 為真的情況下,會把無效 entry 置空,防止內(nèi)存泄漏。其實就是向后掃描,遇到無效的就順帶干掉,直到遇到空槽為止。
接著再看第二種情況 (k != null)的分支的(h != i)場景:
也就是說遇到的 entry 是有效的,但是不是在自己原本的位置上,而是被 hash 沖突到其它位置上了,則把它們搬去離原本位置最近的后邊空槽上。這樣在 get 的時候,會最快找到這個 entry,減少開放地址法遍歷數(shù)組的時間。

小結(jié):
執(zhí)行 remove 方法后,會執(zhí)行
e.clear()來把弱引用置空,無效化。并且執(zhí)行一次線性清掃后返回。
線性清掃把要清掃的位置給置空了,然后繼續(xù)往后遍歷,直到遇到空槽位為止,如果遇到無效entry, 就把無效 entry 的槽位置空,防止內(nèi)存泄漏。
第二種情況可能遇到的 entry 是有效的,但是不是在自己原本的位置上,而是被 hash 沖突到其它位置上了,則把它們搬去離原本位置最近的后邊空槽上。這樣在 get 的時候,會最快找到這個 entry,減少開放地址法遍歷數(shù)組的時間。
結(jié)論:
remove 方法能防止內(nèi)存泄露
4.2 set 方法能否防止內(nèi)存泄露?
看完 remove 方法后,我們再來看下 set 方法能否防止內(nèi)存泄漏。
因為每個 entry 都在自己的位置上,并且沒有遇到無效的 entry,最終的效果只是把 remove 的位置置為空槽。所以上面 remove 方法得到的效果圖:

同理,假設(shè)再經(jīng)歷 4 次 remove,可以得出下面的效果圖:

如果此時,這時候正好有兩個 entry 的 key,也即是 ThreadLocal 的所有強應用被置空,于是這兩個 entry 無效。

如果之后只執(zhí)行 set 方法,是否會內(nèi)存泄漏呢?是否任意調(diào)用 set 之后就保證內(nèi)存不會泄漏了呢?
帶著這兩個問題我們來看下 ThreadLocal#set 方法

4.2.1 代碼塊①
遇到 key 和我們當前調(diào)用 set 的 ThreadLocal 相等的 entry,則只用直接把 entry 的 value 設(shè)置一下就好了,和 HashMap 的 put(key, A); put(key, B); 中 A 被替換成 B 同理。
4.2.2 代碼塊②
遇到無效 entry,是我們重點關(guān)注的地方。
4.2.3 代碼塊③
遇到空槽,直接插入,并且嘗試指數(shù)清掃,如果指數(shù)清掃不成功并且當前 entry 的使用槽數(shù)到達閾值則重散列。
我們重點來關(guān)注代碼塊②
這里方便演示,我假設(shè)下標 9 為有效 entry,也正好是 set 方法的位置。

我們接著上面的 代碼塊② 分析,要調(diào)用到 replaceStaleEntry 方法。
下標 9 為有效 entry 的話,我們在 remove 方法中說過,set 不是在自己原本的位置上,而是被 hash 沖突到其它位置上了,則把它們搬去離原本位置最近的后邊空槽上。即下標 12 為 hash 沖突的有效 entry,我們標為綠色下標 12。


第一個 for 循環(huán)中,開始向前找,找到最靠前的無效 entry,直到遇到空槽為止,當然可能會繞循環(huán)數(shù)組一圈繞回來,但是因為使用的槽數(shù)如果到達閾值,就會 rehash,不可能所有槽都用完,所以會遇到空槽的。
如下圖:

因為沒有找到,所以 slotToExpunge = staleSlot,也就是上圖紅色下標 10 位置的 entry。
接著往下看:

我們重點關(guān)注
k == key 的情況,也就是 i 遍歷到圖中綠色下標12槽位的情況。這種情況下會執(zhí)行一次線性清掃,然后執(zhí)行對數(shù)清掃,最后返回。
如下圖:

從 slotToExpunge 位置開始,先進行一輪線性清掃:
和之前一樣,一上來先把待清掃槽位置空(紅色下標 12 無效 entry 的位置),之后遇到紅色下標 12 后面那個空位(也就是黑色下標 14 的空位),所以停下來。
線性清掃返回空位的下標做為參數(shù)傳給對數(shù)清掃。

對數(shù)清掃:清掃次數(shù) = log2(N) ,N 是循環(huán)數(shù)組大小,本例中是 16,所以要清掃 4 次,每次清掃是通過調(diào)用線性清掃實現(xiàn)的。
這里你可能會問了,為啥這里是對數(shù)清掃了?而且清掃次數(shù)為啥是 log2(N)啊?
別著急,源碼是個好東西,源碼寫的很清楚了。

老周你不要騙我,這里明明是循環(huán)數(shù)組的長度啊。

哈哈,沒錯,確實是循環(huán)數(shù)組的長度,作者想表達的是循環(huán)數(shù)組的長度是掃描控制的參數(shù),具體的清掃參數(shù)是 log2(N)。等等,那清掃參數(shù) log2(N)怎么來的呢?不著急,源碼往下看,看到 while 循環(huán)了吧,
while ( (n >>>= 1) != 0),沒錯就是這個得到清掃參數(shù)。我們例子的循環(huán)數(shù)組長度為 16,代入到里面去,無符號向右移動一位,直到等于 0 跳出循環(huán)。16->8->4->2->1,共循環(huán) 4 次,并且只有遇到無效entry時才執(zhí)行線性清掃。注:老周這里為了嚴謹,還是要再提一嘴。這里的 n 是循環(huán)數(shù)組的長度,只是 replaceStaleEntry 方法調(diào)用時,但當從插入調(diào)用時,這個參數(shù) n 是元素的數(shù)量。

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

這里 removed 返回 false,接著 cleanSomeSlots 返回,一直返回到 replaceStaleEntry,并且繼續(xù)返回,最后從 set 方法返回。

結(jié)果顯而易見,紅色下標 5 位置的無效 entry 未被清除,導致內(nèi)存泄露。

結(jié)論:
set 方法的清掃程度不夠深,set 方法并不能防止內(nèi)存泄漏。
4.3 get 方法能否防止內(nèi)存泄露?

直接跟進 getEntry 方法:

get 方法相對來說簡單點,在原本屬于當前 key 的位置上找不到當前 key 的 entry 的話,就會根據(jù)開放地址法線性遍歷找到 key 對應的 entry。
k == null 的話,執(zhí)行線性清掃 expungeStaleEntry 方法,順便把路上無效的 entry 清除掉。
還是看我們上面的例子:



根據(jù)前面說的遇到的 entry 是有效的,但是不是在自己原本的位置上,而是被 hash 沖突到其它位置上了,則把它們搬去離原本位置最近的后邊空槽上。這樣在 get 的時候,會最快找到這個 entry,減少開放地址法遍歷數(shù)組的時間。所以藍色下標 12 的位置會搬移到 黑色下標 10 號位置。
搬移后的圖:


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

結(jié)果顯而易見,紅色下標 5 位置的無效 entry 未被清除,導致內(nèi)存泄露。
結(jié)論:
get 方法的清掃程度不夠深,get 方法并不能防止內(nèi)存泄漏。
五、總結(jié)
本文主要以市面上關(guān)于 ThreadLocal 都不太準確的文章進行了一番論證并給出正確的結(jié)論。特別重點介紹了 ThreadLocal 中 ThreadLocalMap 的大致實現(xiàn)原理以及 ThreadLocal 內(nèi)存泄露的問題。
ThreadLocalMap 的 Entry 的 key 是弱引用,如果外部沒有強引用指向 key,key 就會被回收,而 value 由于 Entry 強引用指向了它,導致無法被回收,但是 value 又無法被訪問,因此發(fā)生內(nèi)存泄漏。
關(guān)于內(nèi)存泄漏,我們重點從源碼層面分析了 get、set、remove 方法,并圖文并茂的演示了 get、set 方法不能防止內(nèi)存泄漏,而 remove 方法能防止內(nèi)存泄漏的結(jié)論。
所以,開發(fā)者應該盡量在使用完畢后及時調(diào)用 remove 刪除節(jié)點,這里老周建議用 Spring 的 AOP 思想對 remove 方法進行切面,省的使用完畢后忘記調(diào)用 remove 方法清除。
歡迎大家關(guān)注我的公眾號【老周聊架構(gòu)】,Java后端主流技術(shù)棧的原理、源碼分析、架構(gòu)以及各種互聯(lián)網(wǎng)高并發(fā)、高性能、高可用的解決方案。
喜歡的話,點贊、再看、分享三連。

點個在看你最好看
