10.22 吃透源碼的每一個細節和設計原理--ThreadLocal

專注於Java領域優質技術,歡迎關注

引言

ThreadLocal 是面試過程中非常高頻的一個類,這類的複雜程度絕對是可以帶出一系列連環炮的面試轟炸。biu biu biu ~~~~.

一直覺得自己對這個類很瞭解了,但是直到去看源碼,接二連三的技術浮出水面(弱引用,避免內存溢出的操作,開放地址法解決hash 衝突,各種內部類的複雜的關係),看到你懷疑人生,直到根據代碼一步一步的畫圖才最終理解(所以本篇文章會有大量的圖)。 這裡也給大家一個啟示,面對複雜的事情的時候,實在被問題繞暈了,就畫圖吧,藉助圖可以讓問題可視化,便於理解。

WAHT

ThreadLocal 是一個線程的本地變量,也就意味著這個變量是線程獨有的,是不能與其他線程共享的,這樣就可以避免資源競爭帶來的多線程的問題,這種解決多線程的安全問題和lock(這裡的lock 指通過synchronized 或者Lock 等實現的鎖) 是有本質的區別的:

  1. lock 的資源是多個線程共享的,所以訪問的時候需要加鎖。
  2. ThreadLocal 是每個線程都有一個副本,是不需要加鎖的。
  3. lock 是通過時間換空間的做法。
  4. ThreadLocal 是典型的通過空間換時間的做法。

當然他們的使用場景也是不同的,關鍵看你的資源是需要多線程之間共享的還是單線程內部共享的

使用

ThreadLocal 的使用是非常簡單的,看下面的代碼

吃透源碼的每一個細節和設計原理--ThreadLocal

看到這裡是不是覺得特別簡單?別高興太早,點進去代碼看看,你絕對會懷疑人生

源碼分析

在分析源碼之前先畫一下ThreadLocal ,ThreadLocalMap 和Thread 的關係,如果你對他們的關係還不瞭解的話,請看我的另一篇文章BAT面試必考:ThreadLocal ,ThreadLocalMap 和Thread 的關係

吃透源碼的每一個細節和設計原理--ThreadLocal

set 方法

吃透源碼的每一個細節和設計原理--ThreadLocal

createMap 方法只是在第一次設置值的時候創建一個ThreadLocalMap 賦值給Thread 對象的threadLocals 屬性進行綁定,以後就可以直接通過這個屬性獲取到值了。從這裡可以看出,為什麼說ThreadLocal 是線程本地變量來的了

吃透源碼的每一個細節和設計原理--ThreadLocal

值真正是放在ThreadLocalMap 中存取的,ThreadLocalMap 內部類有一個Entry 類,key是ThreadLocal 對象,value 就是你要存放的值,上面的代碼value 存放的就是hello word。ThreadLocalMap 和HashMap的功能類似,但是實現上卻有很大的不同:

  1. HashMap 的數據結構是數組+鏈表
  2. ThreadLocalMap的數據結構僅僅是數組
  3. HashMap 是通過鏈地址法解決hash 衝突的問題
  4. ThreadLocalMap 是通過開放地址法來解決hash 衝突的問題
  5. HashMap 裡面的Entry 內部類的引用都是強引用
  6. ThreadLocalMap裡面的Entry 內部類中的key 是弱引用,value 是強引用

為什麼ThreadLocalMap 採用開放地址法來解決哈希衝突?

jdk 中大多數的類都是採用了鏈地址法來解決hash 衝突,為什麼ThreadLocalMap 採用開放地址法來解決哈希衝突呢?首先我們來看看這兩種不同的方式

鏈地址法

這種方法的基本思想是將所有哈希地址為i的元素構成一個稱為同義詞鏈的單鏈表,並將單鏈表的頭指針存在哈希表的第i個單元中,因而查找、插入和刪除主要在同義詞鏈中進行。列如對於關鍵字集合{12,67,56,16,25,37, 22,29,15,47,48,34},我們用前面同樣的12為除數,進行除留餘數法:

吃透源碼的每一個細節和設計原理--ThreadLocal

開放地址法

這種方法的基本思想是一旦發生了衝突,就去尋找下一個空的散列地址(這非常重要,源碼都是根據這個特性,必須理解這裡才能往下走),只要散列表足夠大,空的散列地址總能找到,並將記錄存入。

比如說,我們的關鍵字集合為{12,33,4,5,15,25},表長為10。 我們用散列函數f(key) = key mod l0。 當計算前S個數{12,33,4,5}時,都是沒有衝突的散列地址,直接存入(藍色代表為空的,可以存放數據):

吃透源碼的每一個細節和設計原理--ThreadLocal

計算key = 15時,發現f(15) = 5,此時就與5所在的位置衝突。於是我們應用上面的公式f(15) = (f(15)+1) mod 10 =6。於是將15存入下標為6的位置。這其實就是房子被人買了於是買下一間的作法:

吃透源碼的每一個細節和設計原理--ThreadLocal

鏈地址法和開放地址法的優缺點

開放地址法:

  1. 容易產生堆積問題,不適於大規模的數據存儲。
  2. 散列函數的設計對沖突會有很大的影響,插入時可能會出現多次衝突的現象。
  3. 刪除的元素是多個衝突元素中的一個,需要對後面的元素作處理,實現較複雜。

鏈地址法:

  1. 處理衝突簡單,且無堆積現象,平均查找長度短。
  2. 鏈表中的結點是動態申請的,適合構造表不能確定長度的情況。
  3. 刪除結點的操作易於實現。只要簡單地刪去鏈表上相應的結點即可。
  4. 指針需要額外的空間,故當結點規模較小時,開放定址法較為節省空間。

ThreadLocalMap 採用開放地址法原因

  1. ThreadLocal 中看到一個屬性 HASH_INCREMENT = 0x61c88647 ,0x61c88647 是一個神奇的數字,讓哈希碼能均勻的分佈在2的N次方的數組裡, 即 Entry[] table,關於這個神奇的數字google 有很多解析,這裡就不重複說了
  2. ThreadLocal 往往存放的數據量不會特別大(而且key 是弱引用又會被垃圾回收,及時讓數據量更小),這個時候開放地址法簡單的結構會顯得更省空間,同時數組的查詢效率也是非常高,加上第一點的保障,衝突概率也低

弱引用

如果對弱引用不了解的同學,先看下我之前的寫的一篇文章別再找了,一文徹底解析Java 中的弱引用(參考官網)系

接下來我們看看ThreadLocalMap 中的存放數據的內部類Entry 的實現源碼

吃透源碼的每一個細節和設計原理--ThreadLocal

我們可以知道Entry 的key 是一個弱引用,也就意味這可能會被垃圾回收器回收掉

threadLocal.get()==null

也就意味著被回收掉了

ThreadLocalMap set 方法

吃透源碼的每一個細節和設計原理--ThreadLocal

吃透源碼的每一個細節和設計原理--ThreadLocal

還是拿上面解釋開放地址法解釋的例子來說明下。 比如說,我們的關鍵字集合為{12,33,4,5,15,25},表長為10。 我們用散列函數f(key) = key mod l0。 當計算前S個數{12,33,4,5,15,25}時,並且此時key=33,k=5 已經過期了(藍色代表為空的,可以存放數據,紅色代表key 過期,過期的key為null):

吃透源碼的每一個細節和設計原理--ThreadLocal

吃透源碼的每一個細節和設計原理--ThreadLocal

replaceStaleEntry 這個方法

吃透源碼的每一個細節和設計原理--ThreadLocal

吃透源碼的每一個細節和設計原理--ThreadLocal

吃透源碼的每一個細節和設計原理--ThreadLocal

第一個for 循環是向前遍歷數據的,直到遍歷到空的entry 就停止(這個是根據開放地址的線性探測法),這裡的例子就是遍歷到index=1就停止了。向前遍歷的過程同時會找出過期的key,這個時候找到的是下標index=3 的為過期,進入到

if (e.get() == null) slotToExpunge = i;

注意此時slotToExpunge=3,staleSlot=5

第二個for 循環是從index=staleSlot開始,向後編列的,找出是否有和當前匹配的key,有的話進行清理過期的對象和重新設置當前的值。這個例子遍歷到index=6 的時候,匹配到key=15的值,進入如下代碼

吃透源碼的每一個細節和設計原理--ThreadLocal

先進行數據交換,注意此時slotToExpunge=3,staleSlot=5,i=6。這裡就是把5 和6 的位置的元素進行交換,並且設置新的value=new,交換後的圖是這樣的

吃透源碼的每一個細節和設計原理--ThreadLocal

為什麼要交換

這裡解釋下為什麼交換,我們先來看看如果不交換的話,經過設置值和清理過期對象,會是以下這張圖

吃透源碼的每一個細節和設計原理--ThreadLocal

這個時候如果我們再一次設置一個key=15,value=new2 的值,通過f(15)=5,這個時候由於上次index=5是過期對象,被清空了,所以可以存在數據,那麼就直接存放在這裡了

吃透源碼的每一個細節和設計原理--ThreadLocal

你看,這樣整個數組就存在兩個key=15 的數據了,這樣是不允許的,所以一定要交換數據expungeStaleEntry

吃透源碼的每一個細節和設計原理--ThreadLocal

吃透源碼的每一個細節和設計原理--ThreadLocal

接下來我們詳細模擬下整個過程 根據我們的例子,key=5,15,25 都是衝突的,並且k=5的值已經過期,經過replaceStaleEntry 方法,在進入expungeStaleEntry 方法之前,數據結構是這樣的

吃透源碼的每一個細節和設計原理--ThreadLocal

此時傳進來的參數staleSlot=6,

吃透源碼的每一個細節和設計原理--ThreadLocal

這個時候會把index=6設置為null,數據結構變成下面的情況

吃透源碼的每一個細節和設計原理--ThreadLocal

接下來我們會遍歷到i=7,經過int h = k.threadLocalHashCode & (len - 1) (實際上對應我們的舉例的函數int h= f(25)); 得到的h=5,而25實際存放在index=7 的位置上,這個時候我們需要從h=5的位置上重新開始編列,直到遇到空的entry 為止

吃透源碼的每一個細節和設計原理--ThreadLocal

這個時候h=6,並把k=25 的值移到index=6 的位置上,同時設置index=7 為空,如下圖

吃透源碼的每一個細節和設計原理--ThreadLocal

其實目的跟replaceStaleEntry 交換位置的原理是一樣的,為了防止由於回收掉中間那個衝突的值,導致後面衝突的值沒辦法找到(因為e==null 就跳出循環了)

ThreadLocal 內存溢出問題

通過上面的分析,我們知道expungeStaleEntry() 方法是幫助垃圾回收的,根據源碼,我們可以發現 get 和set 方法都可能觸發清理方法expungeStaleEntry(),所以正常情況下是不會有內存溢出的 但是如果我們沒有調用get 和set 的時候就會可能面臨著內存溢出,養成好習慣不再使用的時候調用remove(),加快垃圾回收,避免內存溢出

退一步說,就算我們沒有調用get 和set 和remove 方法,線程結束的時候,也就沒有強引用再指向ThreadLocal 中的ThreadLocalMap了,這樣ThreadLocalMap 和裡面的元素也會被回收掉,但是有一種危險是,如果線程是線程池的, 在線程執行完代碼的時候並沒有結束,只是歸還給線程池,這個時候ThreadLocalMap 和裡面的元素是不會回收掉的

來源:掘金 鏈接:https://juejin.im/post/5d8b2bde51882509372faa7c


分享到:


相關文章: