首頁 > 科技

阿里 P7大牛整理!ThreadLocal與ThreadLocalMap源碼筆記,超硬核

2021-06-02 23:12:53

ThreadLocal類

該類主要用於不同執行緒儲存自己的執行緒本地變數。本文先通過一個示例簡單介紹該類的使用方法,然後從ThreadLocal類的初始化、儲存結構、增刪資料和hash值計算等幾個方面,分析對應源碼。採用的版本為jdk1.8。

ThreadLocal物件可以在多個執行緒中被使用,通過set()方法設定執行緒本地變數,通過get()方法獲取設定的執行緒本地變數。我們先通過一個示例簡單瞭解下使用方法:

由於threadlocal設定的值是在每個執行緒中都有一個副本的,執行緒之間不會互相影響。程式碼運行的結果如下所示:

ThreadLocal-初始化

ThreadLocal類只有一個無參的構造方法,如下所示:

但其實還有一個帶參數的構造方法,不過是它的子類。ThreadLocal中定義了一個內部類SuppliedThreadLocal,為繼承自ThreadLocal類的子類。可以通過該類進行給定初始值的初始化,其定義如下:

通過TheadLocal threadLocal = Thread.withInitial(supplier);這樣的語句可以進行給定初始值的初始化。在某個執行緒第一次呼叫get()方法時,會執行initialValue()方法設定執行緒變數為傳入supplier中的值。

ThreadLocal-儲存結構

在jdk1.8版本中,使用的是TheadLocalMap這一個容器儲存執行緒本地變數。

該容器的設計思想和HashMap有很多共同之處。比如:內部定義了Entry節點儲存鍵值對(使用ThreadLocal物件作為鍵);使用一個數組儲存entry節點;設定一個閾值,超過閾值時進行擴容;通過鍵的hash值與陣列長度進行&操作確定下標索引等。但也有很多不同之處,具體我們在後續介紹ThreadLocalMap類時再詳細分析。

ThreadLocal-增刪資料

ThreadLocal類提供了get(),set()和remove()方法來操作當前執行緒的threadlocal變數副本。底層則是基於ThreadLocalMap容器來實現資料操作。

不過要注意的是:ThreadLocal中並沒有ThreadLocalMap的成員變數,ThreadLocalMap物件是Thread類中的一個成員,所以需要通過通過當前執行緒的Thread物件去獲取該容器。

每一個執行緒Thread物件都會有一個map容器,該容器會隨著執行緒的終結而回收。

設定執行緒本地變數的方法。

獲取執行緒本地變數的方法。

移除執行緒本地變數的方法

ThreadLocal-hash值計算

ThreadLocal的hash值用於ThreadLocalMap容器計算陣列下標。類中定義threadLocalHashCode表示其hash值。類中定義了靜態方法和靜態原子變數計算hash值,也就是說所有的threadLocal物件共用一個增長器。

我們使用同樣的方法定義一個測試類,定義多個不同測試類物件,看看hash值的生成情況。如下所示,可以看到hash值都不同,是共用的一個增長器。

ThreadLocalMap類

ThreadLocalMap類是ThreadLocal的內部類。其作為一個容器,為ThreadLocal提供操作執行緒本地變數的功能。每一個Thread物件中都會有一個ThreadLocalMap物件例項(成員變數threadLocals,初始值為null)。因為map是Thread物件的非公共成員,不會被併發呼叫,所以不用考慮併發風險。

後文將從資料儲存設計、初始化、增刪資料等方面分析對應源碼。

ThreadLocalMap-資料儲存設計

該map和hashmap類似,使用一個Entry陣列來儲存節點元素,定義size變量表示當前容器中元素的數量,定義threshold變數用於計算擴容的閾值。

不同的是Entry節點為WeakReference類的子類,使用引用欄位作為鍵,將弱引用欄位(通常是ThreadLocal物件)和值繫結在一起。使用弱引用是為了使得threadLocal物件可以被回收,(如果將key作為entry的一個成員變數,那執行緒銷燬前,threadLocal物件不會被回收掉,即使該threadLocal物件不再使用)。

ThreadLocalMap-初始化

提供了帶初始鍵和初始值的map構造方法,還有一個基於已有map的構造方法(用於ThreadLocal的子類InheritableThreadLocal初始化map容器,目的是將父執行緒的map傳入子執行緒,會在創建子執行緒的過程中自動執行)。如下所示:

ThreadLocalMap-移除元素

這裡將移除元素的方法放在前面,是因為其它部分會頻繁使用過時節點的移除方法。先理解這部分內容有助於後續理解其他部分。

根據key移除容器元素的方法:

移除過時節點的執行方法:

移除過時節點除了將該節點置為null之外,還要對該節點之後的節點進行移動,看看能不能往前找合適的空格轉移。

這種方法有點類似jvm垃圾回收演算法的標記-整理方法。都是將垃圾清除之後,將剩餘元素進行整理,變得更緊湊。這裡的整理是需要強制執行的,目的是為了保證開放地址法一定能在連續的非null節點塊中找到已有節點。(試想,如果把過時節點移除而不整理,該節點為null,將前後節點分開了。而如果後面有某個節點hash計算的下標在前面的節點塊,在查詢節點時通過開放地址會找不到該節點)。示意圖如下:

移除所有過時節點的方法:很簡單,全局遍歷,移除所有過時節點。

嘗試去掃描一些過時節點並清除節點,如果有節點被清除會返回true。這裡只執行了logn次掃描判斷,是為了在不掃描和全局掃描之間找到一種平衡,是上面的方法的一個平衡。

ThreadLocalMap-獲取元素

獲取容器元素的方法:

ThreadLocalMap-增加和修改元素

增加和修改容器元素的方法:

這裡在根據hash值計算出下標後,由於是開放地址解決hash衝突,會順序向後遍歷直到遇到null或遇到key對應的節點。

這裡會出現三種情況:

case1:遍歷時找到了key對應節點,這時直接修改節點的值即可;

case2:遍歷中遇到了有過時的節點(key被回收的節點);

case3:遍歷沒有遇到過時的節點,也沒有找到key對應節點,說明此時應該插入新節點(用輸入鍵值構造新節點)。因為是增加新元素,所以可以容量會超過閾值。在刪除節點後容量如果超過閾值,則要進行擴容操作。

case2:增加和修改過程中遇到已經過時的節點的處理。這裡的參數staleSlot表示key計算的下標開始往後遇到的第一個過時節點,不管map中有無key對應的節點,該位置之後一定會存入key的節點。這裡定義了一個變數slotToExpunge,其含義是左右連續非null的entry塊中第一個過時節點(記錄該位置是為了後續清除過時節點可以從slotToExpunge處開始)。示意如下:

這步操作有兩種情況:

casse2.1:從過時節點staleSlot往後查詢遇到key對應節點,則將staleSlot處節點與key對應節點交換。然後清除整理連續塊。

casse2.2:沒遇到key對應節點,說明map中不存在key對應節點,則新建一個節點填入staleSlot處。然後清除整理連續塊。

case3:增加元素後可能超過閾值導致的擴容處理

ThreadLocalMap-記憶體洩露問題以及對設計的一些思考

先來聊一聊記憶體洩漏這個概念。我的理解是有一塊記憶體空間,如果不再被使用但又不能被垃圾回收器回收掉,那麼就相當於這塊記憶體少了這塊空間,即出現了記憶體洩露問題。如果記憶體洩露的空間一直在積累,那麼最終會導致可用空間一直減少,最終可能導致程式無法運行。

ThreadLocalMap中也是有可能會出現該問題的,map中entry節點的key為弱引用,如果key沒有其它強引用,是會被垃圾收集器回收的。回收之後,map中該節點的value就不會再被使用,但value又被entry節點強引用,不會被回收。這就相當於value這塊記憶體空間發生了洩露。所以能看到在源碼中很多方法都進行了清除過時節點的操作,為的就是儘量避免記憶體洩漏。

在看源碼時,一直在思考為什麼entry節點的鍵要採用弱引用的方式。不妨反過來思考,如果entry節點將threadLocal物件作為一個成員變數,而不是採用弱引用的方式,那麼entry節點一直對key和value保持著強引用關係,即使threadlocal物件在其它地方都不再使用,該物件也不會被回收。這就會導致entry節點永遠不會被回收(只要執行緒不終結),而且也不能主動去判斷是否切斷map中threadlocal物件的引用(不知道是否還有其它地方引用到了)。

因為map是Thread物件的一個成員變數,執行緒不終結,map是不會被回收的,如果發生了記憶體洩露的問題,可能會一直積累下去,最終導致程式發生異常。而key採用弱引用加之主動的判斷過時節點(判斷是否過時很簡單,看key是否為null即可)並進行清除處理可以最大限度的減少記憶體洩露的發生。

各種Java資料,關注回覆「資料」獲取。


IT145.com E-mail:sddin#qq.com