JUC---ThreadLocal原理詳解

_Elmer發表於2024-11-16

什麼是ThreadLocal?

通常情況下,我們建立的變數是可以被任何一個執行緒訪問並修改的。如果想實現每一個執行緒都有自己的專屬本地變數該如何解決呢?

JDK 中自帶的ThreadLocal類正是為了解決這樣的問題。 ThreadLocal類主要解決的就是讓每個執行緒繫結自己的值,可以將ThreadLocal類形象的比喻成存放資料的盒子,盒子中可以儲存每個執行緒的私有資料。

如果你建立了一個ThreadLocal變數,那麼訪問這個變數的每個執行緒都會有這個變數的本地副本,這也是ThreadLocal變數名的由來。他們可以使用 get()set() 方法來獲取預設值或將其值更改為當前執行緒所存的副本的值,從而避免了執行緒安全問題

ThreadLocal 原理了解嗎?

最終的變數是放在了當前執行緒的 ThreadLocalMap 中,並不是存在 ThreadLocal 上,ThreadLocal 可以理解為只是ThreadLocalMap的封裝,傳遞了變數值。 ThreadLocal 類中可以透過Thread.currentThread()獲取到當前執行緒物件後,直接透過getMap(Thread t)可以訪問到該執行緒的ThreadLocalMap物件。

每個Thread中都具備一個ThreadLocalMap,而ThreadLocalMap可以儲存以ThreadLocal為 key ,Object 物件為 value 的鍵值對。

ThreadLocal類中有靜態內部類ThreadLocalMap,在ThreadLocalMap類中也有靜態內部類Entry,而這個Entry類繼承自WeakReference

static class ThreadLocalMap {  
    static class Entry extends WeakReference<ThreadLocal<?>> {  
        Object value;  
        Entry(ThreadLocal<?> k, Object v) {  
            super(k);  
            value = v;  
        }  
    }
//.....
}

比如我們在同一個執行緒中宣告瞭兩個 ThreadLocal 物件的話, Thread內部都是使用僅有的那個ThreadLocalMap 存放資料的,ThreadLocalMap的 key 就是 ThreadLocal物件,value 就是 ThreadLocal 物件呼叫set方法設定的值。

ThreadLocal 資料結構如下圖所示:

在每條執行緒Thread內部有一個ThreadLocal.ThreadLocalMap型別的成員變數threadLocals,這個threadLocals就是每條執行緒用來儲存變數副本的,key值為當前ThreadLocal物件,value為變數副本(即T型別的變數)。每個Thread執行緒物件最開始的threadLocals都為空,當執行緒呼叫ThreadLocal.set()或ThreadLocal.get()方法時(get方法待會而會分析到),都會呼叫createMap()方法對threadLocals進行初始化。然後在當前執行緒裡面,如果要使用副本變數,就可以透過get方法在threadLocals裡面查詢。

ThreadLocalMap

ThreadLocal的原理是涉及三個核心類:ThreadLocalThread以及ThreadLocalMap類。在Thread類中存在兩個成員變數:threadLocalsinheritableThreadLocals,這兩個成員變數的型別都為ThreadLocalMap,經過一系列分析後我們可以得知,這兩個成員變數是儲存執行緒變數副本的最終容器,而前面也曾提到過:ThreadLocalMapThreadLocal中定製版的HashMap,但是它並沒有實現Map介面,而是自己內部透過陣列型別儲存Entry實現。而Entry只是簡單的繼承了WeakReference弱引用,並沒有沒有實現類似HashMapNode.next的後繼節點指向,所以ThreadLocalMap並不是連結串列形式的實現。哪沒有了連結串列結構之後,ThreadLocalMap是如何解決雜湊衝突的呢?

ThreadLocalMap是如何解決雜湊衝突的呢? ---開放定址法

在呼叫createMap()方法建立ThreadLocalMap示例時,在ThreadLocalMap的構造方法中,會為成員變數table初始化一個長度為16的Entry陣列,透過hashCodelength位運算確定出一個下標索引值i,這個i就是被儲存在table陣列中的下標位置。

每條執行緒的threadlocals都會在內部維護獨立table陣列,而每個ThreadLocal物件在不同的執行緒table中位置都是相同的。對於同一條執行緒而言,不同的ThreadLocal變數副本都會被封裝成一個個的Entry物件儲存在自己內部的table中。

ok~,接著往下說,經過int i = key.threadLocalHashCode & (len-1);計算出索引下標值之後,會開始遍歷table,然後會開始判斷,如果table[i]位置不為空,但是原本的key值和現在新的key值是相同的情況下,則使用現在的新值替換掉之前的老值,重新整理value值並返回;如果table[i]位置為空,則建立一個的Entry物件封裝K-V值並將該物件放在table[i]位置;如果table[i]位置不為空並且Key不相同時,哪就呼叫nextIndex(i,len)獲取下一個位置資訊並判斷下一個位置是否為空,直到找到為空的位置為止;在table[i]位置不為空並且Key不相同的情況下,如果遍歷完整個table陣列也沒有找到為空的下標位置時,代表陣列已經存滿了需要擴容,則呼叫rehash()對陣列擴容兩倍

整個ThreadLocalMap儲存過程結束,如下:

在get時,也會根據ThreadLocal物件的雜湊值跟table陣列長度進行計算獲取下標索引值i,然後判斷該位置Entry物件的key值與get(key)的key是否相同,如果相同則直接獲取該位置的值並返回。如果不相同則遍歷整個陣列中table[i]之後的所有元素,迴圈判斷下一個位置的key是否與傳入進來的key一致,如果一致則獲取返回

ThreadLocal 記憶體洩露問題是怎麼導致的?

ThreadLocalMap 中使用的 key 為 ThreadLocal 的弱引用,而 value 是強引用。所以,如果 ThreadLocal 沒有被外部強引用的情況下,在垃圾回收的時候,key 會被清理掉,而 value 不會被清理掉。

這樣一來,ThreadLocalMap 中就會出現 key 為 null 的 Entry。假如我們不做任何措施的話,value 永遠無法被 GC 回收,這個時候就可能會產生記憶體洩露。ThreadLocalMap 實現中已經考慮了這種情況,在呼叫 set()get()remove() 方法的時候,會清理掉 key 為 null 的記錄。使用完 ThreadLocal方法後最好手動呼叫remove()方法

可不可以把value也變成弱引用?

不可以。因為存進ThreadLocal中正在使用的物件,線上程的棧中也有引用的,這是一根強引用指標,所以只要執行緒還在使用,就算記憶體不足,對應的Key也不會被回收;反之,如果key和value的關係都設計成弱引用,這時假設記憶體不足,觸發GC就會導致value被回收,因為執行緒本身不直接持有value,而是透過key來間接性的訪問value,如果value也是弱引用,就會出現“key還在,value因為記憶體不足,導致被GC回收”的問題

可不可以把key變成強引用?

不可以。既然key被設計成了弱引用,所以才會導致key=null的情況出現,那假設把key設計成強引用,是不是就解決了這個問題呢?先看個例子:

public static void main(String[] args) {  
    ThreadLocal TL = new ThreadLocal();  
    TL.set(new Object());  
    TL = null;  
}  

這裡建立了一個ThreadLocal物件TL,並設定一個Object物件,然後將其置空。如果Key是強引用的話,TL無法被回收,也無法被訪問,Object無法被回收,也無法被訪問,Key和Value同時出現了記憶體洩漏

為啥K-V都記憶體洩漏了呢?因為最後一行置空程式碼,只能將main執行緒棧中的引用置空,而Thread物件內部有一個threadLocals成員,依舊會保持與ThreadLocalMap的引用,而Map的Key又強引用自ThreadLocal,這時main執行緒的棧,雖然沒有引用這個TL,但Map卻在引用著它,最終就導致了K-V都記憶體洩漏。

上面也是ThreadLocalMap中,為什麼Key被設計成弱引用的原因,而且ThreadLocal也在儘可能的避免記憶體洩漏,當你呼叫set/get/remove()方法時,都會清理過期的Key(呼叫remove方法是最有效的)

綜上所述:key設計成弱引用反而是最好的選擇

ThreadLocalMap擴容機制瞭解嗎?

  1. 觸發擴容的條件
    • ThreadLocalMap初始容量是 16,它在儲存元素時,當元素個數達到閾值(threshold)就會觸發擴容。閾值的計算方式是陣列容量(table.length)的三分之二
    • 例如,初始容量為 16 時,當儲存的元素個數達到16 * 2/3 = 10(向下取整)個元素時,就會觸發擴容。
  2. 擴容過程
    • 擴容是建立一個新的Entry陣列,新陣列的大小是原來的兩倍
    • 然後遍歷舊陣列中的所有Entry,將其重新雜湊(rehash)到新陣列中。在重新雜湊的過程中,會處理可能出現的雜湊衝突。
    • 對於雜湊衝突,ThreadLocalMap採用線性探測法來解決。即當發生衝突時,會順序查詢下一個可用的位置來儲存元素。在擴容後的重新雜湊過程中,這個線性探測的邏輯也會起作用。
    • 假設舊陣列中有一個Entry在位置i,重新雜湊時,它會先計算新的索引位置i' = i & (newLength - 1)(其中newLength是新陣列的長度),如果這個位置沒有被佔用,就將Entry放入該位置;如果被佔用了,就會線性探測下一個位置,直到找到一個空閒位置。

ThreadLocal怎麼實現執行緒隔離的?

由於每個執行緒都有自己獨立的ThreadLocalMap,所以不同執行緒之間的ThreadLocal變數是相互隔離的。即使多個執行緒使用了相同的ThreadLocal物件,它們所操作的也是各自執行緒中的變數副本,不會相互影響。

要說是怎麼實現執行緒隔離的,其實就是在set()、get()方法的具體實現,我們set的值,為什麼不會被其他的執行緒所讀取。

Set()方法:

public void set(T value) {
    // 1、獲取當前執行緒
    Thread t = Thread.currentThread();
    // 2、獲取當前執行緒的threadlocals成員變數
    ThreadLocalMap map = getMap(t);
    // 3、判斷map是否為null
    if (map != null)
        // 如果不為null,就直接將value放進map中
       // key是當前的threadLocal,value就是傳進來的值
        map.set(this, value);
    else
        // 如果為 null,初始化一個map,再將value 放進map中
       // key是當前的threadLocal,value就是傳進來的值
        createMap(t, value);
}

Get()方法:

public T get() {
    // 獲取到當前執行緒
    Thread t = Thread.currentThread();
   // 2、獲取當前執行緒的threadlocals成員變數
    ThreadLocalMap map = getMap(t);
    //3、判斷map是否為null
    if (map != null) 
        //3.1、如果不為null,根據當前的ThreadLocal 從當前執行緒中的ThreadLocals中取出map儲存的變數副本
        ThreadLocalMap.Entry e = map.getEntry(this);
        // 如果儲存的值不為null,就返回值
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    //
    return setInitialValue();
}
  • 當在一個執行緒中呼叫ThreadLocalget()方法獲取變數值時,它會首先獲取當前執行緒的ThreadLocalMap,然後根據當前的ThreadLocal物件作為鍵,從ThreadLocalMap中查詢對應的變數值並返回。如果找不到,則會返回null或根據初始化方法返回預設值。

這就是ThreadLocal的原理~~~❤️❤️❤️

相關文章