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