前言
自從被各大網際網路公司的"造火箭"級面試難度吊打之後,痛定思痛,遂收拾心神,從基礎的知識點開始展開地毯式學習。每一個非天才程式猿都有一個對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): ...