我把 ThreadLocal 能問的,都寫了
你好,我是yes。
今天我們再來盤一盤 ThreadLocal ,這篇力求對(duì) ThreadLocal 一網(wǎng)打盡,徹底弄懂 ThreadLocal 的機(jī)制。
有了這篇基礎(chǔ)之后,下篇再來盤一盤 ThreadLocal 的進(jìn)階版,等我哈。
話不多說,本文要解決的問題如下:
- 為什么需要 ThreadLocal
- 應(yīng)該如何設(shè)計(jì) ThreadLocal
- 從源碼看ThreadLocal 的原理
- ThreadLocal 內(nèi)存泄露之為什么要用弱引用
- ThreadLocal 的最佳實(shí)踐
- InheritableThreadLocal
好了,開車!
為什么需要 ThreadLocal
最近不是開放三胎政策嘛,假設(shè)你有三個(gè)孩子。
現(xiàn)在你帶著三個(gè)孩子出去逛街,路過了玩具店,三個(gè)孩子都看中了一款變形金剛。
所以你買了一個(gè)變形金剛,打算讓三個(gè)孩子輪著玩。
回到家你發(fā)現(xiàn),孩子因?yàn)檫@個(gè)玩具吵架了,三個(gè)都爭著要玩,誰也不讓著誰。
這時(shí)候怎么辦呢?你可以去拉架,去講道理,說服孩子輪流玩,但這很累。
所以一個(gè)簡單的辦法就是出去再買兩個(gè)變形金剛,這樣三個(gè)孩子都有各自的變形金剛,世界就暫時(shí)得到了安寧。
映射到我們今天的主題,變形金剛就是共享變量,孩子就是程序運(yùn)行的線程。
有多個(gè)線程(孩子),爭搶同一個(gè)共享變量(玩具),就會(huì)產(chǎn)生沖突,而程序的解決辦法是加鎖(父母說服,講道理,輪流玩),但加鎖就意味著性能的消耗(父母比較累)。
所以有一種解決辦法就是避免共享(讓每個(gè)孩子都各自擁有一個(gè)變形金剛),這樣線程之間就不需要競爭共享變量(孩子之間就不會(huì)爭搶)。
所以為什么需要 ThreadLocal?
就是為了通過本地化資源來避免共享,避免了多線程競爭導(dǎo)致的鎖等消耗。
這里需要強(qiáng)調(diào)一下,不是說任何東西都能直接通過避免共享來解決,因?yàn)橛行r(shí)候就必須共享。
舉個(gè)例子:當(dāng)利用多線程同時(shí)累加一個(gè)變量的時(shí)候,此時(shí)就必須共享,因?yàn)橐粋€(gè)線程的對(duì)變量的修改需要影響要另個(gè)線程,不然累加的結(jié)果就不對(duì)了。
再舉個(gè)不需要共享的例子:比如現(xiàn)在每個(gè)線程需要判斷當(dāng)前請(qǐng)求的用戶來進(jìn)行權(quán)限判斷,那這個(gè)用戶信息其實(shí)就不需要共享,因?yàn)槊總€(gè)線程只需要管自己當(dāng)前執(zhí)行操作的用戶信息,跟別的用戶不需要有交集。
好了,道理很簡單,這下子想必你已經(jīng)清晰了 ThreadLocal 出現(xiàn)的緣由了。
再來看一下 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();
- }
- }
- }
輸出結(jié)果如下:
可以看到,我在 new 線程的時(shí)候,設(shè)置了每個(gè)線程名,每個(gè)線程都操作同一個(gè) ThreadLocal 對(duì)象的 get 卻返回的各自的線程名,是不是很神奇?
應(yīng)該如何設(shè)計(jì) ThreadLocal ?
那應(yīng)該怎么設(shè)計(jì) ThreadLocal 來實(shí)現(xiàn)以上的操作,即本地化資源呢?
我們的目標(biāo)已經(jīng)明確了,就是用 ThreadLocal 變量來實(shí)現(xiàn)線程隔離。
從代碼上看,可能最直接的實(shí)現(xiàn)方法就是將 ThreadLocal 看做一個(gè) map ,然后每個(gè)線程是 key,這樣每個(gè)線程去調(diào)用 ThreadLocal.get 的時(shí)候,將自身作為 key 去 map 找,這樣就能獲取各自的值了。
聽起來很完美?錯(cuò)了!
這樣 ThreadLocal 就變成共享變量了,多個(gè)線程競爭 ThreadLocal ,那就得保證 ThreadLocal 的并發(fā)安全,那就得加鎖了,這樣繞了一圈就又回去了。
所以這個(gè)方案不行,那應(yīng)該怎么做?
答案其實(shí)上面已經(jīng)講了,是需要在每個(gè)線程的本地都存一份值,說白了就是每個(gè)線程需要有個(gè)變量,來存儲(chǔ)這些需要本地化資源的值,并且值有可能有多個(gè),所以怎么弄呢?
在線程對(duì)象內(nèi)部搞個(gè) map,把 ThreadLocal 對(duì)象自身作為 key,把它的值作為 map 的值。
這樣每個(gè)線程可以利用同一個(gè)對(duì)象作為 key ,去各自的 map 中找到對(duì)應(yīng)的值。
這不就完美了嘛!比如我現(xiàn)在有 3 個(gè) ThreadLocal 對(duì)象,2 個(gè)線程。
- ThreadLocal<String> threadLocal1 = new ThreadLocal<>();
- ThreadLocal<Integer> threadLocal2 = new ThreadLocal<>();
- ThreadLocal<Integer> threadLocal3 = new ThreadLocal<>();
那此時(shí) ThreadLocal 對(duì)象和線程的關(guān)系如下圖所示:
這樣一來就滿足了本地化資源的需求,每個(gè)線程維護(hù)自己的變量,互不干擾,實(shí)現(xiàn)了變量的線程隔離,同時(shí)也滿足存儲(chǔ)多個(gè)本地變量的需求,完美!
JDK就是這樣實(shí)現(xiàn)的!我們來看看源碼。
從源碼看ThreadLocal 的原理
前面我們說到 Thread 對(duì)象里面會(huì)有個(gè) map,用來保存本地變量。
我們來看下 jdk 的 Thread 實(shí)現(xiàn)
- ThreadLocal<String> threadLocal1 = new ThreadLocal<>();
- ThreadLocal<Integer> threadLocal2 = new ThreadLocal<>();
- ThreadLocal<Integer> threadLocal3 = new ThreadLocal<>();
可以看到,確實(shí)有個(gè) map ,不過這個(gè) map 是 ThreadLocal 的靜態(tài)內(nèi)部類,記住這個(gè)變量的名字 threadLocals,下面會(huì)有用的哈。
看到這里,想必有很多小伙伴會(huì)產(chǎn)生一個(gè)疑問。
竟然這個(gè) map 是放在 Thread 里面使用,那為什么要定義成 ThreadLocal 的靜態(tài)內(nèi)部類呢?
首先內(nèi)部類這個(gè)東西是編譯層面的概念,就像語法糖一樣,經(jīng)過編譯器之后其實(shí)內(nèi)部類會(huì)提升為外部頂級(jí)類,和平日里外部定義的類沒有區(qū)別,也就是說在 JVM 中是沒有內(nèi)部類這個(gè)概念的。
一般情況下非靜態(tài)內(nèi)部類用在內(nèi)部類,跟其他類無任何關(guān)聯(lián),專屬于這個(gè)外部類使用,并且也便于調(diào)用外部類的成員變量和方法,比較方便。
而靜態(tài)外部類其實(shí)就等于一個(gè)頂級(jí)類,可以獨(dú)立于外部類使用,所以更多的只是表明類結(jié)構(gòu)和命名空間。
所以說這樣定義的用意就是說明 ThreadLocalMap 是和 ThreadLocal 強(qiáng)相關(guān)的,專用于保存線程本地變量。
現(xiàn)在我們來看一下 ThreadLocalMap 的定義:
重點(diǎn)我已經(jīng)標(biāo)出來了,首先可以看到這個(gè) ThreadLocalMap 里面有個(gè) Entry 數(shù)組,熟悉 HashMap 的小伙伴可能有點(diǎn)感覺了。
這個(gè) Entry 繼承了 WeakReference 即弱引用。這里需要注意,不是說 Entry 自己是弱引用,看到我標(biāo)注的 Entry 構(gòu)造函數(shù)的 super(k) 沒,這個(gè) key 才是弱引用。
所以 ThreadLocalMap 里有個(gè) Entry 的數(shù)組,這個(gè) Entry 的 key 就是 ThreadLocal 對(duì)象,value 就是我們需要保存的值。
那是如何通過 key 在數(shù)組中找到 Entry 然后得到 value 的呢 ?
這就要從上面的 threadLocalName.get()說起,不記得這個(gè)代碼的滑上去看下示例,其實(shí)就是調(diào)用 ThreadLocal 的 get 方法。
此時(shí)就進(jìn)入 ThreadLocal#get方法中了,這里就可以得知為什么不同的線程對(duì)同一個(gè) ThreadLocal 對(duì)象調(diào)用 get 方法竟然能得到不同的值了。
這個(gè)中文注釋想必很清晰了吧!
ThreadLocal#get方法首先獲取當(dāng)前線程,然后得到當(dāng)前線程的 ThreadLocalMap 變量即 threadLocals,然后將自己作為 key 從 ThreadLocalMap 中找到 Entry ,最終返回 Entry 里面的 value 值。
這里我們再看一下 key 是如何從 ThreadLocalMap 中找到 Entry 的,即map.getEntry(this)是如何實(shí)現(xiàn)的,其實(shí)很簡單。
可以看到 ThreadLocalMap 雖然和 HashMap 一樣,都是基于數(shù)組實(shí)現(xiàn)的,但是它們對(duì)于 Hash 沖突的解決方法不一樣。
HashMap 是通過鏈表(紅黑樹)法來解決沖突,而 ThreadLocalMap 是通過開放尋址法來解決沖突。
聽起來好像很高級(jí),其實(shí)道理很簡單,我們來看一張圖就很清晰了。
所以說,如果通過 key 的哈希值得到的下標(biāo)無法直接命中,則會(huì)將下標(biāo) +1,即繼續(xù)往后遍歷數(shù)組查找 Entry ,直到找到或者返回 null。
可以看到,這種 hash 沖突的解決效率其實(shí)不高,但是一般 ThreadLocal 也不會(huì)太多,所以用這種簡單的辦法解決即可。
至于代碼中的expungeStaleEntry我們等下再分析,先來看下 ThreadLocalMap#set 方法,看看寫入的怎樣實(shí)現(xiàn)的,來看看 hash 沖突的解決方法是否和上面說的一致。
可以看到 set 的邏輯也很清晰。
先通過 key 的 hash 值計(jì)算出一個(gè)數(shù)組下標(biāo),然后看看這個(gè)下標(biāo)是否被占用了,如果被占了看看是否就是要找的 Entry 。
如果是則進(jìn)行更新,如果不是則下標(biāo)++,即往后遍歷數(shù)組,查找下一個(gè)位置,找到空位就 new 個(gè) Entry 然后把坑給占用了。
當(dāng)然,這種數(shù)組操作一般免不了閾值的判斷,如果超過閾值則需要進(jìn)行擴(kuò)容。
上面的清理操作和 key 為空的情況,下面再做分析,這里先略過。
至此,我們已經(jīng)分析了 ThreadLocalMap 的核心操作 get 和 set ,想必你對(duì) ThreadLocalMap 的原理已經(jīng)從源碼層面清晰了!
可能有些小伙伴對(duì) key 的哈希值的來源有點(diǎn)疑惑,所以我再來補(bǔ)充一下 key.threadLocalHashCode的分析。
可以看到key.threadLocalHashCode其實(shí)就是調(diào)用 nextHashCode 進(jìn)行一個(gè)原子類的累加。
注意看上面都是靜態(tài)變量和靜態(tài)方法,所以在 ThreadLocal 對(duì)象之間是共享的,然后通過固定累加一個(gè)奇怪的數(shù)字0x61c88647來分配 hash 值。
這個(gè)數(shù)字當(dāng)然不是亂寫的,是實(shí)驗(yàn)證明的一個(gè)值,即通過 0x61c88647 累加生成的值與 2 的冪取模的結(jié)果,可以較為均勻地分布在 2 的冪長度的數(shù)組中,這樣可以減少 hash 沖突。
有興趣的小伙伴可以深入研究一下,反正我沒啥興趣。
ThreadLocal 內(nèi)存泄露之為什么要用弱引用
接下來就是要解決上面挖的坑了,即 key 的弱引用、Entry 的 key 為什么可能為 null、還有清理 Entry 的操作。
之前提到過,Entry 對(duì) key 是弱引用,那為什么要弱引用呢?
我們知道,如果一個(gè)對(duì)象沒有強(qiáng)引用,只有弱引用的話,這個(gè)對(duì)象是活不過一次 GC 的,所以這樣的設(shè)計(jì)就是為了讓當(dāng)外部沒有對(duì) ThreadLocal 對(duì)象有強(qiáng)引用的時(shí)候,可以將 ThreadLocal 對(duì)象給清理掉。
那為什么要這樣設(shè)計(jì)呢?
假設(shè) Entry 對(duì) key 的引用是強(qiáng)引用,那么來看一下這個(gè)引用鏈:
從這條引用鏈可以得知,如果線程一直在,那么相關(guān)的 ThreadLocal 對(duì)象肯定會(huì)一直在,因?yàn)樗恢北粡?qiáng)引用著。
看到這里,可能有人會(huì)說那線程被回收之后就好了呀。
重點(diǎn)來了!線程在我們應(yīng)用中,常常是以線程池的方式來使用的,比如 Tomcat 的線程池處理了一堆請(qǐng)求,而線程池中的線程一般是不會(huì)被清理掉的,所以這個(gè)引用鏈就會(huì)一直在,那么 ThreadLocal 對(duì)象即使沒有用了,也會(huì)隨著線程的存在,而一直存在著!
所以這條引用鏈需要弱化一下,而能操作的只有 Entry 和 key 之間的引用,所以它們之間用弱引用來實(shí)現(xiàn)。
與之對(duì)應(yīng)的還有一個(gè)條引用鏈,我結(jié)合著上面的線程引用鏈都畫出來:
另一條引用鏈就是棧上的 ThreadLocal 引用指向堆中的 ThreadLocal 對(duì)象,這個(gè)引用是強(qiáng)引用。
如果有這條強(qiáng)引用存在,那說明此時(shí)的 ThreadLocal 是有用的,此時(shí)如果發(fā)生 GC 則 ThreadLocal 對(duì)象不會(huì)被清除,因?yàn)橛袀€(gè)強(qiáng)引用存在。
當(dāng)隨著方法的執(zhí)行完畢,相應(yīng)的棧幀也出棧了,此時(shí)這條強(qiáng)引用鏈就沒了,如果沒有別的棧有對(duì) ThreadLocal 對(duì)象的引用,那么說明 ThreadLocal 對(duì)象無法再被訪問到(定義成靜態(tài)變量的另說)。
那此時(shí) ThreadLocal 只存在與 Entry 之間的弱引用,那此時(shí)發(fā)生 GC 它就可以被清除了,因?yàn)樗鼰o法被外部使用了,那就等于沒用了,是個(gè)垃圾,應(yīng)該被處理來節(jié)省空間。
至此,想必你已經(jīng)明白為什么 Entry 和 key 之間要設(shè)計(jì)為弱引用,就是因?yàn)槠饺站€程的使用方式基本上都是線程池,所以線程的生命周期就很長,可能從你部署上線后一直存在,而 ThreadLocal 對(duì)象的生命周期可能沒這么長。
所以為了能讓已經(jīng)沒用 ThreadLocal 對(duì)象得以回收,所以 Entry 和 key 要設(shè)計(jì)成弱引用,不然 Entry 和 key是強(qiáng)引用的話,ThreadLocal 對(duì)象就會(huì)一直在內(nèi)存中存在。
但是這樣設(shè)計(jì)就可能產(chǎn)生內(nèi)存泄漏。
那什么叫內(nèi)存泄漏?
就是指:程序中已經(jīng)無用的內(nèi)存無法被釋放,造成系統(tǒng)內(nèi)存的浪費(fèi)。
當(dāng) Entry 中的 key 即 ThreadLocal 對(duì)象被回收了之后,會(huì)發(fā)生 Entry 中 key 為 null 的情況,其實(shí)這個(gè) Entry 就已經(jīng)沒用了,但是又無法被回收,因?yàn)橛?Thread->ThreadLocalMap ->Entry 這條強(qiáng)引用在,這樣沒用的內(nèi)存無法被回收就是內(nèi)存泄露。
那既然會(huì)有內(nèi)存泄漏還這樣實(shí)現(xiàn)?
這里就要填一填上面的坑了,也就是涉及到的關(guān)于 expungeStaleEntry即清理過期的 Entry 的操作。
設(shè)計(jì)者當(dāng)然知道會(huì)出現(xiàn)這種情況,所以在多個(gè)地方都做了清理無用 Entry ,即 key 已經(jīng)被回收的 Entry 的操作。
比如通過 key 查找 Entry 的時(shí)候,如果下標(biāo)無法直接命中,那么就會(huì)向后遍歷數(shù)組,此時(shí)遇到 key 為 null 的 Entry 就會(huì)清理掉,再貼一下這個(gè)方法:
這個(gè)方法也很簡單,我們來看一下它的實(shí)現(xiàn):
所以在查找 Entry 的時(shí)候,就會(huì)順道清理無用的 Entry ,這樣就能防止一部分的內(nèi)存泄露啦!
還有像擴(kuò)容的時(shí)候也會(huì)清理無用的 Entry:
其它還有,我就不貼了,反正知曉設(shè)計(jì)者是做了一些操作來回收無用的 Entry 的即可。
ThreadLocal 的最佳實(shí)踐
當(dāng)然,等著這些操作被動(dòng)回收不是最好的方法,假設(shè)后面沒人調(diào)用 get 或者調(diào)用 get 都直接命中或者不會(huì)發(fā)生擴(kuò)容,那無用的 Entry 豈不是一直存在了嗎?所以上面說只能防止一部分的內(nèi)存泄露。
所以,最佳實(shí)踐是用完了之后,調(diào)用一下 remove 方法,手工把 Entry 清理掉,這樣就不會(huì)發(fā)生內(nèi)存泄漏了!
- void yesDosth {
- threadlocal.set(xxx);
- try {
- // do sth
- } finally {
- threadlocal.remove();
- }
- }
這就是使用 Threadlocal 的一個(gè)正確姿勢啦,即不需要的時(shí)候,顯示的 remove 掉。
當(dāng)然,如果不是線程池使用方式的話,其實(shí)不用關(guān)系內(nèi)存泄漏,反正線程執(zhí)行完了就都回收了,但是一般我們都是使用線程池的,可能只是你沒感覺到。
比如你用了 tomcat ,其實(shí)請(qǐng)求的執(zhí)行用的就是 tomcat 的線程池,這就是隱式使用。
還有一個(gè)問題,關(guān)于 withInitial 也就是初始化值的方法。
由于類似 tomcat 這種隱式線程池的存在,即線程第一次調(diào)用執(zhí)行 Threadlocal 之后,如果沒有顯示調(diào)用 remove 方法,則這個(gè) Entry 還是存在的,那么下次這個(gè)線程再執(zhí)行任務(wù)的時(shí)候,不會(huì)再調(diào)用 withInitial 方法,也就是說會(huì)拿到上一次執(zhí)行的值。
但是你以為執(zhí)行任務(wù)的是新線程,會(huì)初始化值,然而它是線程池里面的老線程,這就和預(yù)期不一致了,所以這里需要注意。
InheritableThreadLocal
這個(gè)其實(shí)之前文章寫過了,不過這次竟然寫了 threadlocal 就再拿出來。
這玩意可以理解為就是可以把父線程的 threadlocal 傳遞給子線程,所以如果要這樣傳遞就用 InheritableThreadLocal ,不要用 threadlocal。
原理其實(shí)很簡單,在 Thread 中已經(jīng)包含了這個(gè)成員:
在父線程創(chuàng)建子線程的時(shí)候,子線程的構(gòu)造函數(shù)可以得到父線程,然后判斷下父線程的 InheritableThreadLocal 是否有值,如果有的話就拷過來。
這里要注意,只會(huì)在線程創(chuàng)建的時(shí)會(huì)拷貝 InheritableThreadLocal 的值,之后父線程如何更改,子線程都不會(huì)受其影響。
最后
至此有關(guān) ThreadLocal 的知識(shí)點(diǎn)就差不多了。
想必你已經(jīng)清楚 ThreadLocal 的原理,包括如何實(shí)現(xiàn),為什么 key 要設(shè)計(jì)成弱引用,并且關(guān)于在線程池中使用的注意點(diǎn)等等。
其實(shí)本沒打算寫 ThreadLocal 的,因?yàn)樽罱诳?Netty ,所以想寫一下 FastThreadLocal ,但是前置知識(shí)點(diǎn)是 ThreadLocal ,所以就干了這篇。
消化了這篇之后,出去面試 ThreadLocal 算是沒問題了吧,最后再留個(gè)小小的思考題。
那為什么 Entry 中的 value 不弱引用?
這個(gè)題目來自群友的一個(gè)面試題哈,想必看完這篇文章之后,這個(gè)題目難不倒你,歡迎留言區(qū)寫出答案!
等我下篇的 ThreadLocal 進(jìn)階版!