1. 程式人生 > 其它 >併發王者課-鉑金10:能工巧匠-ThreadLocal如何為執行緒打造私有資料空間

併發王者課-鉑金10:能工巧匠-ThreadLocal如何為執行緒打造私有資料空間

說起ThreadLocal,相信你對它的名字一定不陌生。在併發程式設計中,它有著較高的出場率,並且也是面試中的高頻面試題之一,其重要性不言而喻。在本文中,我將和你一起學習它的用法及原理,啃下這塊硬骨頭。

歡迎來到《併發王者課》,本文是該系列文章中的第23篇,鉑金中的第10篇

說起ThreadLocal,相信你對它的名字一定不陌生。在併發程式設計中,它有著較高的出場率,並且也是面試中的高頻面試題之一,所以其重要性不言而喻。當然,它也可能曾經讓你在夜裡輾轉反側,或讓你在面試時閃爍其詞。因為,ThreadLocal雖然使用簡單,但要理解它的原理又似乎並不容易

然而,正所謂明知山有虎,偏向虎山行。在本文中,我將和你一起學習ThreadLocal的用法及其原理,啃下這塊硬骨頭。

關於ThreadLocal的用法和原理,網上也有著非常多的資料可以查閱。遺憾的是,這其中的大部分資料在講解時都不夠透徹。有的是蜻蜓點水,沒有把必要的細節講清楚,有的則比較片面,只講了其中的某個點。

所以,當併發王者課系列寫到這篇文章的時候,如何才能簡明扼要地把ThreadLocal介紹清楚,讓讀者能在一篇文章中透徹地理解它,但同時又要避免萬字長文讀不下去,是我最近一直在思考的問題。為此,在綜合現有資料的基礎上,我精心設計了一些配圖,儘可能地讓文章圖文並茂,以幫助你相對輕鬆地理解ThreadLocal中的精要。然而,每個讀者的背景不同,理解也就不同。所以,對於你認為的並沒有講清楚的地方,希望你在評論區留言反饋,我會盡量調整完善,爭取讓你“一文讀懂”。

一、ThreadLocal使用場景初體驗

夫子的疑惑:在什麼場景下需要使用ThreadLocal

在王者峽谷中,每個英雄都有著自己的領地和莊園。在莊園裡,按照功能職責的不同又劃分為不同的區域,比如有圈養野怪的區域

,還有存放金幣以及武器等不同區域。當然,這些區域都是英雄私有的,不能混淆錯亂

所以,鎧在打野和獲得金幣時,可以把他打的野怪放進自己莊園裡,那是他的私有空間。同樣,蘭陵王和其他英雄也是如此。這個設計如下圖所示:

現在,我們就來編寫一段程式碼模擬上述的場景:

  • 鎧在打野和獲得金幣時,放進他的私有空間裡
  • 蘭陵王在打野和獲得金幣時,放進他的私有空間裡
  • 他們的空間都位於王者峽谷中

以下是我們編寫的一段程式碼。在程式碼中,我們定義了一個wildMonsterLocal變數,用於存放英雄們打野時獲得的野怪;而coinLocal則用於存放英雄們所獲得的金幣。於是,鎧將他所打的棕熊放進了圈養區,並將獲得的500金幣

放進了金幣存放區;而蘭陵王則將他所打的野狼放進了圈養區,並將獲得的100金幣放進了金幣存放區

過了一陣子之後,他們分別取走他們存放的野怪和金幣。

主要示例如下所示。在閱讀下面示例程式碼時,要著重注意對ThreadLocal的getset方法的呼叫。

//私人野怪圈養區
private static final ThreadLocal < WildMonster > wildMonsterLocal = new ThreadLocal < > ();
//私人金幣存放區
private static final ThreadLocal < Coin > coinLocal = new ThreadLocal < > ();

public static void main(String[] args) {
  Thread 鎧 = newThread("鎧", () -> {
    try {
      say("今天打到了一隻棕熊,先把它放進圈養區,改天再享用!");
      wildMonsterLocal.set(new Bear("棕熊"));
      say("路上殺了一些兵線,獲得了一些金幣,也先存起來!");
      coinLocal.set(new Coin(500));

      Thread.sleep(2000);
      note("\n過了一陣子...\n");
      say("從圈養區拿到了一隻:", wildMonsterLocal.get().getName());
      say("金幣存放區現有金額:", coinLocal.get().getAmount());
    } catch (InterruptedException e) {}
  });

  Thread 蘭陵王 = newThread("蘭陵王", () -> {
    try {
      Thread.sleep(1000);
      say("今天打到了一隻野狼,先把它放進圈養區,改天再享用!");
      wildMonsterLocal.set(new Wolf("野狼"));
      say("路上殺了一些兵線,獲得了一些金幣,也先存起來!");
      coinLocal.set(new Coin(100));

      Thread.sleep(2000);
      say("從圈養區拿到了一隻:", wildMonsterLocal.get().getName());
      say("金幣存放區現有金額:", coinLocal.get().getAmount());
    } catch (InterruptedException e) {}
  });
  鎧.start();
  蘭陵王.start();
}

示例程式碼中用到的類如下所示:

@Data
private static class WildMonster {
  protected String name;
}

private static class Wolf extends WildMonster {
  public Wolf(String name) {
    this.name = name;
  }
}

private static class Bear extends WildMonster {
  public Bear(String name) {
    this.name = name;
  }
}

@Data
private static class Coin {
  private int amount;

  public Coin(int amount) {
    this.amount = amount;
  }
}

示例程式碼執行結果如下:

鎧:今天打到了一隻棕熊,先把它放進圈養區,改天再享用!
鎧:路上殺了一些兵線,獲得了一些金幣,也先存起來!
蘭陵王:今天打到了一隻野狼,先把它放進圈養區,改天再享用!
蘭陵王:路上殺了一些兵線,獲得了一些金幣,也先存起來!

過了一陣子...

鎧:從圈養區拿到了一隻:棕熊
鎧:金幣存放區現有金額:500
蘭陵王:從圈養區拿到了一隻:野狼
蘭陵王:金幣存放區現有金額:100

Process finished with exit code 0

從執行的結果中,可以清楚地看到,在過了一陣子之後,鎧和蘭陵王分別取到了他們之前存放的野怪和金幣,並且絲毫不差

以上,就是ThreadLocal應用的典型。在多執行緒併發場景中,如果你需要為每個執行緒設定可以跨越類和方法層面的私有變數,那麼你就需要考慮使用ThreadLocal了。注意,這裡有兩個要點,一是變數為某個執行緒獨享,二是變數可以在不同方法甚至不同的類中共享

ThreadLocal在軟體設計中的應用場景非常多。舉個簡單的例子,在一次請求中,如果你需要設定一個traceId來跟蹤請求的完整呼叫鏈路,那麼你就需要一個能跨越類和方法的變數,這個變數可以讓執行緒在不同的類中自由獲取,且不會出錯,其過程如下圖所示:

二、ThreadLocal原理解析

對於ThreadLocal,一般來說被提及最多的可能就是那個經典的面試問題:談談你對ThreadLocal記憶體洩露的理解。這個問題看起來很簡單,但要回答到點子上的話,就必須對其原始碼有足夠理解。當然,背誦面試題的答案扯一通“軟引用”、“記憶體回收”巴拉巴拉也是可以的,畢竟大部分的面試官也是半吊子。

接下來,我們會結合上文的場景,以及它的示例程式碼來講解ThreadLocal的原理,讓你找到關於這個問題的真正答案。

1. 原始碼分析

如果對ThreadLocal理解有困難的話,很大的可能是:你沒有理清不同概念之間的關係。所以,理解ThreadLocal原始碼的第一步是找出它的相關概念,並理清它們之間的關係,即Thread、ThreadLocalMap和ThreadLocal。正是這三個關鍵概念,唱出了一臺好戲。當然,如果細分的話,你也可以把Entry單獨拎出來。

關鍵概念1:Thread類

為什麼Thread在關鍵概念中排名第一,因為ThreadLocal就是為它而生的。那Thread和ThreadLocal是什麼關係呢?我們這就來看看Thread的原始碼:

class Thread implements Runnable {
    ...
    /* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */
    ThreadLocal.ThreadLocalMap threadLocals = null;
    ...
}

沒有什麼比原始碼展現更清晰的了。你可以非常直觀地看到,Thread中有一個變數:threadLocals. 通俗地說,這個變數就是用來存放當前執行緒的一些私有資料的,並且可以存放多個私有資料,畢竟執行緒是可以攜帶多個私有資料的,比如它可以攜帶traceId,也自然可以攜帶userId等資料。理解了這個變數的用途之後,再看看它的型別,也就是ThreadLocal.ThreadLocalMap.你看,Thread就這樣和ThreadLocal扯上了關係,所以接下來我們來看另外一個關鍵概念。

關鍵概念2:ThreadLocalMap類

從Thread的原始碼中你已經看到,Thread是用ThreadLocalMap來存放執行緒私有資料的。這裡,我們先暫且撇開ThreadLocal,來直接看ThreadLocalMap的原始碼:

static class ThreadLocalMap {
        
        ...

        /**
         * The entries in this hash map extend WeakReference, using
         * its main ref field as the key (which is always a
         * ThreadLocal object).  Note that null keys (i.e. entry.get()
         * == null) mean that the key is no longer referenced, so the
         * entry can be expunged from table.  Such entries are referred to
         * as "stale entries" in the code that follows.
         */
        static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;

            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }

        /**
         * The table, resized as necessary.
         * table.length MUST always be a power of two.
         */
        private Entry[] table;
        ...

}

ThreadLocalMap中最關鍵的屬性就是Entry[] table,正是它實現了執行緒私有多資料的儲存。而Entry則是繼承了WeakReference,並且Entry的Key型別是ThreadLocal. 看到這裡,先不要想著ThreadLocalMap的其他原始碼,你現在應當理解的是,table是執行緒私有資料儲存的地方,而ThreadLocalMap的其他原始碼不過都是為了table資料的存與取而存在的。這是你對ThreadLocalMap理解的關鍵,不要把自己迷失在錯綜複雜的其他原始碼中。

關鍵概念3:ThreadLocal類

現在,目光終於到了ThreadLocal這個類上。Thread中使用到了ThreadLocalMap,而接下來你會發現ThreadLocal不過是封裝了一些對ThreadLocalMap的操作。你看,ThreadLocal中的get()set()remove()等方法都是在操作ThreadLocalMap. 在各種操作之前,都會通過getMap()方法拿到當前執行緒的ThreadLocalMap.

public class ThreadLocal<T> {

    ...

    // 獲取當前執行緒的資料
    public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }
    // 初始化資料
    private T setInitialValue() {
        T value = initialValue();
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
        return value;
    }

    // 設定當前執行緒的資料
    public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }

     public void remove() {
         ThreadLocalMap m = getMap(Thread.currentThread());
         if (m != null)
             m.remove(this);
     }

    // 獲取執行緒私有資料儲存的關鍵,雖然操作在ThreadLocal中,但是實際操作的是Thread中的threadLocals變數
    ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }
    
    // 初始化執行緒的t.threadLocals變數,設定為空值
    void createMap(Thread t, T firstValue) {
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }
    ...
}

如果,此時你對相關概念及其原始碼的理解仍然感到困惑,那就對了。下面這幅圖,將結合相關概念和示例程式碼,來還原這其中的相關概念和它們之間的關係,這幅圖值得你反覆細品。

在上面這幅圖中,你需要如下這些細節:

  • 有兩個執行緒:蘭陵王
  • 有兩個ThreadLocal物件,它們分別用於存放執行緒的私有資料,即英雄們的野怪和金幣;
  • 執行緒鎧執行緒蘭陵王都有一個ThreadLocal.ThreadLocalMap的變數,用來存放不同的ThreadLocal,即wildMonsterLocalcoinLocal這兩個變數都會放進ThreadLocalMap的table裡,也就是entry陣列中;
  • 當執行緒向ThreadLocalMap中放入資料時,它的key會指向ThreadLocal物件,而value則是ThreadLocal中的值。比如,當棕熊放入wildMonsterLocal中時,對應Entry的key是wildMonsterLocal,而value則是new Bear(),即棕熊當蘭陵王放入野怪時,同理;當鎧放入金幣時,也是同理
  • 當Entry的key指向ThreadLocal物件時,比如指向wildMonsterLocalcoinLocal時,注意,是虛引用是虛引用是虛引用是虛引用!重要的事情,說四遍。看圖中的紅線虛線,或ThreadLocalMap原始碼中的WeakReference.

如果你已經看明白上面這幅圖,那麼下面這幅圖中的關係也就應該一目了既然。否則,如果你似乎看不明白它,請回到上面繼續品上面那幅圖,直到你對下圖一目瞭然。

2. 使用指南

接下來,將為你簡單介紹ThreadLocal的一些常見高頻用法。

(1)建立ThreadLocal

像建立其他物件一樣建立即可,沒有什麼特別之處。

ThreadLocal < WildMonster > wildMonsterLocal = new ThreadLocal < > ();

在物件建立完成之後,每個執行緒便可以向其中讀寫資料。當然,每個執行緒都只能看到它們自己的資料。

(2)設定ThreadLocal的值

wildMonsterLocal.set(new Bear("棕熊"));

(3)取出ThreadLocal的值

wildMonsterLocal.get();

在讀取資料時需要注意的是,如果此時還沒有資料設定進來,那麼將會呼叫setInitialValue方法來設定初始值並返回給呼叫方。

(4)取出ThreadLocal的值

wildMonsterLocal.remove();

(5)初始化ThreadLocal的值

private ThreadLocal wildMonsterLocal = new ThreadLocal<WildMonster>() {
    @Override 
    protected WildMonster initialValue() {
        return new WildMonster();
    }
};   

在對ThreadLocal進行get操作時,如果當前尚未進行過資料設定,那麼會執行初始化動作,如果你此時希望設定初始值,可以重寫它的initialValue方法。

3. 如何理解ThreadLocal的記憶體洩露問題

首先,你要理解弱引用這個概念。在Java中,引用分為強引用、弱引用、軟引用、虛幻引用等不同的引用型別,而不同的引用型別對應的則是不同的垃圾回收策略。如果你對此不熟的話,建議可以去檢索相關資料,也可以看這篇

對於弱引用,在垃圾回收器執行緒掃描它所管轄的記憶體區域的過程中,一旦發現了只具有弱引用的物件,不管當前記憶體空間足夠與否,都會回收它的記憶體。不過,由於垃圾回收器是一個優先順序很低的執行緒,因此不一定會很快發現那些只具有弱引用的物件。但是,即便是偶爾發生,也足夠造成問題。

當你理解了弱引用和對應的垃圾回收策略之後,此刻,請回到上面的那幅圖:

在這幅圖裡,Entry的key指向ThreadLocal物件時,用的正是弱引用,圖中已經紅色箭頭標註。這裡的紅色虛線會造成上面問題呢?你想想看,如果此時ThreadLocal物件被回收時,那麼Entry中的key就程式設計了null. 可是,雖然key(wildMonsterLocal)變成了null,value的值(new Bear("棕熊"))還是強引用,它還會繼續存在,但實際已經沒有用了,所以會造成這個Entry就廢了,但是因為value的存在卻不能被回收。於是,記憶體洩露就這樣產生了。

那既然如此,為什麼要使用弱引用?

相信你一定有這個疑問,如果沒有,這篇文章你可能需要再讀一遍。明知這裡會產生記憶體洩露的風險,卻仍然使用弱引用的原因在於:當ThreadLocal物件沒有強引用時,它們需要被清理,否則它們長期存在於ThreadLocalMap中,也是一種記憶體洩露。你看,問題就是這樣的一環扣著一環。

最佳實踐:如何避免記憶體洩露

那麼,既然事已如此,如何避免記憶體洩露呢?這裡給出一個可行的最佳實踐:在呼叫完成後,手動執行remove()方法

private static final ThreadLocal<WildMonster> wildMonsterLocal = new ThreadLocal<>();

try{
    wildMonsterLocal.get();
    ...
}finally{
    wildMonsterLocal.remove();
}

除此之外,ThreadLocal也給出一個方案:在呼叫set方法設定時,會呼叫replaceStaleEntry方法來檢查key為null的Entry。如果發現有key為null的Entry,那麼會將它的value也設定為null,這樣Entry便可以被回收。當然,如果你沒有再呼叫set方法,那麼這個方案就是無效的

private void set(ThreadLocal < ? > key, Object value) {

     ...

     for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
         ThreadLocal < ? > k = e.get();

         if (k == key) {
             e.value = value;
             return;
         }

         if (k == null) {
             replaceStaleEntry(key, value, i); //看這裡
             return;
         }
     }

     ...
 }

小結

以上就是關於ThreadLocal的全部內容。在學習ThreadLocal時,首先要理解的是它的應用場景,即它所要解決的問題。其次,對它的原始碼要有一定的瞭解。在瞭解原始碼時,要注意從Thread、ThreadLocal和ThreadLocalMap三個概念出發,理解他們之間的關係。如此,你才能完全理解常見的記憶體洩露問題是怎麼一回事。

正文到此結束,恭喜你又上了一顆星✨

夫子的試煉

  • 嘗試向你的朋友解釋ThreadLocal記憶體洩露是如何發生的。

延伸閱讀與參考資料

關於作者

關注【技術八點半】,及時獲取文章更新。傳遞有品質的技術文章,記錄平凡人的成長故事,偶爾也聊聊生活和理想。早晨8:30推送作者品質原創,晚上20:30推送行業深度好文。

如果本文對你有幫助,歡迎點贊關注監督,我們一起從青銅到王者