上文我們學習了ThreadLocal的基本用法以及基本原理,ThreadLocal中的方法並不多,基本用到的也就get、set、remove等方法,但是其核心邏輯還是在定義在ThreadLocal內部的靜態內部類ThreadLocalMap中,里面有很多設計非常精妙的地方,本文中我們就從ThreadLocalMap的角度入手深入學習ThreadLocal的原理。
1. 基本數據結構
按照官方的解釋是:這是一個定制化的Hash類型的map,專門用來保存線程本地變量。其內部采用是通過一個自定義的Entry來封裝數據,並且保存在一個Entry數組中。為了便於處理大量且長時間存活的對象引用(其實是ThreadLocal),Entry采用WeakReference作為key的類型,當map中空間不夠時,key為null的ertry將會被刪除。ThreadLocalMap內部數據結構如下:
static class ThreadLocalMap { static class Entry extends WeakReference<ThreadLocal<?>> { /** 要保存到線程本地的變量 */ Object value; Entry(ThreadLocal<?> k, Object v) { super(k); value = v; } } /** * 數組初始容量 -- 必須為2的倍數. */ private static final int INITIAL_CAPACITY = 16; /** * 存儲entry的數組,長度為2的倍數 */ private Entry[] table; /** * entries數量 */ private int size = 0; /** * resize閾值 */ private int threshold; // Default to 0 /** * 計算閾值 */ private void setThreshold(int len) { threshold = len * 2 / 3; } /** * i+1,大於等於len則從0開始繼續 */ private static int nextIndex(int i, int len) { return ((i + 1 < len) ? i + 1 : 0); } /** * i-1,小於0則從len-1開始繼續 */ private static int prevIndex(int i, int len) { return ((i - 1 >= 0) ? i - 1 : len - 1); } ...... }
在ThreadLocalMap內部通過自定義的Entry類來封裝要保存的數據,以ThreadLocal類型對象為key,Object類型對象為value。這個Entry繼承自WeakReference<ThreadLocal<?>>,每個Entry都可以是一個指向ThreadLocal對象的弱引用,可通過Entry的get方法來獲取對ThreadLocal對象的引用,而這個引用就是key。所有的Entry統一保存在一個Entry數組table中,數組的長度必須為2的倍數,通過key的hashcode與數組長度減1進行與運算來定位Entry在數組中的存儲位置,這點和hashmap類似,但是當發生hash碰撞時hashmap的處理方法是放入鏈表或者樹中(都在同一個hash桶中),而ThreadLocalMap則是依次往后查找可以保存的地方,沒有桶的概念(這點后面會結合代碼詳細講)。
既然ThreadLocalMap內部是一個數組,通過key的hashcode來定位到數組下標,這里我們不得不說一下key的hashcode的生成方式,非常精妙,因為key類型為ThreadLocal,所以其hashcode的生成方式也在ThreadLocal中:
private final int threadLocalHashCode = nextHashCode(); private static AtomicInteger nextHashCode = new AtomicInteger(); private static final int HASH_INCREMENT = 0x61c88647; private static int nextHashCode() { return nextHashCode.getAndAdd(HASH_INCREMENT); }
對於每個ThreadLocal對象,都有一個獨自不變的hashcode,每新增一個ThreadLocal對象,會自動生成其自己的hashcode,其實就是讓nextHashCode自增0x61c88647,目的是為了讓生成的hashcode均勻的分布在2的冪次方上,而數組長度也是2的冪次方,這樣就保證了要插入的元素可以均勻分布在數組中。
雖然ThreadLocal使用了很牛逼的辦法來生成hashcode,但是還是不可避免會產生hash碰撞,當出現碰撞時是如何來處理呢?我們接着看:
2. 獲取元素
我們知道ThreadLocalMap是以Entry為基本單元保存數據的,而且是以key-value對的形式,我們先來看一下是如何通過key獲取到Entry的:
private Entry getEntry(ThreadLocal<?> key) { int i = key.threadLocalHashCode & (table.length - 1); Entry e = table[i]; if (e != null && e.get() == key) return e; else return getEntryAfterMiss(key, i, e); }
這個邏輯比較簡單:
- 首先通過key的hashcode獲取數組下標(與運算);
- 如果下標對應處Entry不為空,且key與傳入的key是指向同一個ThreadLocal對象則認為找到,直接返回Entry;
- 否則執行getEntryAfterMiss;
/**
* 有三種情況下會執行這個方法
* 1. e為null;
* 2. e!=null,e的key=null;
* 3. e!=null,e的key!=null,e的key!=要找的key,即出現hash碰撞
**/
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) { Entry[] tab = table; int len = tab.length; while (e != null) { ThreadLocal<?> k = e.get(); if (k == key) return e; if (k == null) expungeStaleEntry(i); else i = nextIndex(i, len); // 出現碰撞,則依次往后找 e = tab[i]; } return null; }
這里的邏輯也比較清晰:
- 獲取內部保存Entry的數組及數組長度;
- 獲取傳入Entry對應的key,如果和傳入的key相等則直接返回key;
- 如果Entry對應的key為空,則執行expungeStaleEntry,傳入的參數為當前Entry所在數組下標i;
- 否則將獲取e在數組中后面那個元素並賦值給e,如果e不為空,則循環從第2步執行,否則直接退出循環;
對於key為空的Entry在ThreadLocal里面稱為staleSlot,接下來看一下expungeStaleEntry:
private int expungeStaleEntry(int staleSlot) { Entry[] tab = table; int len = tab.length; // 直接將下標為staleSlot處的元素擦除,value和Entry都要擦除 tab[staleSlot].value = null; tab[staleSlot] = null; size--; // Rehash操作直到數組對應下標處元素為空的情況 Entry e; int i; for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) { ThreadLocal<?> k = e.get(); if (k == null) { e.value = null; tab[i] = null; size--; } else { int h = k.threadLocalHashCode & (len - 1); if (h != i) { tab[i] = null; while (tab[h] != null) h = nextIndex(h, len); tab[h] = e; } } } return i; }
邏輯會稍微復雜一些,我們還是一步一步看:
- 獲取內部保存Entry的數組及數組長度;
- key為空代表這個Entry已經不需要了,直接置空,幫助gc,並將size減1;
- 從傳入的staleSlot下標后面的元素開始,依次遍歷過去,循環執行下面的操作,直到遇到Entry為空停止;
- 如果Entry為staleSlot(即key為null),則清空;
- 否則檢查該Entry是否在它應該在的位置(根據hashcode計算出來的下標與其實際下標是否相等);
- 如果不在則將當前slot置為空,繼續往后尋找,直到一個Entry為空的slot,將其放進去,重復下一次循環;
expungeStaleEntry的作用是清除傳入的staleSlot處的Entry,除此之外還會管兩件"閑事":
- 從其后面開始清除遇到的staleSlot;
- rehash計算下標與實際下標不相符的Entry,
- 直到遇到Entry為空的slot則停止。
從上面的分析我們得出,通過key獲取元素時,如果從計算出來的下標能獲取到符合要求的值則直接返回,否則會從該位置開始依次往后找;遇到Entry不為空但是Entry的key為空的會擦除該Entry並繼續循環;遇到Entry不為空且key不為空(hash碰撞)則直接往后找;在整個找的過程中遇到Entry為null則停止查找,直接返回null。
3. 設置元素
接下來我們看看設置元素,也就是set方法:
private void set(ThreadLocal<?> key, Object value) { Entry[] tab = table; int len = tab.length; int i = key.threadLocalHashCode & (len-1); for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) { ThreadLocal<?> k = e.get(); // 找到則直接替換,然后直接返回 if (k == key) { e.value = value; return; } // 發現staleSlot,則執行replaceStaleEntry,然后直接返回 if (k == null) { replaceStaleEntry(key, value, i); return; } } // 如果沒有找到,則new一個Entry插入數組中 tab[i] = new Entry(key, value); int sz = ++size;
// 插入新的Etry之后需要試探的去擦除一些過期的slot(key=null的Entry),如果Entry數量大於閾值,則執行擴容 if (!cleanSomeSlots(i, sz) && sz >= threshold) rehash(); }
這也是一個私有方法,這里看起來代碼不多,但是里面涉及到的東西很多,邏輯也要比get方法復雜,但是沒關系,我們層層遞進,一一分解。
- 獲取Entry數組、數組長度以及通過要插入的key的hashcode計算出其在數組中的下標;
- 拿到下標之后,對應下標處如果有Entry存在,則有三種情況:
- key不為空,且等於要插入的key,則直接將value替換成要執行的value,返回;
- key為空,則執行replaceStaleEntry中的邏輯,返回;
- 如果key不為空但是又不等於要插入的key,則取下標i處后一個元素,循環執行上面的操作;
- 如果如上的循環結束,到這里代表沒有找到要插入的key,且當前i處的Entry為空,則直接new一個Entry,將待插入的key和value放入其中,再放入數組;
- 將代表數組中Entry數量的size加1;
- 執行cleanSomeSlots中的邏輯,如果有刪除一些Slot,並且size大於閾值,則需要執行rehash中的邏輯進行擴容,否則set執行結束;
上面的步驟看完之后,我們來看看其中當key為空時需要執行的replaceStaleEntry的邏輯:
private void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) { Entry[] tab = table; int len = tab.length; Entry e; // 現在staleSlot處對應的Entry其key=null,往前查找看是否能不能找到一個stale的Entry int slotToExpunge = staleSlot; for (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len)) if (e.get() == null) slotToExpunge = i; // Find either the key or trailing null slot of run, whichever // occurs first for (int i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) { ThreadLocal<?> k = e.get(); // 找到了直接替換,替換之后再嘗試刪除一些stale的Entry if (k == key) { e.value = value; tab[i] = tab[staleSlot]; tab[staleSlot] = e; // Start expunge at preceding stale entry if it exists if (slotToExpunge == staleSlot) slotToExpunge = i; cleanSomeSlots(expungeStaleEntry(slotToExpunge), len); return; } // 如果i處對應的Entry是stale,並且前面往前沒有找到stale的Entry,則將i標識為待擦除的slot if (k == null && slotToExpunge == staleSlot) slotToExpunge = i; } // 如果沒有找到傳入key對應的entry,則new一個新Entry放在傳入staleSlot下標處,現在staleSlot處的Entry不再是stale(過期的)了 tab[staleSlot].value = null; tab[staleSlot] = new Entry(key, value); // 如果還發現有其他stale entries存在, 將其清除 if (slotToExpunge != staleSlot) cleanSomeSlots(expungeStaleEntry(slotToExpunge), len); }
這個replaceStaleEntry的邏輯比較難理解,只要清楚它主要干了下面兩件事:
- 嘗試查找和傳入key對應的Entry,找到則替換,沒找到則在傳入的staleSlot處插入一個新的Entry;
- 在上面的過程中,盡力地去擦除一些找到的staleSlot;
以及插入一個新的Entry之后,試探性地去刪除多余的staleSlot(注意,是試探性的哦),邏輯在cleanSomeSlots中:
/**
* @param i 掃描起始下標,從第i+1處開始掃描
*
* @param n 掃描次數控制量,在往后面掃描的過程中,如果沒有發現staleSlot,則最多掃描log2(n)個元素,否則在staleSlot之后再掃log2(table.length-1)個
**/
private boolean cleanSomeSlots(int i, int n) {
// 標識是否有刪除過staleSlot
boolean removed = false; Entry[] tab = table; int len = tab.length; do { i = nextIndex(i, len); Entry e = tab[i]; if (e != null && e.get() == null) { n = len; removed = true; i = expungeStaleEntry(i); } } while ( (n >>>= 1) != 0); return removed; }
從i+1處開始,往后掃描,如果遇到staleSlot,則執行expungeStaleEntry,往后掃描log2(n)次結束循環,n為傳入的參數,如果發現staleSlot,則將n更新為Entry數組長度len。
這個設計非常巧妙,試探性的掃描一些單元看是否能發現staleSlot(不新鮮的entrys,也就是key=null)。當一個新元素添加進來或者一個staleSlot被清除的時候,會調用這個方法。該方法掃描元素的數量是對數級的,如果不掃描就不能及時清除key為null的entry(會浪費內存),如果全數組掃描則會導致一次插入的時間復雜度為O(n),采用這種試探性的掃描方式其實是一種在功能和性能之間的平衡,盡最大努力清理垃圾,又不導致過於消耗性能。
如果插入了新Entry,且執行了cleanSomeSlots之后size的數量還是大於閾值的話,這時就需要rehash擴容了:
private void rehash() { expungeStaleEntries(); // Use lower threshold for doubling to avoid hysteresis if (size >= threshold - threshold / 4) resize(); }
// 掃描全表,清除所有staleSlot private void expungeStaleEntries() { Entry[] tab = table; int len = tab.length; for (int j = 0; j < len; j++) { Entry e = tab[j]; if (e != null && e.get() == null) expungeStaleEntry(j); } }
// 將表容量擴大一倍 private void resize() { Entry[] oldTab = table; int oldLen = oldTab.length; int newLen = oldLen * 2; Entry[] newTab = new Entry[newLen]; int count = 0; for (int j = 0; j < oldLen; ++j) { Entry e = oldTab[j]; if (e != null) { ThreadLocal<?> k = e.get(); if (k == null) { e.value = null; // Help the GC } else { int h = k.threadLocalHashCode & (newLen - 1); while (newTab[h] != null) h = nextIndex(h, newLen); newTab[h] = e; count++; } } } setThreshold(newLen); size = count; table = newTab; }
首先掃描全表,清除所有staleSlot,如果這還不能減小size,則將table容量擴大一倍。擴容的邏輯比較簡單,根據新數組容量來計算新的數組下標,如果存在hash沖突就往后找,直到Entry為空則把元素放進去。
到這里我們學習了ThreadLocal的基本原理、核心數據結構、最常用的get和set方法,是不是對ThreadLocal有了更深入的了解呢?如果有,那非常高興我的文章能給你帶來一丁點價值^_^
4. 內存泄漏
前面有講到,ThreadLocalMap中的Entry其類型是屬於弱引用(繼承了WeakReference),被弱引用指向的對象,在下一次GC時是會被回收的,除非這個對象還有強引用指向它(對Java中強、軟、弱、虛引用不清楚的同學可以詳細了解下),之所以這樣設計,我的理解是Entry是存在ThreadLocalMap中,而這個map又是保存在線程thread中的,用戶是不能直接獲取到的,也是不能直接操作的,也就會影響到垃圾回收。為了避免因為ThreadLocalMap存儲了ThreadLocal對象而影響到ThreadLocal對象的垃圾回收,JDK的設計者把主動權完全交給調用方,一旦調用方不想使用,只需設置ThreadLocal對象為null,內存就可以被回收掉了,這也是弱引用的一個主要使用場景。
另一方面,在set和getEntry的過程中會頻繁的去清理stale entry,以及時釋放空余位置,這樣就可以及時清除value,因為value是我們要保存到ThreadLocal中的值,而這是強引用,即便是key被回收了,value依然不會被回收。
雖然ThreadLocal中做了種種設計來防止內存泄漏,但是如果使用不當還是會導致內存泄漏,我這里借用一個網上的例子,一起來感受下:
public class ThreadLocalLeakDemo { public static void main(String[] args) { new Thread(new Runnable() { @Override public void run() { for(int i = 0; i< 1000 ;i++) { TestClass t = new TestClass(i); t.printId();...
// 行1,注釋掉這一行時不會導致內存溢出 t = null;
// 行2,注釋掉這一行時會導致內存溢出 t.threadLocal.remove(); } } }).start();; } static class TestClass{ private int id; private int[] arr;
// 注意,這是一個普通成員哦 private ThreadLocal<TestClass> threadLocal; TestClass(int id){ this.id = id; arr = new int[1000000]; threadLocal = new ThreadLocal(); threadLocal.set(this); } public void printId() { System.out.println(threadLocal.get().id); } } }
/**
* 注釋行2,放開行1時,會導致內存溢出,結果如下:
**/
449
450
451
Exception in thread "Thread-0" java.lang.OutOfMemoryError: Java heap space
at testDemos.annotationDemos.ThreadLocalLeakDemo$TestClass.<init>(ThreadLocalLeakDemo.java:28)
at testDemos.annotationDemos.ThreadLocalLeakDemo$1.run(ThreadLocalLeakDemo.java:13)
at java.lang.Thread.run(Unknown Source)
...
/**
* 注釋行1,放開行2時,不會導致內存泄漏,結果如下:
**/
...
997
998
999
上面其實就是改了一行代碼,就導致內存溢出,增加的那一步操作就是調用了ThreadLocal的remove,那我們就來看看remove的邏輯:
移除元素的邏輯很簡單,根據傳入的key定位到數組下標i,從這個下標開始往后循環,直到遇到Entry為空時停止循環。如果找到key對應的entry,則調用Entry的clear方法。
private void remove(ThreadLocal<?> key) { Entry[] tab = table; int len = tab.length; int i = key.threadLocalHashCode & (len-1); for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) { if (e.get() == key) { e.clear(); expungeStaleEntry(i); return; } } }
結合上面的例子和源碼,我們解釋一下為什么沒有調用remove方法會導致內存溢出。如上,在不調用remove時,每一次循環都會插入一個新的Entry對象到ThreadLocalMap中,這個Entry是指向一個新的ThreadLocal對象,對於這個ThreadLocal對象存在兩個引用:
- Entry-->ThreadLocal,這是弱引用;
- Entry-->value(TestClass)-->ThreadLocal,這是強引用;
由於強引用一直存在,而t=null並不能讓value不可達,因為value是保存在線程本地內存中的,所以沒法回收這個新的ThreadLocal對象,導致一直堆積,最終報OOM
而如果調用remove的話,則會直接將對應Entry以及其保存的value清空,這樣就不會內存泄漏了。
其實上面的例子是使用不當導致的,如果將ThreadLocal成員變量置為static,也不會出現這個問題,因為即便有1000次循環,但是都是用的同一個ThreadLocal,在線程本地始終只有一份,用private static來修飾ThreadLocal也是一個官方推薦的慣用法。
5. 總結
- ThreadLocal內部數據結構:Entry數組
- Entry封裝要保存的數據,以key-value的形式,key的類型為指向ThreadLocal的WeakReference,value為要保存的對象
- 通過key的hashcode來初步定位其在數組中的位置,如果沒有則往后依次查找,如果找到則返回(getEntry)或替換(set),直到碰到為空的Entry為止,這就是解決hash碰撞所采用的方法;
- 當出現hash沖突時,ThreadLocalMap采用的辦法就是繼續往后面找,這是線性操作所以會比較低效。但是ThreadLocal采用的散列算法效果很好,沖突的概率非常小,再加上在set和getEntry的過程中會頻繁的去清理stale entry(expungeStaleEntry、replaceStaleEntry、cleanSomeSlots中都有涉及到),是為了能夠及時釋放空余位置,進一步降低這種低效帶來的影響。
- 由於Entry是指向ThreadLocal對象的弱引用,所以當ThreadLocal對象不存在強引用的時候,是可以被回收的,回收之后Entry就指向空了(get獲取的key為null),但是這時候Entry中的value仍然不為空,可以可能導致內存泄漏,有兩種方式可以清除:
- 在ThreadLocal的get、set方法中會頻繁的去清除staleSlot
- 手動調用TreadLocal的remove方法來清除
以上為個人總結,如有不對,煩請指正。