原創(chuàng)文章&經(jīng)驗總結(jié)&從校招到A廠一路陽光一路滄桑
詳情請戳www.codercc.com
1. 造成內(nèi)存泄漏的原因?
threadLocal是為了解決對象不能被多線程共享訪問的問題,通過threadLocal.set方法將對象實例保存在每個線程自己所擁有的threadLocalMap中,這樣每個線程使用自己的對象實例,彼此不會影響達(dá)到隔離的作用,從而就解決了對象在被共享訪問帶來線程安全問題。如果將同步機制和threadLocal做一個橫向比較的話,同步機制就是通過控制線程訪問共享對象的順序,而threadLocal就是為每一個線程分配一個該對象,各用各的互不影響。打個比方說,現(xiàn)在有100個同學(xué)需要填寫一張表格但是只有一支筆,同步就相當(dāng)于A使用完這支筆后給B,B使用后給C用......老師就控制著這支筆的使用順序,使得同學(xué)之間不會產(chǎn)生沖突。而threadLocal就相當(dāng)于,老師直接準(zhǔn)備了100支筆,這樣每個同學(xué)都使用自己的,同學(xué)之間就不會產(chǎn)生沖突。很顯然這就是兩種不同的思路,同步機制以“時間換空間”,由于每個線程在同一時刻共享對象只能被一個線程訪問造成整體上響應(yīng)時間增加,但是對象只占有一份內(nèi)存,犧牲了時間效率換來了空間效率即“時間換空間”。而threadLocal,為每個線程都分配了一份對象,自然而然內(nèi)存使用率增加,每個線程各用各的,整體上時間效率要增加很多,犧牲了空間效率換來時間效率即“空間換時間”。
關(guān)于threadLocal,threadLocalMap更多的細(xì)節(jié)可以看這篇文章,給出了很詳細(xì)的各個方面的知識(很多也是面試高頻考點)。threadLocal,threadLocalMap,entry之間的關(guān)系如下圖所示:
上圖中,實線代表強引用,虛線代表的是弱引用,如果threadLocal外部強引用被置為null(threadLocalInstance=null)的話,threadLocal實例就沒有一條引用鏈路可達(dá),很顯然在gc(垃圾回收)的時候勢必會被回收,因此entry就存在key為null的情況,無法通過一個Key為null去訪問到該entry的value。同時,就存在了這樣一條引用鏈:threadRef->currentThread->threadLocalMap->entry->valueRef->valueMemory,導(dǎo)致在垃圾回收的時候進行可達(dá)性分析的時候,value可達(dá)從而不會被回收掉,但是該value永遠(yuǎn)不能被訪問到,這樣就存在了內(nèi)存泄漏。當(dāng)然,如果線程執(zhí)行結(jié)束后,threadLocal,threadRef會斷掉,因此threadLocal,threadLocalMap,entry都會被回收掉。可是,在實際使用中我們都是會用線程池去維護我們的線程,比如在Executors.newFixedThreadPool()時創(chuàng)建線程的時候,為了復(fù)用線程是不會結(jié)束的,所以threadLocal內(nèi)存泄漏就值得我們關(guān)注。
2. 已經(jīng)做出了哪些改進?
實際上,為了解決threadLocal潛在的內(nèi)存泄漏的問題,Josh Bloch and Doug Lea大師已經(jīng)做了一些改進。在threadLocal的set和get方法中都有相應(yīng)的處理。下文為了敘述,針對key為null的entry,源碼注釋為stale entry,直譯為不新鮮的entry,這里我就稱之為“臟entry”。比如在ThreadLocalMap的set方法中:
private void set(ThreadLocal<?> key, Object value) {
// We don't use a fast path as with get() because it is at
// least as common to use set() to create new entries as
// it is to replace existing ones, in which case, a fast
// path would fail more often than not.
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;
}
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
在該方法中針對臟entry做了這樣的處理:
- 如果當(dāng)前table[i]!=null的話說明hash沖突就需要向后環(huán)形查找,若在查找過程中遇到臟entry就通過replaceStaleEntry進行處理;
- 如果當(dāng)前table[i]==null的話說明新的entry可以直接插入,但是插入后會調(diào)用cleanSomeSlots方法檢測并清除臟entry
2.1 cleanSomeSlots
該方法的源碼為:
/* @param i a position known NOT to hold a stale entry. The
* scan starts at the element after i.
*
* @param n scan control: {@code log2(n)} cells are scanned,
* unless a stale entry is found, in which case
* {@code log2(table.length)-1} additional cells are scanned.
* When called from insertions, this parameter is the number
* of elements, but when from replaceStaleEntry, it is the
* table length. (Note: all this could be changed to be either
* more or less aggressive by weighting n instead of just
* using straight log n. But this version is simple, fast, and
* seems to work well.)
*
* @return true if any stale entries have been removed.
*/
private boolean cleanSomeSlots(int i, int n) {
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;
}
入?yún)ⅲ?/strong>
i表示:插入entry的位置i,很顯然在上述情況2(table[i]==null)中,entry剛插入后該位置i很顯然不是臟entry;
-
參數(shù)n
2.1. n的用途
主要用于掃描控制(scan control),從while中是通過n來進行條件判斷的說明n就是用來控制掃描趟數(shù)(循環(huán)次數(shù))的。在掃描過程中,如果沒有遇到臟entry就整個掃描過程持續(xù)log2(n)次,log2(n)的得來是因為
n >>>= 1
,每次n右移一位相當(dāng)于n除以2。如果在掃描過程中遇到臟entry的話就會令n為當(dāng)前hash表的長度(n=len
),再掃描log2(n)趟,注意此時n增加無非就是多增加了循環(huán)次數(shù)從而通過nextIndex往后搜索的范圍擴大,示意圖如下
按照n的初始值,搜索范圍為黑線,當(dāng)遇到了臟entry,此時n變成了哈希數(shù)組的長度(n取值增大),搜索范圍log2(n)增大,紅線表示。如果在整個搜索過程沒遇到臟entry的話,搜索結(jié)束,采用這種方式的主要是用于時間效率上的平衡。
2.2. n的取值
如果是在set方法插入新的entry后調(diào)用(上述情況2),n位當(dāng)前已經(jīng)插入的entry個數(shù)size;如果是在replaceSateleEntry方法中調(diào)用n為哈希表的長度len。
2.2 expungeStaleEntry
如果對輸入?yún)?shù)能夠理解的話,那么cleanSomeSlots方法搜索基本上清除了,但是全部搞定還需要掌握expungeStaleEntry方法,當(dāng)在搜索過程中遇到了臟entry的話就會調(diào)用該方法去清理掉臟entry。源碼為:
/**
* Expunge a stale entry by rehashing any possibly colliding entries
* lying between staleSlot and the next null slot. This also expunges
* any other stale entries encountered before the trailing null. See
* Knuth, Section 6.4
*
* @param staleSlot index of slot known to have null key
* @return the index of the next null slot after staleSlot
* (all between staleSlot and this slot will have been checked
* for expunging).
*/
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
//清除當(dāng)前臟entry
// expunge entry at staleSlot
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;
// Rehash until we encounter null
Entry e;
int i;
//2.往后環(huán)形繼續(xù)查找,直到遇到table[i]==null時結(jié)束
for (i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
//3. 如果在向后搜索過程中再次遇到臟entry,同樣將其清理掉
if (k == null) {
e.value = null;
tab[i] = null;
size--;
} else {
//處理rehash的情況
int h = k.threadLocalHashCode & (len - 1);
if (h != i) {
tab[i] = null;
// Unlike Knuth 6.4 Algorithm R, we must scan until
// null because multiple entries could have been stale.
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
}
}
return i;
}
該方法邏輯請看注釋(第1,2,3步),主要做了這么幾件事情:
- 清理當(dāng)前臟entry,即將其value引用置為null,并且將table[staleSlot]也置為null。value置為null后該value域變?yōu)椴豢蛇_(dá),在下一次gc的時候就會被回收掉,同時table[staleSlot]為null后以便于存放新的entry;
- 從當(dāng)前staleSlot位置向后環(huán)形(nextIndex)繼續(xù)搜索,直到遇到哈希桶(tab[i])為null的時候退出;
- 若在搜索過程再次遇到臟entry,繼續(xù)將其清除。
也就是說該方法,清理掉當(dāng)前臟entry后,并沒有閑下來繼續(xù)向后搜索,若再次遇到臟entry繼續(xù)將其清理,直到哈希桶(table[i])為null時退出。因此方法執(zhí)行完的結(jié)果為 從當(dāng)前臟entry(staleSlot)位到返回的i位,這中間所有的entry不是臟entry。為什么是遇到null退出呢?原因是存在臟entry的前提條件是 當(dāng)前哈希桶(table[i])不為null,只是該entry的key域為null。如果遇到哈希桶為null,很顯然它連成為臟entry的前提條件都不具備。
現(xiàn)在對cleanSomeSlot方法做一下總結(jié),其方法執(zhí)行示意圖如下:
如圖所示,cleanSomeSlot方法主要有這樣幾點:
- 從當(dāng)前位置i處(位于i處的entry一定不是臟entry)為起點在初始小范圍(log2(n),n為哈希表已插入entry的個數(shù)size)開始向后搜索臟entry,若在整個搜索過程沒有臟entry,方法結(jié)束退出
- 如果在搜索過程中遇到臟entryt通過expungeStaleEntry方法清理掉當(dāng)前臟entry,并且該方法會返回下一個哈希桶(table[i])為null的索引位置為i。這時重新令搜索起點為索引位置i,n為哈希表的長度len,再次擴大搜索范圍為log2(n')繼續(xù)搜索。
下面,以一個例子更清晰的來說一下,假設(shè)當(dāng)前table數(shù)組的情況如下圖。
如圖當(dāng)前n等于hash表的size即n=10,i=1,在第一趟搜索過程中通過nextIndex,i指向了索引為2的位置,此時table[2]為null,說明第一趟未發(fā)現(xiàn)臟entry,則第一趟結(jié)束進行第二趟的搜索。
第二趟所搜先通過nextIndex方法,索引由2的位置變成了i=3,當(dāng)前table[3]!=null但是該entry的key為null,說明找到了一個臟entry,先將n置為哈希表的長度len,然后繼續(xù)調(diào)用expungeStaleEntry方法,該方法會將當(dāng)前索引為3的臟entry給清除掉(令value為null,并且table[3]也為null),但是該方法可不想偷懶,它會繼續(xù)往后環(huán)形搜索,往后會發(fā)現(xiàn)索引為4,5的位置的entry同樣為臟entry,索引為6的位置的entry不是臟entry保持不變,直至i=7的時候此處table[7]位null,該方法就以i=7返回。至此,第二趟搜索結(jié)束;
由于在第二趟搜索中發(fā)現(xiàn)臟entry,n增大為數(shù)組的長度len,因此擴大搜索范圍(增大循環(huán)次數(shù))繼續(xù)向后環(huán)形搜索;
直到在整個搜索范圍里都未發(fā)現(xiàn)臟entry,cleanSomeSlot方法執(zhí)行結(jié)束退出。
2.3 replaceStaleEntry
先來看replaceStaleEntry 方法,該方法源碼為:
/*
* @param key the key
* @param value the value to be associated with key
* @param staleSlot index of the first stale entry encountered while
* searching for key.
*/
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
int staleSlot) {
Entry[] tab = table;
int len = tab.length;
Entry e;
// Back up to check for prior stale entry in current run.
// We clean out whole runs at a time to avoid continual
// incremental rehashing due to garbage collector freeing
// up refs in bunches (i.e., whenever the collector runs).
//向前找到第一個臟entry
int slotToExpunge = staleSlot;
for (int i = prevIndex(staleSlot, len);
(e = tab[i]) != null;
i = prevIndex(i, len))
if (e.get() == null)
1. 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();
// If we find key, then we need to swap it
// with the stale entry to maintain hash table order.
// The newly stale slot, or any other stale slot
// encountered above it, can then be sent to expungeStaleEntry
// to remove or rehash all of the other entries in run.
if (k == key) {
//如果在向后環(huán)形查找過程中發(fā)現(xiàn)key相同的entry就覆蓋并且和臟entry進行交換
2. e.value = value;
3. tab[i] = tab[staleSlot];
4. tab[staleSlot] = e;
// Start expunge at preceding stale entry if it exists
//如果在查找過程中還未發(fā)現(xiàn)臟entry,那么就以當(dāng)前位置作為cleanSomeSlots
//的起點
if (slotToExpunge == staleSlot)
5. slotToExpunge = i;
//搜索臟entry并進行清理
6. cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
}
// If we didn't find stale entry on backward scan, the
// first stale entry seen while scanning for key is the
// first still present in the run.
//如果向前未搜索到臟entry,則在查找過程遇到臟entry的話,后面就以此時這個位置
//作為起點執(zhí)行cleanSomeSlots
if (k == null && slotToExpunge == staleSlot)
7. slotToExpunge = i;
}
// If key not found, put new entry in stale slot
//如果在查找過程中沒有找到可以覆蓋的entry,則將新的entry插入在臟entry
8. tab[staleSlot].value = null;
9. tab[staleSlot] = new Entry(key, value);
// If there are any other stale entries in run, expunge them
10. if (slotToExpunge != staleSlot)
//執(zhí)行cleanSomeSlots
11. cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}
該方法的邏輯請看注釋,下面我結(jié)合各種情況詳細(xì)說一下該方法的執(zhí)行過程。首先先看這一部分的代碼:
int slotToExpunge = staleSlot;
for (int i = prevIndex(staleSlot, len);
(e = tab[i]) != null;
i = prevIndex(i, len))
if (e.get() == null)
slotToExpunge = i;
這部分代碼通過PreIndex方法實現(xiàn)往前環(huán)形搜索臟entry的功能,初始時slotToExpunge和staleSlot相同,若在搜索過程中發(fā)現(xiàn)了臟entry,則更新slotToExpunge為當(dāng)前索引i。另外,說明replaceStaleEntry并不僅僅局限于處理當(dāng)前已知的臟entry,它認(rèn)為在出現(xiàn)臟entry的相鄰位置也有很大概率出現(xiàn)臟entry,所以為了一次處理到位,就需要向前環(huán)形搜索,找到前面的臟entry。那么根據(jù)在向前搜索中是否還有臟entry以及在for循環(huán)后向環(huán)形查找中是否找到可覆蓋的entry,我們分這四種情況來充分理解這個方法:
-
1.前向有臟entry
-
1.1后向環(huán)形查找找到可覆蓋的entry
該情形如下圖所示。
向前環(huán)形搜索到臟entry,向后環(huán)形查找到可覆蓋的entry的情況.png如圖,slotToExpunge初始狀態(tài)和staleSlot相同,當(dāng)前向環(huán)形搜索遇到臟entry時,在第1行代碼中slotToExpunge會更新為當(dāng)前臟entry的索引i,直到遇到哈希桶(table[i])為null的時候,前向搜索過程結(jié)束。在接下來的for循環(huán)中進行后向環(huán)形查找,若查找到了可覆蓋的entry,第2,3,4行代碼先覆蓋當(dāng)前位置的entry,然后再與staleSlot位置上的臟entry進行交換。交換之后臟entry就更換到了i處,最后使用cleanSomeSlots方法從slotToExpunge為起點開始進行清理臟entry的過程
-
1.2后向環(huán)形查找未找到可覆蓋的entry
該情形如下圖所示。
前向環(huán)形搜索到臟entry,向后環(huán)形未搜索可覆蓋entry.png
如圖,slotToExpunge初始狀態(tài)和staleSlot相同,當(dāng)前向環(huán)形搜索遇到臟entry時,在第1行代碼中slotToExpunge會更新為當(dāng)前臟entry的索引i,直到遇到哈希桶(table[i])為null的時候,前向搜索過程結(jié)束。在接下來的for循環(huán)中進行后向環(huán)形查找,若沒有查找到了可覆蓋的entry,哈希桶(table[i])為null的時候,后向環(huán)形查找過程結(jié)束。那么接下來在8,9行代碼中,將插入的新entry直接放在staleSlot處即可,最后使用cleanSomeSlots方法從slotToExpunge為起點開始進行清理臟entry的過程
-
-
2.前向沒有臟entry
-
2.1后向環(huán)形查找找到可覆蓋的entry
該情形如下圖所示。
前向未搜索到臟entry,后向環(huán)形搜索到可覆蓋的entry.png
如圖,slotToExpunge初始狀態(tài)和staleSlot相同,當(dāng)前向環(huán)形搜索直到遇到哈希桶(table[i])為null的時候,前向搜索過程結(jié)束,若在整個過程未遇到臟entry,slotToExpunge初始狀態(tài)依舊和staleSlot相同。在接下來的for循環(huán)中進行后向環(huán)形查找,若遇到了臟entry,在第7行代碼中更新slotToExpunge為位置i。若查找到了可覆蓋的entry,第2,3,4行代碼先覆蓋當(dāng)前位置的entry,然后再與staleSlot位置上的臟entry進行交換,交換之后臟entry就更換到了i處。如果在整個查找過程中都還沒有遇到臟entry的話,會通過第5行代碼,將slotToExpunge更新當(dāng)前i處,最后使用cleanSomeSlots方法從slotToExpunge為起點開始進行清理臟entry的過程。
-
2.2后向環(huán)形查找未找到可覆蓋的entry
該情形如下圖所示。
前向環(huán)形未搜索到臟entry,后向環(huán)形查找未查找到可覆蓋的entry.png
如圖,slotToExpunge初始狀態(tài)和staleSlot相同,當(dāng)前向環(huán)形搜索直到遇到哈希桶(table[i])為null的時候,前向搜索過程結(jié)束,若在整個過程未遇到臟entry,slotToExpunge初始狀態(tài)依舊和staleSlot相同。在接下來的for循環(huán)中進行后向環(huán)形查找,若遇到了臟entry,在第7行代碼中更新slotToExpunge為位置i。若沒有查找到了可覆蓋的entry,哈希桶(table[i])為null的時候,后向環(huán)形查找過程結(jié)束。那么接下來在8,9行代碼中,將插入的新entry直接放在staleSlot處即可。另外,如果發(fā)現(xiàn)slotToExpunge被重置,則第10行代碼if判斷為true,就使用cleanSomeSlots方法從slotToExpunge為起點開始進行清理臟entry的過程。
-
下面用一個實例來有個直觀的感受,示例代碼就不給出了,代碼debug時table狀態(tài)如下圖所示:
如圖所示,當(dāng)前的staleSolt為i=4,首先先進行前向搜索臟entry,當(dāng)i=3的時候遇到臟entry,slotToExpung更新為3,當(dāng)i=2的時候tabel[2]為null,因此前向搜索臟entry的過程結(jié)束。然后進行后向環(huán)形查找,知道i=7的時候遇到table[7]為null,結(jié)束后向查找過程,并且在該過程并沒有找到可以覆蓋的entry。最后只能在staleSlot(4)處插入新entry,然后從slotToExpunge(3)為起點進行cleanSomeSlots進行臟entry的清理。是不是上面的1.2的情況。
這些核心方法,通過源碼又給出示例圖,應(yīng)該最終都能掌握了,也還挺有意思的。若覺得不錯,對我的辛勞付出能給出鼓勵歡迎點贊,給小弟鼓勵,在此謝過 :)。
當(dāng)我們調(diào)用threadLocal的get方法時,當(dāng)table[i]不是和所要找的key相同的話,會繼續(xù)通過threadLocalMap的
getEntryAfterMiss方法向后環(huán)形去找,該方法為:
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;
}
當(dāng)key==null的時候,即遇到臟entry也會調(diào)用expungeStleEntry對臟entry進行清理。
當(dāng)我們調(diào)用threadLocal.remove方法時候,實際上會調(diào)用threadLocalMap的remove方法,該方法的源碼為:
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;
}
}
}
同樣的可以看出,當(dāng)遇到了key為null的臟entry的時候,也會調(diào)用expungeStaleEntry清理掉臟entry。
從以上set,getEntry,remove方法看出,在threadLocal的生命周期里,針對threadLocal存在的內(nèi)存泄漏的問題,都會通過expungeStaleEntry,cleanSomeSlots,replaceStaleEntry這三個方法清理掉key為null的臟entry。
2.4 為什么使用弱引用?
從文章開頭通過threadLocal,threadLocalMap,entry的引用關(guān)系看起來threadLocal存在內(nèi)存泄漏的問題似乎是因為threadLocal是被弱引用修飾的。那為什么要使用弱引用呢?
如果使用強引用
假設(shè)threadLocal使用的是強引用,在業(yè)務(wù)代碼中執(zhí)行threadLocalInstance==null
操作,以清理掉threadLocal實例的目的,但是因為threadLocalMap的Entry強引用threadLocal,因此在gc的時候進行可達(dá)性分析,threadLocal依然可達(dá),對threadLocal并不會進行垃圾回收,這樣就無法真正達(dá)到業(yè)務(wù)邏輯的目的,出現(xiàn)邏輯錯誤
如果使用弱引用
假設(shè)Entry弱引用threadLocal,盡管會出現(xiàn)內(nèi)存泄漏的問題,但是在threadLocal的生命周期里(set,getEntry,remove)里,都會針對key為null的臟entry進行處理。
從以上的分析可以看出,使用弱引用的話在threadLocal生命周期里會盡可能的保證不出現(xiàn)內(nèi)存泄漏的問題,達(dá)到安全的狀態(tài)。
2.5 Thread.exit()
當(dāng)線程退出時會執(zhí)行exit方法:
private void exit() {
if (group != null) {
group.threadTerminated(this);
group = null;
}
/* Aggressively null out all reference fields: see bug 4006245 */
target = null;
/* Speed the release of some of these resources */
threadLocals = null;
inheritableThreadLocals = null;
inheritedAccessControlContext = null;
blocker = null;
uncaughtExceptionHandler = null;
}
從源碼可以看出當(dāng)線程結(jié)束時,會令threadLocals=null,也就意味著GC的時候就可以將threadLocalMap進行垃圾回收,換句話說threadLocalMap生命周期實際上thread的生命周期相同。
3. threadLocal最佳實踐
通過這篇文章對threadLocal的內(nèi)存泄漏做了很詳細(xì)的分析,我們可以完全理解threadLocal內(nèi)存泄漏的前因后果,那么實踐中我們應(yīng)該怎么做?
- 每次使用完ThreadLocal,都調(diào)用它的remove()方法,清除數(shù)據(jù)。
- 在使用線程池的情況下,沒有及時清理ThreadLocal,不僅是內(nèi)存泄漏的問題,更嚴(yán)重的是可能導(dǎo)致業(yè)務(wù)邏輯出現(xiàn)問題。所以,使用ThreadLocal就跟加鎖完要解鎖一樣,用完就清理。
參考資料
《java高并發(fā)程序設(shè)計》
http://blog.xiaohansong.com/2016/08/06/ThreadLocal-memory-leak/