阿里架構師教你如何使用ThreadLocal及原理分析
內容導航
- 什麼是ThreadLocal
- ThreadLocal的使用
- 分析ThreadLocal的實現原理
- ThreadLocal的應用場景及問題
一、什麼是ThreadLocal
ThreadLocal,簡單翻譯過來就是本地執行緒,但是直接這麼翻譯很難理解ThreadLocal的作用,如果換一種說法,可以稱為執行緒本地儲存。簡單來說,就是ThreadLocal為共享變數在每個執行緒中都建立一個副本,每個執行緒可以訪問自己內部的副本變數。這樣做的好處是可以保證共享變數在多執行緒環境下訪問的執行緒安全性
二、ThreadLocal的使用演示
ThreadLocal的使用
沒有使用ThreadLocal時
通過一個簡單的例子來演示一下ThreadLocal的作用,這段程式碼是定義了一個靜態的成員變數 num,然後通過構造5個執行緒對這個 num做遞增
執行結果
每個執行緒都會對這個成員變數做遞增,如果執行緒的執行順序不確定,那麼意味著每個執行緒獲得的結果也是不一樣的。
使用了ThreadLocal以後
通過ThreadLocal對上面的程式碼做一個改動
執行結果
從結果可以看到,每個執行緒的值都是5,意味著各個執行緒都是從ThreadLocal的 initialValue方法中拿到預設值0並且做了 num+=5的操作,同時也意味著每個執行緒從ThreadLocal中拿到的值都是0,這樣使得各個執行緒對於共享變數num來說,是完全隔離彼此不相互影響.
ThreadLocal會給定一個初始值,也就是 initialValue()方法,而每個執行緒都會從ThreadLocal中獲得這個初始化的值的副本,這樣可以使得每個執行緒都擁有一個副本拷貝
三、從原始碼分析ThreadLocal的實現
看到這裡,估計有很多人都會和我一樣有一些疑問
- 每個執行緒的變數副本是怎麼儲存的?
- ThreadLocal是如何實現多執行緒場景下的共享變數副本隔離?
帶著疑問,來看一下ThreadLocal這個類的定義(預設情況下,JDK的原始碼都是基於1.8版本)
從ThreadLocal的方法定義來看,還是挺簡單的。就幾個方法
- get: 獲取ThreadLocal中當前執行緒對應的執行緒區域性變數
- set:設定當前執行緒的執行緒區域性變數的值
- remove:將當前執行緒區域性變數的值刪除
另外,還有一個initialValue()方法,在前面的程式碼中有演示,作用是返回當前執行緒區域性變數的初始值,這個方法是一個 protected方法,主要是在構造ThreadLocal時用於設定預設的初始值
set方法的實現
set方法是設定一個執行緒的區域性變數的值,相當於當前執行緒通過set設定的區域性變數的值,只對當前執行緒可見。
- Thread.currentThread 獲取當前執行的執行緒
- getMap(t) ,根據當前執行緒得到當前執行緒的ThreadLocalMap物件,這個物件具體是做什麼的?稍後分析
- 如果map不為空,說明當前執行緒已經構造過ThreadLocalMap,直接將值儲存到map中
- 如果map為空,說明是第一次使用,呼叫 createMap構造
ThreadLocalMap是什麼?
我們來分析一下這句話, ThreadLocalMapmap=getMap(t)獲得一個ThreadLocalMap物件,那這個物件是幹嘛的呢?
其實不用分析,基本上也能猜測出來,Map是一個集合,集合用來儲存資料,那麼在ThreadLocal中,應該就是用來儲存執行緒的區域性變數的。 ThreadLocalMap這個類很關鍵。
t.threadLocals實際上就是訪問Thread類中的ThreadLocalMap這個成員變數
從上面的程式碼發現每一個執行緒都有自己單獨的ThreadLocalMap例項,而對應這個執行緒的所有本地變數都會儲存到這個map內
ThreadLocalMap是在哪裡構造?
在 set方法中,有一行程式碼 createmap(t,value);,這個方法就是用來構造ThreadLocalMap,從傳入的引數來看,它的實現邏輯基本也能猜出出幾分吧
Threadt 是通過 Thread.currentThread()來獲取的表示當前執行緒,然後直接通過 newThreadLocalMap將當前執行緒中的 threadLocals做了初始化
ThreadLocalMap是一個靜態內部類,內部定義了一個Entry物件用來真正儲存資料
分析到這裡,基本知道了ThreadLocalMap長啥樣了,也知道它是如何構造的?那麼我看到這裡的時候仍然有疑問
- Entry集成了 WeakReference,這個表示什麼意思?
- 在構造ThreadLocalMap的時候 newThreadLocalMap(this,firstValue);,key其實是this,this表示當前物件的引用,在當前的案例中,this指的是 ThreadLocal<Integer>local。那麼多個執行緒對應同一個ThreadLocal例項,怎麼對每一個ThreadLocal物件做區分呢?
解惑WeakReference
weakReference表示弱引用,在Java中有四種引用型別,強引用、弱引用、軟引用、虛引用。
使用弱引用的物件,不會阻止它所指向的物件被垃圾回收器回收。
在Java語言中, 當一個物件o被建立時, 它被放在Heap裡. 當GC執行的時候, 如果發現沒有任何引用指向o, o就會被回收以騰出記憶體空間. 也就是說, 一個物件被回收, 必須滿足兩個條件:
- 沒有任何引用指向它
- GC被執行.
這段程式碼中,構造了兩個物件a,b,a是物件DemoA的引用,b是物件DemoB的引用,物件DemoB同時還依賴物件DemoA,那麼這個時候我們認為從物件DemoB是可以到達物件DemoA的。這種稱為強可達(strongly reachable)
如果我們增加一行程式碼來將a物件的引用設定為null,當一個物件不再被其他物件引用的時候,是會被GC回收的,但是對於這個場景來說,即時是a=null,也不可能被回收,因為DemoB依賴DemoA,這個時候是可能造成記憶體洩漏的
通過弱引用,有兩個方法可以避免這樣的問題
對於方法2來說,DemoA只是被弱引用依賴,假設垃圾收集器在某個時間點決定一個物件是弱可達的(weakly reachable)(也就是說當前指向它的全都是弱引用),這時垃圾收集器會清除所有指向該物件的弱引用,然後把這個弱可達物件標記為可終結(finalizable)的,這樣它隨後就會被回收。
試想一下如果這裡沒有使用弱引用,意味著ThreadLocal的生命週期和執行緒是強繫結,只要執行緒沒有銷燬,那麼ThreadLocal一直無法回收。而使用弱引用以後,當ThreadLocal被回收時,由於Entry的key是弱引用,不會影響ThreadLocal的回收防止記憶體洩漏,同時,在後續的原始碼分析中會看到,ThreadLocalMap本身的垃圾清理會用到這一個好處,方便對無效的Entry進行回收
解惑ThreadLocalMap以this作為key
在構造ThreadLocalMap時,使用this作為key來儲存,那麼對於同一個ThreadLocal物件,如果同一個Thread中儲存了多個值,是如何來區分儲存的呢?
答案就在 firstKey.threadLocalHashCode&(INITIAL_CAPACITY-1)
關鍵點是 threadLocalHashCode,它相當於一個ThreadLocal的ID,實現的邏輯如下
這裡用到了一個非常完美的雜湊演算法,可以簡單理解為,對於同一個ThreadLocal下的多個執行緒來說,當任意執行緒呼叫set方法存入一個數據到Entry中的時候,其實會根據 threadLocalHashCode生成一個唯一的id標識對應這個資料,儲存在Entry資料下標中。
- threadLocalHashCode是通過
- nextHashCode.getAndAdd(HASH_INCREMENT)來實現的
- i*HASH_INCREMENT+HASH_INCREMENT,每次新增一個元素(ThreadLocal)到Entry[],都會自增0x61c88647,目的為了讓雜湊碼能均勻的分佈在2的N次方的數組裡
- Entry[i]= hashCode & (length-1)
魔數0x61c88647
從上面的分析可以看出,它是在上一個被構造出的ThreadLocal的threadLocalHashCode的基礎上加上一個魔數0x61c88647。我們來做一個實驗,看看這個雜湊演算法的運算結果
輸出結果
根據執行結果,這個演算法在長度為2的N次方的陣列上,確實可以完美雜湊,沒有任何衝突, 是不是很神奇。
魔數0x61c88647的選取和斐波那契雜湊有關,0x61c88647對應的十進位制為1640531527。而斐波那契雜湊的乘數可以用 (long)((1L<<31)*(Math.sqrt(5)-1)); 如果把這個值給轉為帶符號的int,則會得到-1640531527。也就是說(long)((1L<<31)*(Math.sqrt(5)-1));得到的結果就是1640531527,也就是魔數0x61c88647
總結,我們用0x61c88647作為魔數累加為每個ThreadLocal分配各自的ID也就是threadLocalHashCode再與2的冪取模,得到的結果分佈很均勻。
圖形分析
為了更直觀的體現 set方法的實現,通過一個圖形表示如下
set剩餘原始碼分析
前面分析了set方法第一次初始化ThreadLocalMap的過程,也對ThreadLocalMap的結構有了一個全面的瞭解。那麼接下來看一下map不為空時的執行邏輯
主要邏輯
- 根據key的雜湊雜湊計算Entry的陣列下標
- 通過線性探索探測從i開始往後一直遍歷到陣列的最後一個Entry
- 如果map中的key和傳入的key相等,表示該資料已經存在,直接覆蓋
- 如果map中的key為空,則用新的key、value覆蓋,並清理key=null的資料
- rehash擴容
replaceStaleEntry
由於Entry的key為弱引用,如果key為空,說明ThreadLocal這個物件被GC回收了。 replaceStaleEntry的作用就是把陳舊的Entry進行替換
cleanSomeSlots
這個函式有兩處地方會被呼叫,用於清理無效的Entry
- 插入的時候可能會被呼叫
- 替換無效slot的時候可能會被呼叫
區別是前者傳入的n為元素個數,後者為table的容量
expungeStaleEntry
執行一次全量清理
get操作
set的邏輯分析完成以後,get的原始碼分析就很簡單了
setInitialValue
根據 initialValue()的value初始化ThreadLocalMap
- 從當前執行緒中獲取ThreadLocalMap,查詢當前ThreadLocal變數例項對應的Entry,如果不為null,獲取value,返回
- 如果map為null,即還沒有初始化,走初始化方法
remove方法
remove的方法比較簡單,從Entry[]中刪除指定的key就行
四、ThreadLocal的應用場景及問題
應用場景
ThreadLocal的實際應用場景:
- 比如線上程級別,維護session,維護使用者登入資訊userID(登陸時插入,多個地方獲取)
- 資料庫的連結物件 Connection,可以通過ThreadLocal來做隔離避免執行緒安全問題
問題
ThreadLocal的記憶體洩漏
ThreadLocalMap中Entry的key使用的是ThreadLocal的弱引用,如果一個ThreadLocal沒有外部強引用,當系統執行GC時,這個ThreadLocal勢必會被回收,這樣一來,ThreadLocalMap中就會出現一個key為null的Entry,而這個key=null的Entry是無法訪問的,當這個執行緒一直沒有結束的話,那麼就會存在一條強引用鏈
Thread Ref - > Thread -> ThreadLocalMap - > Entry -> value 永遠無法回收而造成記憶體洩漏
其實我們從原始碼分析可以看到,ThreadLocalMap是做了防護措施的
- 首先從ThreadLocal的直接索引位置(通過
- ThreadLocal.threadLocalHashCode & (len-1)運算得到)獲取Entry e,如果e不為null並且key相同則返回e
- 如果e為null或者key不一致則向下一個位置查詢,如果下一個位置的key和當前需要查詢的key相等,則返回對應的Entry,否則,如果key值為null,則擦除該位置的Entry,否則繼續向下一個位置查詢
在這個過程中遇到的key為null的Entry都會被擦除,那麼Entry內的value也就沒有強引用鏈,自然會被回收。仔細研究程式碼可以發現,set操作也有類似的思想,將key為null的這些Entry都刪除,防止記憶體洩露。
但是這個設計一來與一個前提條件,就是呼叫get或者set方法,但是不是所有場景都會滿足這個場景的,所以為了避免這類的問題,我們可以在合適的位置手動呼叫ThreadLocal的remove函式刪除不需要的ThreadLocal,防止出現記憶體洩漏
所以建議的使用方法是
- 將ThreadLocal變數定義成private static的,這樣的話ThreadLocal的生命週期就更長,由於一直存在ThreadLocal的強引用,所以ThreadLocal也就不會被回收,也就能保證任何時候都能根據ThreadLocal的弱引用訪問到Entry的value值,然後remove它,防止記憶體洩露
- 每次使用完ThreadLocal,都呼叫它的remove()方法,清除資料。
推薦一個交流學習交流圈子:142019080 裡面會分享一些資深架構師錄製的視訊錄影:有Spring,MyBatis,Netty原始碼
分析,高併發、高效能、分散式、微服務架構的原理,JVM效能優化這些成為架構師必備的知識體系。還能領取免費的
學習資源,目前受益