ThreadLocal必知必會

淡墨痕發表於2020-05-22

前言

自從被各大網際網路公司的"造火箭"級面試難度吊打之後,痛定思痛,遂收拾心神,從基礎的知識點開始展開地毯式學習。每一個非天才程式猿都有一個對35歲的恐懼,而消除恐懼最好的方式就是面對它、看清它、乃至跨過它,學習就是這個世界給普通人提供的一把成長型武器,掌握了它,便能與粗暴的生活一戰。

最近看了好幾篇有關ThreadLocal的面試題和技術部落格,下面結合原始碼自己做一個總結,以方便後面的自我回顧。

本文重點:

1、ThreadLocal如何發揮作用的?

2、ThreadLocal設計的巧妙之處

3、ThreadLocal記憶體洩露問題

4、如何讓新執行緒繼承原執行緒的ThreadLocal?

下面開始正文。

一、ThreadLocal如何發揮作用的?

首先來一段本地demo,工作中用的時候也是類似的套路,先宣告一個ThreadLocal,然後呼叫它的set方法將特定物件存入,不過用完之後一定別忘了加remove,此處是一個錯誤的示範...

 1 public class ThreadLocalDemo {
 2 
 3     private static ThreadLocal<String> threadLocal = new ThreadLocal<String>();
 4 
 5     public static void main(String[] args) {
 6         threadLocal.set("main thread");
 7         new Thread(() -> {
 8             threadLocal.set("thread");
 9         }).start();
10     }
11 }

追蹤一下set方法:

1     public void set(T value) {
2         Thread t = Thread.currentThread();
3         ThreadLocalMap map = getMap(t); // 1、得到map
4         if (map != null)
5             map.set(this, value); // 2、放入value
6         else
7             createMap(t, value); // 3、初始化map
8     }

在threadLocal的set方法中有三個主要方法,第一個方法是去當前執行緒的threadLocals中獲取map,該map是Thread類的一個成員變數。

 

 如果執行緒是新建出來的,threadLocals這個值肯定是null,此時會進入方法3 createMap中(如下)新建一個ThreadLocalMap,存入當前的ThreadLocal物件和value。

1 void createMap(Thread t, T firstValue) {
2         t.threadLocals = new ThreadLocalMap(this, firstValue);
3     }

相對而言最複雜的是方法2 map.set()方法,如下,該方法程式碼位於ThreadLocal的內部類ThreadLocalMap中。

 1 private void set(ThreadLocal<?> key, Object value) {
 2 
 3             // We don't use a fast path as with get() because it is at
 4             // least as common to use set() to create new entries as
 5             // it is to replace existing ones, in which case, a fast
 6             // path would fail more often than not.
 7 
 8             Entry[] tab = table;
 9             int len = tab.length;
10             int i = key.threadLocalHashCode & (len-1); // 1、獲取要存放的key的陣列下標
11 
12             for (Entry e = tab[i];
13                  e != null;
14                  e = tab[i = nextIndex(i, len)]) { ///2、如果下標所在位置是空的,則直接跳過此for迴圈,不為空則進入內部判斷邏輯,否則往下移動陣列指標 ***
15                 ThreadLocal<?> k = e.get();
16                 // 2.1 如果不是空,則判斷key是不是原陣列下標處Entry物件的key,是的話直接替換value即可
17                 if (k == key) {
18                     e.value = value;
19                     return;
20                 }
21                 // 2.2 如果陣列下標處的Entry的key是null,說明弱引用已經被回收,此時也替換掉value ***
22                 if (k == null) {
23                     replaceStaleEntry(key, value, i);
24                     return;
25                 }
26             }
27             // 3、說明陣列中i所在位置是空的,直接new一個Entry賦值
28             tab[i] = new Entry(key, value);
29             int sz = ++size;
30             if (!cleanSomeSlots(i, sz) && sz >= threshold) // 4、清理掉一些無用的資料 ***
31                 rehash();
32         }

該方法加了註釋,重要的地方均用 *** 標識了出來,雖然可能無法清楚每一步的用意與原理,但大體做了什麼都能知道---在此方法中完成了value物件的儲存

寫到這裡的時候,BZ的思維也不清晰了,趕緊畫個圖清醒下:

 

 完成set操作後,當前執行緒、threadLocal變數、ThreadLocal物件、ThreadLocalMap之間的關係基本梳理出來了。

插播一個擴充套件,補充一下引用相關的知識。Java中的強引用是除非程式碼主動修改或者持有引用的變數被清理,否則該引用指向的物件一定不會被垃圾回收器回收;軟引用是隻要JVM記憶體空間夠用,就不會對該引用指向的物件進行垃圾回收;而弱引用是隻要進行垃圾回收時該物件只有弱引用,則就會被回收。

Entry類的弱引用實現如下所示:

1 static class Entry extends WeakReference<ThreadLocal<?>> {
2             /** The value associated with this ThreadLocal. */
3             Object value;
4 
5             Entry(ThreadLocal<?> k, Object v) {
6                 super(k);
7                 value = v;
8             }
9         }

下面開始填坑。

 

二、ThreadLocal設計的巧妙之處

上面ThreadLocalMap.set方法的程式碼中,標識了三顆星的第二步有什麼意義?

 答:找到第一個未被佔的下標位置。ThreadLocalMap中的Entry[]陣列是一個環狀結構,通過nextIndex方法即可證明,當i+1比len大的時候,返回0即初始位置。當出現hash衝突時,HashMap是通過在下標位置串接連結串列來存放資料,而ThreadLocalMap不會有那麼大的訪問量,所以採用了更加輕便的解決hash衝突的方式-往後移一個位置,看看是不是空的,不是空的則繼續往後移,直到找到空的位置。

1 private static int nextIndex(int i, int len) {
2             return ((i + 1 < len) ? i + 1 : 0);
3         }

 

為什麼編寫JDK程式碼的大佬們要將Entry的key設定為弱引用?標識了三顆星的2.2步為什麼key會是null?

答:key設定為弱引用是為了當threadLocal被清理之後堆中的ThreadLocal物件也能被清理掉,避免ThreadLocal物件帶來的記憶體洩露。這也是key是null的原因-當只有key這個弱引用指向ThreadLocal物件時,發生一次垃圾回收就會將該ThreadLocal回收了。但這種方式沒法完全避免記憶體洩露,因為回看之前的記憶體分佈圖,key指向的物件雖然被釋放了記憶體,但是value還在啊,而且由於這個value對應的key是null,也就不會有地方使用這個value,完蛋,記憶體釋放不了了。

這時2.2的邏輯就發揮一部分作用了,如果當前i下標的key是null,說明已經被回收了,那麼直接把這個位置佔用就行了,反正已經沒人用了。

 

標識了三顆星的第四步 cleanSomeSlots方法的職責是什麼?

 答:該方法用於清除部分key為null的Entry物件。為什麼是清除部分呢?且看方法實現:

 1 private boolean cleanSomeSlots(int i, int n) {
 2             boolean removed = false;
 3             Entry[] tab = table;
 4             int len = tab.length;
 5             do {
 6                 i = nextIndex(i, len);
 7                 Entry e = tab[i];
 8                 if (e != null && e.get() == null) {
 9                     n = len;
10                     removed = true;
11                     i = expungeStaleEntry(i);
12                 }
13             } while ( (n >>>= 1) != 0);
14             return removed;
15         }

在do/while迴圈中,每次迴圈給n右移一位(傳入的n是陣列中存放的資料個數),如果遇到一個key為null的情況, 說明陣列中可能存在多個這種物件,所以將n置為整個陣列的長度,多迴圈幾次,並且呼叫了expungeStaleEntry方法將key為null的value引用去掉。cleanSomeSlots方法沒有采用完全迴圈遍歷的方式,主要出於方法執行效率的考量。

下面再詳細說說expungeStaleEntry方法的邏輯,該方法專門用於清除key為null的這種過期資料,而且還附帶一個作用:將之前因為hash衝突導致下標後移的物件收縮緊湊一些,提高遍歷查詢效率。

 1 private int expungeStaleEntry(int staleSlot) {
 2             Entry[] tab = table;
 3             int len = tab.length;
 4             // 1、清除入參所在下標的value
 5             // expunge entry at staleSlot
 6             tab[staleSlot].value = null;
 7             tab[staleSlot] = null;
 8             size--;
 9             // 2、從入參下標開始往後遍歷,一直遍歷到tab[i]等於null的位置停止
10             // Rehash until we encounter null
11             Entry e;
12             int i;
13             for (i = nextIndex(staleSlot, len);
14                  (e = tab[i]) != null;
15                  i = nextIndex(i, len)) {
16                 ThreadLocal<?> k = e.get();
17                 if (k == null) { // 2.1 如果key為null,找的就是這種渾水摸魚的,必除之而後快
18                     e.value = null;
19                     tab[i] = null;
20                     size--;
21                 } else {
22                     int h = k.threadLocalHashCode & (len - 1);
23                     if (h != i) { // 2.2 h即當前這個entry的key應該在的下標位置,如果跟i不同,說明這個entry是發生下標衝突後移過來的
24                         tab[i] = null; // 此時要將現在處於i位置的e移到h位置,故先將tab[i]置為null,在後面再將tab[i]位置的e存入h位置
25 
26                         // Unlike Knuth 6.4 Algorithm R, we must scan until
27                         // null because multiple entries could have been stale.
28                         while (tab[h] != null) // 2.3 這裡通過while迴圈來找到h以及後面第一個為null的下標位置,這個位置就是存放e的位置
29                             h = nextIndex(h, len);
30                         tab[h] = e;
31                     }
32                 }
33             }
34             return i;
35         }

 

為什麼存放執行緒相關的變數要這樣設計?為何不能在ThreadLocal中定義一個Map的成員變數,key就是執行緒,value就是要存放的物件,這樣設計豈不是更簡潔易懂?

答:這樣設計能做到訪問效率和空間佔用的最優。先看訪問效率,如果採用平常思維的方式用一個公共Map來存放key-value,則當多執行緒訪問的時候肯定會有訪問衝突,即使使用ConcurrentHashMap也同樣會有鎖競爭帶來的效能消耗,而現在這種將map存入Thread中的設計,則保證了一個執行緒只能訪問自己的map,並且是單執行緒肯定不會有執行緒安全問題,簡直不要太爽。

 

三、ThreadLocal記憶體洩露問題

文章開頭的示例中,用static修飾了ThreadLocal,這樣做是否必要?有什麼作用?

答:用static修飾ThreadLocal變數,使得在整個執行緒執行過程中,Map中的key不會被回收(因為有一個靜態變數的強引用在引用著呢),所以想什麼時候取就什麼時候取,而且從頭到尾都是同一個threadLocal變數(再new一個除外),存入map中時也只佔用一個下標位置,不會出現不可控的記憶體佔用超限。由此可見,設定為static並不是完全必要,但作用是有的。

ThreadLocal中針對key為null的情況,在好幾處用不同的姿勢進行清除,就是為了避免記憶體洩漏,這樣是否能完全避免記憶體洩漏?若不能,如何做才能完全避免?

答:能最大程度的避免記憶體洩漏,但不能完全避免。執行緒執行完了就會將ThreadLocalMap記憶體釋放,但如果是執行緒池中的執行緒,一直重複利用,那麼它的Map中的value資料就可能越攢越多得不到釋放引起記憶體洩露。如何避免?用完後在finally中調一下remove方法吧,前輩大佬們都給寫好了的方法,且用即可。

 

另外,threadLocal變數不能是區域性變數,因為key是弱引用,如果設定成區域性變數,則方法執行完之後強引用清除只剩弱引用,就可能被釋放掉,key變為null,這樣也就背離了ThreadLocal在同一個執行緒經過多個方法時共享同一個變數的設計初衷。

 

四、如何讓新執行緒繼承原執行緒的ThreadLocal?

 答:new一個InheritableThreadLocal物件set資料即可,這時會存入當前Thread的成員變數 inheritableThreadLocals中。當在當前執行緒中new一個新執行緒時,在新執行緒的init方法中會將當前執行緒的inheritableThreadLocals存入新執行緒中,完成資料的繼承。

 

 Old Thread(ZZQ):畢生功力都傳授給你了,還不趕緊去為禍人間?

New Thread(Pipe River): ...

相關文章