今天我們再來盤一盤 ThreadLocal ,這篇力求對 ThreadLocal 一網打盡,徹底弄懂 ThreadLocal 的機制。
話不多說,本文要解決的問題如下:
-
- 為什麼需要 ThreadLocal
- 應該如何設計 ThreadLocal
- 從源碼看ThreadLocal 的原理
- ThreadLocal 記憶體洩露之為什麼要用弱引用
- ThreadLocal 的最佳實踐
- InheritableThreadLocal
好了,開車!
為什麼需要 ThreadLocal
最近不是開放三胎政策嘛,假設你有三個孩子。
現在你帶着三個孩子出去逛街,路過了玩具店,三個孩子都看中了一款變形金剛。
是以你買了一個變形金剛,打算讓三個孩子輪着玩。
回到家你發現,孩子因為這個玩具吵架了,三個都争着要玩,誰也不讓着誰。
這時候怎麼辦呢?你可以去拉架,去講道理,說服孩子輪流玩,但這很累。
是以一個簡單的辦法就是出去再買兩個變形金剛,這樣三個孩子都有各自的變形金剛,世界就暫時得到了安甯。
映射到我們今天的主題,變形金剛就是共享變量,孩子就是程式運作的線程。
有多個線程(孩子),争搶同一個共享變量(玩具),就會産生沖突,而程式的解決辦法是加鎖(父母說服,講道理,輪流玩),但加鎖就意味着性能的消耗(父母比較累)。
是以有一種解決辦法就是避免共享(讓每個孩子都各自擁有一個變形金剛),這樣線程之間就不需要競争共享變量(孩子之間就不會争搶)。
是以為什麼需要 ThreadLocal?
就是為了通過本地化資源來避免共享,避免了多線程競争導緻的鎖等消耗。
這裡需要強調一下,不是說任何東西都能直接通過避免共享來解決,因為有些時候就必須共享。
舉個例子:當利用多線程同時累加一個變量的時候,此時就必須共享,因為一個線程的對變量的修改需要影響要另個線程,不然累加的結果就不對了。
再舉個不需要共享的例子:比如現在每個線程需要判斷目前請求的使用者來進行權限判斷,那這個使用者資訊其實就不需要共享,因為每個線程隻需要管自己目前執行操作的使用者資訊,跟别的使用者不需要有交集。
好了,道理很簡單,這下子想必你已經清晰了 ThreadLocal 出現的緣由了。
再來看一下 ThreadLocal 使用的小 demo。
public class YesThreadLocal {
private static final ThreadLocal<String> threadLocalName = ThreadLocal.withInitial(() -> Thread.currentThread().getName());
public static void main(String[] args) {
for (int i = 0; i < 5; i++) {
new Thread(() -> {
System.out.println("threadName: " + threadLocalName.get());
}, "yes-thread-" + i).start();
}
}
}
輸出結果如下:
![](https://img.laitimes.com/img/_0nNw4CM6IyYiwiM6ICdiwiI0gTMx81dsQWZ4lmZf1GLlpXazVmcvwFciV2dsQXYtJ3bm9CX9s2RkBnVHFmb1clWvB3MaVnRtp1XlBXe0xCMy81dvRWYoNHLwEzX5xCMx8FesU2cfdGLwMzX0xiRGZkRGZ0Xy9GbvNGLpZTY1EmMZVDUSFTU4VFRR9Fd4VGdsYTMfVmepNHLrJXYtJXZ0F2dvwVZnFWbp1zczV2YvJHctM3cv1Ce-cmbw5iN0kjNzMDMlJTO4EmZ4UzYxYzXwITNxcTM0IzLcJTMxIDMy8CXn9Gbi9CXzV2Zh1WavwVbvNmLvR3YxUjLyM3Lc9CX6MHc0RHaiojIsJye.png)
可以看到,我在 new 線程的時候,設定了每個線程名,每個線程都操作同一個 ThreadLocal 對象的 get 卻傳回的各自的線程名,是不是很神奇?
應該如何設計 ThreadLocal ?
那應該怎麼設計 ThreadLocal 來實作以上的操作,即本地化資源呢?
我們的目标已經明确了,就是用 ThreadLocal 變量來實作線程隔離。
從代碼上看,可能最直接的實作方法就是将 ThreadLocal 看做一個 map ,然後每個線程是 key,這樣每個線程去調用
ThreadLocal.get
的時候,将自身作為 key 去 map 找,這樣就能擷取各自的值了。
聽起來很完美?錯了!
這樣 ThreadLocal 就變成共享變量了,多個線程競争 ThreadLocal ,那就得保證 ThreadLocal 的并發安全,那就得加鎖了,這樣繞了一圈就又回去了。
是以這個方案不行,那應該怎麼做?
答案其實上面已經講了,是需要在每個線程的本地都存一份值,說白了就是每個線程需要有個變量,來存儲這些需要本地化資源的值,并且值有可能有多個,是以怎麼弄呢?
線上程對象内部搞個 map,把 ThreadLocal 對象自身作為 key,把它的值作為 map 的值。
這樣每個線程可以利用同一個對象作為 key ,去各自的 map 中找到對應的值。
這不就完美了嘛!比如我現在有 3 個 ThreadLocal 對象,2 個線程。
ThreadLocal<String> threadLocal1 = new ThreadLocal<>();
ThreadLocal<Integer> threadLocal2 = new ThreadLocal<>();
ThreadLocal<Integer> threadLocal3 = new ThreadLocal<>();
那此時 ThreadLocal 對象和線程的關系如下圖所示:
這樣一來就滿足了本地化資源的需求,每個線程維護自己的變量,互不幹擾,實作了變量的線程隔離,同時也滿足存儲多個本地變量的需求,完美!
JDK就是這樣實作的!我們來看看源碼。
從源碼看ThreadLocal 的原理
前面我們說到 Thread 對象裡面會有個 map,用來儲存本地變量。
我們來看下 jdk 的 Thread 實作
public class Thread implements Runnable {
// 這就是我們說的那個 map 。
ThreadLocal.ThreadLocalMap threadLocals = null;
}
可以看到,确實有個 map ,不過這個 map 是 ThreadLocal 的靜态内部類,記住這個變量的名字 threadLocals,下面會有用的哈。
看到這裡,想必有很多小夥伴會産生一個疑問。
竟然這個 map 是放在 Thread 裡面使用,那為什麼要定義成 ThreadLocal 的靜态内部類呢?
首先内部類這個東西是編譯層面的概念,就像文法糖一樣,經過編譯器之後其實内部類會提升為外部頂級類,和平日裡外部定義的類沒有差別,也就是說在 JVM 中是沒有内部類這個概念的。
一般情況下非靜态内部類用在内部類,跟其他類無任何關聯,專屬于這個外部類使用,并且也便于調用外部類的成員變量和方法,比較友善。
而靜态外部類其實就等于一個頂級類,可以獨立于外部類使用,是以更多的隻是表明類結構和命名空間。
是以說這樣定義的用意就是說明 ThreadLocalMap 是和 ThreadLocal 強相關的,專用于儲存線程本地變量。
現在我們來看一下 ThreadLocalMap 的定義:
重點我已經标出來了,首先可以看到這個 ThreadLocalMap 裡面有個 Entry 數組,熟悉 HashMap 的小夥伴可能有點感覺了。
這個 Entry 繼承了 WeakReference 即弱引用。這裡需要注意,不是說 Entry 自己是弱引用,看到我标注的 Entry 構造函數的
super(k)
沒,這個 key 才是弱引用。
是以 ThreadLocalMap 裡有個 Entry 的數組,這個 Entry 的 key 就是 ThreadLocal 對象,value 就是我們需要儲存的值。
那是如何通過 key 在數組中找到 Entry 然後得到 value 的呢 ?
這就要從上面的
threadLocalName.get()
說起,不記得這個代碼的滑上去看下示例,其實就是調用 ThreadLocal 的 get 方法。
此時就進入
ThreadLocal#get
方法中了,這裡就可以得知為什麼不同的線程對同一個 ThreadLocal 對象調用 get 方法竟然能得到不同的值了。
這個中文注釋想必很清晰了吧!
ThreadLocal#get
方法首先擷取目前線程,然後得到目前線程的 ThreadLocalMap 變量即 threadLocals,然後将自己作為 key 從 ThreadLocalMap 中找到 Entry ,最終傳回 Entry 裡面的 value 值。
這裡我們再看一下 key 是如何從 ThreadLocalMap 中找到 Entry 的,即
map.getEntry(this)
是如何實作的,其實很簡單。
可以看到 ThreadLocalMap 雖然和 HashMap 一樣,都是基于數組實作的,但是它們對于 Hash 沖突的解決方法不一樣。
HashMap 是通過連結清單(紅黑樹)法來解決沖突,而 ThreadLocalMap 是通過開放尋址法來解決沖突。
聽起來好像很進階,其實道理很簡單,我們來看一張圖就很清晰了。
是以說,如果通過 key 的哈希值得到的下标無法直接命中,則會将下标 +1,即繼續往後周遊數組查找 Entry ,直到找到或者傳回 null。
可以看到,這種 hash 沖突的解決效率其實不高,但是一般 ThreadLocal 也不會太多,是以用這種簡單的辦法解決即可。
至于代碼中的
expungeStaleEntry
我們等下再分析,先來看下
ThreadLocalMap#set
方法,看看寫入的怎樣實作的,來看看 hash 沖突的解決方法是否和上面說的一緻。
可以看到 set 的邏輯也很清晰。
先通過 key 的 hash 值計算出一個數組下标,然後看看這個下标是否被占用了,如果被占了看看是否就是要找的 Entry 。
如果是則進行更新,如果不是則下标++,即往後周遊數組,查找下一個位置,找到空位就 new 個 Entry 然後把坑給占用了。
當然,這種數組操作一般免不了門檻值的判斷,如果超過門檻值則需要進行擴容。
上面的清理操作和 key 為空的情況,下面再做分析,這裡先略過。
至此,我們已經分析了 ThreadLocalMap 的核心操作 get 和 set ,想必你對 ThreadLocalMap 的原理已經從源碼層面清晰了!
可能有些小夥伴對 key 的哈希值的來源有點疑惑,是以我再來補充一下
key.threadLocalHashCode
的分析。
可以看到
key.threadLocalHashCode
其實就是調用 nextHashCode 進行一個原子類的累加。
注意看上面都是靜态變量和靜态方法,是以在 ThreadLocal 對象之間是共享的,然後通過固定累加一個奇怪的數字
0x61c88647
來配置設定 hash 值。
這個數字當然不是亂寫的,是實驗證明的一個值,即通過
0x61c88647
累加生成的值與 2 的幂取模的結果,可以較為均勻地分布在 2 的幂長度的數組中,這樣可以減少 hash 沖突。
有興趣的小夥伴可以深入研究一下,反正我沒啥興趣。
ThreadLocal 記憶體洩露之為什麼要用弱引用
接下來就是要解決上面挖的坑了,即 key 的弱引用、Entry 的 key 為什麼可能為 null、還有清理 Entry 的操作。
之前提到過,Entry 對 key 是弱引用,那為什麼要弱引用呢?
我們知道,如果一個對象沒有強引用,隻有弱引用的話,這個對象是活不過一次 GC 的,是以這樣的設計就是為了讓當外部沒有對 ThreadLocal 對象有強引用的時候,可以将 ThreadLocal 對象給清理掉。
那為什麼要這樣設計呢?
假設 Entry 對 key 的引用是強引用,那麼來看一下這個引用鍊:
從這條引用鍊可以得知,如果線程一直在,那麼相關的 ThreadLocal 對象肯定會一直在,因為它一直被強引用着。
看到這裡,可能有人會說那線程被回收之後就好了呀。
重點來了!線程在我們應用中,常常是以線程池的方式來使用的,比如 Tomcat 的線程池處理了一堆請求,而線程池中的線程一般是不會被清理掉的,是以這個引用鍊就會一直在,那麼 ThreadLocal 對象即使沒有用了,也會随着線程的存在,而一直存在着!
是以這條引用鍊需要弱化一下,而能操作的隻有 Entry 和 key 之間的引用,是以它們之間用弱引用來實作。
與之對應的還有一個條引用鍊,我結合着上面的線程引用鍊都畫出來:
另一條引用鍊就是棧上的 ThreadLocal 引用指向堆中的 ThreadLocal 對象,這個引用是強引用。
如果有這條強引用存在,那說明此時的 ThreadLocal 是有用的,此時如果發生 GC 則 ThreadLocal 對象不會被清除,因為有個強引用存在。
當随着方法的執行完畢,相應的棧幀也出棧了,此時這條強引用鍊就沒了,如果沒有别的棧有對 ThreadLocal 對象的引用,那麼說明 ThreadLocal 對象無法再被通路到(定義成靜态變量的另說)。
那此時 ThreadLocal 隻存在與 Entry 之間的弱引用,那此時發生 GC 它就可以被清除了,因為它無法被外部使用了,那就等于沒用了,是個垃圾,應該被處理來節省空間。
至此,想必你已經明白為什麼 Entry 和 key 之間要設計為弱引用,就是因為平日線程的使用方式基本上都是線程池,是以線程的生命周期就很長,可能從你部署上線後一直存在,而 ThreadLocal 對象的生命周期可能沒這麼長。
是以為了能讓已經沒用 ThreadLocal 對象得以回收,是以 Entry 和 key 要設計成弱引用,不然 Entry 和 key是強引用的話,ThreadLocal 對象就會一直在記憶體中存在。
但是這樣設計就可能産生記憶體洩漏。
那什麼叫記憶體洩漏?
就是指:程式中已經無用的記憶體無法被釋放,造成系統記憶體的浪費。
當 Entry 中的 key 即 ThreadLocal 對象被回收了之後,會發生 Entry 中 key 為 null 的情況,其實這個 Entry 就已經沒用了,但是又無法被回收,因為有 Thread->ThreadLocalMap ->Entry 這條強引用在,這樣沒用的記憶體無法被回收就是記憶體洩露。
那既然會有記憶體洩漏還這樣實作?
這裡就要填一填上面的坑了,也就是涉及到的關于
expungeStaleEntry
即清理過期的 Entry 的操作。
設計者當然知道會出現這種情況,是以在多個地方都做了清理無用 Entry ,即 key 已經被回收的 Entry 的操作。
比如通過 key 查找 Entry 的時候,如果下标無法直接命中,那麼就會向後周遊數組,此時遇到 key 為 null 的 Entry 就會清理掉,再貼一下這個方法:
這個方法也很簡單,我們來看一下它的實作:
是以在查找 Entry 的時候,就會順道清理無用的 Entry ,這樣就能防止一部分的記憶體洩露啦!
還有像擴容的時候也會清理無用的 Entry:
其它還有,我就不貼了,反正知曉設計者是做了一些操作來回收無用的 Entry 的即可。
ThreadLocal 的最佳實踐
當然,等着這些操作被動回收不是最好的方法,假設後面沒人調用 get 或者調用 get 都直接命中或者不會發生擴容,那無用的 Entry 豈不是一直存在了嗎?是以上面說隻能防止一部分的記憶體洩露。
是以,最佳實踐是用完了之後,調用一下 remove 方法,手工把 Entry 清理掉,這樣就不會發生記憶體洩漏了!
void yesDosth {
threadlocal.set(xxx);
try {
// do sth
} finally {
threadlocal.remove();
}
}
這就是使用 Threadlocal 的一個正确姿勢啦,即不需要的時候,顯示的 remove 掉。
當然,如果不是線程池使用方式的話,其實不用關系記憶體洩漏,反正線程執行完了就都回收了,但是一般我們都是使用線程池的,可能隻是你沒感覺到。
比如你用了 tomcat ,其實請求的執行用的就是 tomcat 的線程池,這就是隐式使用。
還有一個問題,關于 withInitial 也就是初始化值的方法。
由于類似 tomcat 這種隐式線程池的存在,即線程第一次調用執行 Threadlocal 之後,如果沒有顯示調用 remove 方法,則這個 Entry 還是存在的,那麼下次這個線程再執行任務的時候,不會再調用 withInitial 方法,也就是說會拿到上一次執行的值。
但是你以為執行任務的是新線程,會初始化值,然而它是線程池裡面的老線程,這就和預期不一緻了,是以這裡需要注意。
InheritableThreadLocal
這個其實之前文章寫過了,不過這次竟然寫了 threadlocal 就再拿出來。
這玩意可以了解為就是可以把父線程的 threadlocal 傳遞給子線程,是以如果要這樣傳遞就用 InheritableThreadLocal ,不要用 threadlocal。
原理其實很簡單,在 Thread 中已經包含了這個成員:
在父線程建立子線程的時候,子線程的構造函數可以得到父線程,然後判斷下父線程的 InheritableThreadLocal 是否有值,如果有的話就拷過來。
這裡要注意,隻會線上程建立的時會拷貝 InheritableThreadLocal 的值,之後父線程如何更改,子線程都不會受其影響。
最後
至此有關 ThreadLocal 的知識點就差不多了。
想必你已經清楚 ThreadLocal 的原理,包括如何實作,為什麼 key 要設計成弱引用,并且關于線上程池中使用的注意點等等。
其實本沒打算寫 ThreadLocal 的,因為最近在看 Netty ,是以想寫一下 FastThreadLocal ,但是前置知識點是 ThreadLocal ,是以就幹了這篇。