天天看點

我把 ThreadLocal 能問的,都寫了

今天我們再來盤一盤 ThreadLocal ,這篇力求對 ThreadLocal 一網打盡,徹底弄懂 ThreadLocal 的機制。

話不多說,本文要解決的問題如下:

  1. 為什麼需要 ThreadLocal
  2. 應該如何設計 ThreadLocal
  3. 從源碼看ThreadLocal 的原理
  4. ThreadLocal 記憶體洩露之為什麼要用弱引用
  5. ThreadLocal 的最佳實踐
  6. 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();
        }
    }
}


      

輸出結果如下:

我把 ThreadLocal 能問的,都寫了

可以看到,我在 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  對象和線程的關系如下圖所示:

我把 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 的定義:

我把 ThreadLocal 能問的,都寫了

重點我已經标出來了,首先可以看到這個 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 能問的,都寫了

這個中文注釋想必很清晰了吧!

​ThreadLocal#get​

​方法首先擷取目前線程,然後得到目前線程的 ThreadLocalMap 變量即 threadLocals,然後将自己作為 key 從 ThreadLocalMap 中找到 Entry ,最終傳回 Entry 裡面的 value 值。

這裡我們再看一下 key 是如何從 ThreadLocalMap 中找到 Entry 的,即​

​map.getEntry(this)​

​是如何實作的,其實很簡單。

我把 ThreadLocal 能問的,都寫了

可以看到 ThreadLocalMap 雖然和 HashMap 一樣,都是基于數組實作的,但是它們對于 Hash 沖突的解決方法不一樣。

HashMap 是通過連結清單(紅黑樹)法來解決沖突,而 ThreadLocalMap 是通過開放尋址法來解決沖突。

聽起來好像很進階,其實道理很簡單,我們來看一張圖就很清晰了。

我把 ThreadLocal 能問的,都寫了

是以說,如果通過 key 的哈希值得到的下标無法直接命中,則會将下标 +1,即繼續往後周遊數組查找 Entry ,直到找到或者傳回 null。

可以看到,這種 hash 沖突的解決效率其實不高,但是一般 ThreadLocal 也不會太多,是以用這種簡單的辦法解決即可。

至于代碼中的​

​expungeStaleEntry​

​​我們等下再分析,先來看下 ​

​ThreadLocalMap#set​

​ 方法,看看寫入的怎樣實作的,來看看 hash 沖突的解決方法是否和上面說的一緻。

我把 ThreadLocal 能問的,都寫了

可以看到 set 的邏輯也很清晰。

先通過 key 的 hash 值計算出一個數組下标,然後看看這個下标是否被占用了,如果被占了看看是否就是要找的 Entry 。

如果是則進行更新,如果不是則下标++,即往後周遊數組,查找下一個位置,找到空位就 new 個 Entry 然後把坑給占用了。

當然,這種數組操作一般免不了門檻值的判斷,如果超過門檻值則需要進行擴容。

上面的清理操作和 key 為空的情況,下面再做分析,這裡先略過。

至此,我們已經分析了 ThreadLocalMap 的核心操作 get 和 set ,想必你對 ThreadLocalMap 的原理已經從源碼層面清晰了!

可能有些小夥伴對 key 的哈希值的來源有點疑惑,是以我再來補充一下 ​

​key.threadLocalHashCode​

​的分析。

我把 ThreadLocal 能問的,都寫了

可以看到​

​key.threadLocalHashCode​

​其實就是調用 nextHashCode 進行一個原子類的累加。

注意看上面都是靜态變量和靜态方法,是以在 ThreadLocal 對象之間是共享的,然後通過固定累加一個奇怪的數字​

​0x61c88647​

​來配置設定 hash 值。

這個數字當然不是亂寫的,是實驗證明的一個值,即通過 ​

​0x61c88647​

​ 累加生成的值與 2 的幂取模的結果,可以較為均勻地分布在 2 的幂長度的數組中,這樣可以減少 hash 沖突。

有興趣的小夥伴可以深入研究一下,反正我沒啥興趣。

ThreadLocal 記憶體洩露之為什麼要用弱引用

接下來就是要解決上面挖的坑了,即 key 的弱引用、Entry 的 key 為什麼可能為 null、還有清理 Entry 的操作。

之前提到過,Entry 對 key 是弱引用,那為什麼要弱引用呢?

我們知道,如果一個對象沒有強引用,隻有弱引用的話,這個對象是活不過一次 GC 的,是以這樣的設計就是為了讓當外部沒有對 ThreadLocal 對象有強引用的時候,可以将 ThreadLocal 對象給清理掉。

那為什麼要這樣設計呢?

假設 Entry 對 key 的引用是強引用,那麼來看一下這個引用鍊:

我把 ThreadLocal 能問的,都寫了

從這條引用鍊可以得知,如果線程一直在,那麼相關的 ThreadLocal 對象肯定會一直在,因為它一直被強引用着。

看到這裡,可能有人會說那線程被回收之後就好了呀。

重點來了!線程在我們應用中,常常是以線程池的方式來使用的,比如 Tomcat 的線程池處理了一堆請求,而線程池中的線程一般是不會被清理掉的,是以這個引用鍊就會一直在,那麼 ThreadLocal 對象即使沒有用了,也會随着線程的存在,而一直存在着!

是以這條引用鍊需要弱化一下,而能操作的隻有 Entry 和 key 之間的引用,是以它們之間用弱引用來實作。

與之對應的還有一個條引用鍊,我結合着上面的線程引用鍊都畫出來:

我把 ThreadLocal 能問的,都寫了

另一條引用鍊就是棧上的 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 就會清理掉,再貼一下這個方法:

我把 ThreadLocal 能問的,都寫了

這個方法也很簡單,我們來看一下它的實作:

我把 ThreadLocal 能問的,都寫了

是以在查找 Entry 的時候,就會順道清理無用的 Entry ,這樣就能防止一部分的記憶體洩露啦!

還有像擴容的時候也會清理無用的 Entry:

我把 ThreadLocal 能問的,都寫了

其它還有,我就不貼了,反正知曉設計者是做了一些操作來回收無用的 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 中已經包含了這個成員:

我把 ThreadLocal 能問的,都寫了

在父線程建立子線程的時候,子線程的構造函數可以得到父線程,然後判斷下父線程的 InheritableThreadLocal 是否有值,如果有的話就拷過來。

我把 ThreadLocal 能問的,都寫了

這裡要注意,隻會線上程建立的時會拷貝 InheritableThreadLocal 的值,之後父線程如何更改,子線程都不會受其影響。

最後

至此有關 ThreadLocal 的知識點就差不多了。

想必你已經清楚 ThreadLocal 的原理,包括如何實作,為什麼 key 要設計成弱引用,并且關于線上程池中使用的注意點等等。

其實本沒打算寫 ThreadLocal 的,因為最近在看 Netty ,是以想寫一下 FastThreadLocal ,但是前置知識點是 ThreadLocal ,是以就幹了這篇。

繼續閱讀