並發王者課-鉑金10:能工巧匠-ThreadLocal如何為執行緒打造私有數據空間
歡迎來到《並發王者課》,本文是該系列文章中的第23篇,鉑金中的第10篇。
說起ThreadLocal,相信你對它的名字一定不陌生。在並發編程中,它有著較高的出場率,並且也是面試中的高頻面試題之一,所以其重要性不言而喻。當然,它也可能曾經讓你在夜裡輾轉反側,或讓你在面試時閃爍其詞。因為,ThreadLocal雖然使用簡單,但要理解它的原理又似乎並不容易。
然而,正所謂明知山有虎,偏向虎山行。在本文中,我將和你一起學習ThreadLocal的用法及其原理,啃下這塊硬骨頭。
關於ThreadLocal的用法和原理,網上也有著非常多的資料可以查閱。遺憾的是,這其中的大部分資料在講解時都不夠透徹。有的是蜻蜓點水,沒有把必要的細節講清楚,有的則比較片面,只講了其中的某個點。
所以,當並發王者課系列寫到這篇文章的時候,如何才能簡明扼要地把ThreadLocal介紹清楚,讓讀者能在一篇文章中透徹地理解它,但同時又要避免萬字長文讀不下去,是我最近一直在思考的問題。為此,在綜合現有資料的基礎上,我精心設計了一些配圖,儘可能地讓文章圖文並茂,以幫助你相對輕鬆地理解ThreadLocal中的精要。然而,每個讀者的背景不同,理解也就不同。所以,對於你認為的並沒有講清楚的地方,希望你在評論區留言回饋,我會盡量調整完善,爭取讓你「一文讀懂」。
一、ThreadLocal使用場景初體驗
夫子的疑惑:在什麼場景下需要使用ThreadLocal?
在王者峽谷中,每個英雄都有著自己的領地和莊園。在莊園里,按照功能職責的不同又劃分為不同的區域,比如有圈養野怪的區域,還有存放金幣以及武器等不同區域。當然,這些區域都是英雄私有的,不能混淆錯亂。
所以,鎧在打野和獲得金幣時,可以把他打的野怪放進自己莊園里,那是他的私有空間。同樣,蘭陵王和其他英雄也是如此。這個設計如下圖所示:
現在,我們就來編寫一段程式碼模擬上述的場景:
- 鎧在打野和獲得金幣時,放進他的私有空間里;
- 蘭陵王在打野和獲得金幣時,放進他的私有空間里;
- 他們的空間都位於王者峽谷中。
以下是我們編寫的一段程式碼。在程式碼中,我們定義了一個wildMonsterLocal
變數,用於存放英雄們打野時獲得的野怪;而coinLocal
則用於存放英雄們所獲得的金幣。於是,鎧將他所打的棕熊放進了圈養區,並將獲得的500金幣放進了金幣存放區;而蘭陵王則將他所打的野狼放進了圈養區,並將獲得的100金幣放進了金幣存放區。
過了一陣子之後,他們分別取走他們存放的野怪和金幣。
主要示例如下所示。在閱讀下面示例程式碼時,要著重注意對ThreadLocal的get
和set
方法的調用。
//私人野怪圈養區
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,即
wildMonsterLocal
和coinLocal
這兩個變數都會放進ThreadLocalMap的table
里,也就是entry數組中; - 當執行緒向ThreadLocalMap中放入數據時,它的key會指向ThreadLocal對象,而value則是ThreadLocal中的值。比如,當鎧將棕熊放入
wildMonsterLocal
中時,對應Entry的key是wildMonsterLocal
,而value則是new Bear()
,即棕熊。當蘭陵王放入野怪時,同理;當鎧放入金幣時,也是同理; - 當Entry的key指向ThreadLocal對象時,比如指向
wildMonsterLocal
或coinLocal
時,注意,是虛引用,是虛引用,是虛引用,是虛引用!重要的事情,說四遍。看圖中的紅線虛線,或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推送行業深度好文。
如果本文對你有幫助,歡迎點贊、關注、監督,我們一起從青銅到王者。