一篇文章,從源碼深刻詳解ThreadLocal內存泄漏問題

  1. 形成內存泄漏的緣由? threadLocal是爲了解決對象不能被多線程共享訪問的問題,經過threadLocal.set方法將對象實例保存在每一個線程本身所擁有的threadLocalMap中,這樣每一個線程使用本身的對象實例,彼此不會影響達到隔離的做用,從而就解決了對象在被共享訪問帶來線程安全問題。若是將同步機制和threadLocal作一個橫向比較的話,同步機制就是經過控制線程訪問共享對象的順序,而threadLocal就是爲每個線程分配一個該對象,各用各的互不影響。打個比方說,如今有100個同窗須要填寫一張表格可是隻有一支筆,同步就至關於A使用完這支筆後給B,B使用後給C用......老師就控制着這支筆的使用順序,使得同窗之間不會產生衝突。而threadLocal就至關於,老師直接準備了100支筆,這樣每一個同窗都使用本身的,同窗之間就不會產生衝突。很顯然這就是兩種不一樣的思路,同步機制以「時間換空間」,因爲每一個線程在同一時刻共享對象只能被一個線程訪問形成總體上響應時間增長,可是對象只佔有一分內存,犧牲了時間效率換來了空間效率即「時間換空間」。而threadLocal,爲每一個線程都分配了一份對象,天然而然內存使用率增長,每一個線程各用各的,總體上時間效率要增長不少,犧牲了空間效率換來時間效率即「空間換時間」。

關於threadLocal,threadLocalMap更多的細節能夠看這篇文章,給出了很詳細的各個方面的知識(不少也是面試高頻考點)。threadLocal,threadLocalMap,entry之間的關係以下圖所示:java

threadLocal引用示意圖面試

上圖中,實線表明強引用,虛線表明的是弱引用,若是threadLocal外部強引用被置爲null(threadLocalInstance=null)的話,threadLocal實例就沒有一條引用鏈路可達,很顯然在gc(垃圾回收)的時候勢必會被回收,所以entry就存在key爲null的狀況,沒法經過一個Key爲null去訪問到該entry的value。同時,就存在了這樣一條引用鏈:threadRef->currentThread->threadLocalMap->entry->valueRef->valueMemory,致使在垃圾回收的時候進行可達性分析的時候,value可達從而不會被回收掉,可是該value永遠不能被訪問到,這樣就存在了內存泄漏。固然,若是線程執行結束後,threadLocal,threadRef會斷掉,所以threadLocal,threadLocalMap,entry都會被回收掉。但是,在實際使用中咱們都是會用線程池去維護咱們的線程,好比在Executors.newFixedThreadPool()時建立線程的時候,爲了複用線程是不會結束的,因此threadLocal內存泄漏就值得咱們關注。數組

  1. 已經作出了哪些改進? 實際上,爲了解決threadLocal潛在的內存泄漏的問題,Josh Bloch and Doug Lea大師已經作了一些改進。在threadLocal的set和get方法中都有相應的處理。下文爲了敘述,針對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作了這樣的處理:多線程

若是當前table[i]!=null的話說明hash衝突就須要向後環形查找,若在查找過程當中遇到髒entry就經過replaceStaleEntry進行處理; 若是當前table[i]==null的話說明新的entry能夠直接插入,可是插入後會調用cleanSomeSlots方法檢測並清除髒entry 2.1 cleanSomeSlots 該方法的源碼爲:併發

/* @param i a position known NOT to hold a stale entry. Theless

  • 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; } 入參:

i表示:插入entry的位置i,很顯然在上述狀況2(table[i]==null)中,entry剛插入後該位置i很顯然不是髒entry;高併發

參數nthis

2.1. n的用途spa

主要用於掃描控制(scan control),從while中是經過n來進行條件判斷的說明n就是用來控制掃描趟數(循環次數)的。在掃描過程當中,若是沒有遇到髒entry就整個掃描過程持續log2(n)次,log2(n)的得來是由於n >>>= 1,每次n右移一位至關於n除以2。若是在掃描過程當中遇到髒entry的話就會令n爲當前hash表的長度(n=len),再掃描log2(n)趟,注意此時n增長無非就是多增長了循環次數從而經過nextIndex日後搜索的範圍擴大,示意圖以下

cleanSomeSlots示意圖.png 按照n的初始值,搜索範圍爲黑線,當遇到了髒entry,此時n變成了哈希數組的長度(n取值增大),搜索範圍log2(n)增大,紅線表示。若是在整個搜索過程沒遇到髒entry的話,搜索結束,採用這種方式的主要是用於時間效率上的平衡。

2.2. n的取值

若是是在set方法插入新的entry後調用(上述狀況2),n位當前已經插入的entry個數size;若是是在replaceSateleEntry方法中調用n爲哈希表的長度len。

2.2 expungeStaleEntry 若是對輸入參數可以理解的話,那麼cleanSomeSlots方法搜索基本上清除了,可是所有搞定還須要掌握expungeStaleEntry方法,當在搜索過程當中遇到了髒entry的話就會調用該方法去清理掉髒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;

    //清除當前髒entry // expunge entry at staleSlot tab[staleSlot].value = null; tab[staleSlot] = null; size--;

    // Rehash until we encounter null Entry e; int i; //2.日後環形繼續查找,直到遇到table[i]==null時結束 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步),主要作了這麼幾件事情:

清理當前髒entry,即將其value引用置爲null,而且將table[staleSlot]也置爲null。value置爲null後該value域變爲不可達,在下一次gc的時候就會被回收掉,同時table[staleSlot]爲null後以便於存放新的entry; 從當前staleSlot位置向後環形(nextIndex)繼續搜索,直到遇到哈希桶(tab[i])爲null的時候退出; 若在搜索過程再次遇到髒entry,繼續將其清除。 也就是說該方法,清理掉當前髒entry後,並無閒下來繼續向後搜索,若再次遇到髒entry繼續將其清理,直到哈希桶(table[i])爲null時退出。所以方法執行完的結果爲 從當前髒entry(staleSlot)位到返回的i位,這中間全部的entry不是髒entry。爲何是遇到null退出呢?緣由是存在髒entry的前提條件是 當前哈希桶(table[i])不爲null,只是該entry的key域爲null。若是遇到哈希桶爲null,很顯然它連成爲髒entry的前提條件都不具有。

如今對cleanSomeSlot方法作一下總結,其方法執行示意圖以下:

cleanSomeSlots示意圖.png 如圖所示,cleanSomeSlot方法主要有這樣幾點:

從當前位置i處(位於i處的entry必定不是髒entry)爲起點在初始小範圍(log2(n),n爲哈希表已插入entry的個數size)開始向後搜索髒entry,若在整個搜索過程沒有髒entry,方法結束退出 若是在搜索過程當中遇到髒entryt經過expungeStaleEntry方法清理掉當前髒entry,而且該方法會返回下一個哈希桶(table[i])爲null的索引位置爲i。這時從新令搜索起點爲索引位置i,n爲哈希表的長度len,再次擴大搜索範圍爲log2(n')繼續搜索。 下面,以一個例子更清晰的來講一下,假設當前table數組的狀況以下圖。

cleanSomeSlots執行情景圖.png 如圖當前n等於hash表的size即n=10,i=1,在第一趟搜索過程當中經過nextIndex,i指向了索引爲2的位置,此時table[2]爲null,說明第一趟未發現髒entry,則第一趟結束進行第二趟的搜索。

第二趟所搜先經過nextIndex方法,索引由2的位置變成了i=3,當前table[3]!=null可是該entry的key爲null,說明找到了一個髒entry,先將n置爲哈希表的長度len,而後繼續調用expungeStaleEntry方法,該方法會將當前索引爲3的髒entry給清除掉(令value爲null,而且table[3]也爲null),可是該方法可不想偷懶,它會繼續日後環形搜索,日後會發現索引爲4,5的位置的entry一樣爲髒entry,索引爲6的位置的entry不是髒entry保持不變,直至i=7的時候此處table[7]位null,該方法就以i=7返回。至此,第二趟搜索結束;

因爲在第二趟搜索中發現髒entry,n增大爲數組的長度len,所以擴大搜索範圍(增大循環次數)繼續向後環形搜索;

直到在整個搜索範圍裏都未發現髒entry,cleanSomeSlot方法執行結束退出。

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) {
         
         //若是在向後環形查找過程當中發現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
         //若是在查找過程當中還未發現髒entry,那麼就以當前位置做爲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的話,後面就以此時這個位置
     //做爲起點執行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
複製代碼
  1. if (slotToExpunge != staleSlot) //執行cleanSomeSlots
  2. cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
    複製代碼

} 該方法的邏輯請看註釋,下面我結合各類狀況詳細說一下該方法的執行過程。首先先看這一部分的代碼:

int slotToExpunge = staleSlot; for (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len)) if (e.get() == null) slotToExpunge = i; 這部分代碼經過PreIndex方法實現往前環形搜索髒entry的功能,初始時slotToExpunge和staleSlot相同,若在搜索過程當中發現了髒entry,則更新slotToExpunge爲當前索引i。另外,說明replaceStaleEntry並不只僅侷限於處理當前已知的髒entry,它認爲在出現髒entry的相鄰位置也有很大機率出現髒entry,因此爲了一次處理到位,就須要向前環形搜索,找到前面的髒entry。那麼根據在向前搜索中是否還有髒entry以及在for循環後向環形查找中是否找到可覆蓋的entry,咱們分這四種狀況來充分理解這個方法:

1.前向有髒entry

1.1後向環形查找找到可覆蓋的entry

該情形以下圖所示。

向前環形搜索到髒entry,向後環形查找到可覆蓋的entry的狀況.png 如圖,slotToExpunge初始狀態和staleSlot相同,當前向環形搜索遇到髒entry時,在第1行代碼中slotToExpunge會更新爲當前髒entry的索引i,直到遇到哈希桶(table[i])爲null的時候,前向搜索過程結束。在接下來的for循環中進行後向環形查找,若查找到了可覆蓋的entry,第2,3,4行代碼先覆蓋當前位置的entry,而後再與staleSlot位置上的髒entry進行交換。交換以後髒entry就更換到了i處,最後使用cleanSomeSlots方法從slotToExpunge爲起點開始進行清理髒entry的過程

1.2後向環形查找未找到可覆蓋的entry 該情形以下圖所示。

前向環形搜索到髒entry,向後環形未搜索可覆蓋entry.png

如圖,slotToExpunge初始狀態和staleSlot相同,當前向環形搜索遇到髒entry時,在第1行代碼中slotToExpunge會更新爲當前髒entry的索引i,直到遇到哈希桶(table[i])爲null的時候,前向搜索過程結束。在接下來的for循環中進行後向環形查找,若沒有查找到了可覆蓋的entry,哈希桶(table[i])爲null的時候,後向環形查找過程結束。那麼接下來在8,9行代碼中,將插入的新entry直接放在staleSlot處便可,最後使用cleanSomeSlots方法從slotToExpunge爲起點開始進行清理髒entry的過程

2.前向沒有髒entry

2.1後向環形查找找到可覆蓋的entry 該情形以下圖所示。

前向未搜索到髒entry,後向環形搜索到可覆蓋的entry.png

如圖,slotToExpunge初始狀態和staleSlot相同,當前向環形搜索直到遇到哈希桶(table[i])爲null的時候,前向搜索過程結束,若在整個過程未遇到髒entry,slotToExpunge初始狀態依舊和staleSlot相同。在接下來的for循環中進行後向環形查找,若遇到了髒entry,在第7行代碼中更新slotToExpunge爲位置i。若查找到了可覆蓋的entry,第2,3,4行代碼先覆蓋當前位置的entry,而後再與staleSlot位置上的髒entry進行交換,交換以後髒entry就更換到了i處。若是在整個查找過程當中都尚未遇到髒entry的話,會經過第5行代碼,將slotToExpunge更新當前i處,最後使用cleanSomeSlots方法從slotToExpunge爲起點開始進行清理髒entry的過程。

2.2後向環形查找未找到可覆蓋的entry 該情形以下圖所示。

前向環形未搜索到髒entry,後向環形查找未查找到可覆蓋的entry.png

如圖,slotToExpunge初始狀態和staleSlot相同,當前向環形搜索直到遇到哈希桶(table[i])爲null的時候,前向搜索過程結束,若在整個過程未遇到髒entry,slotToExpunge初始狀態依舊和staleSlot相同。在接下來的for循環中進行後向環形查找,若遇到了髒entry,在第7行代碼中更新slotToExpunge爲位置i。若沒有查找到了可覆蓋的entry,哈希桶(table[i])爲null的時候,後向環形查找過程結束。那麼接下來在8,9行代碼中,將插入的新entry直接放在staleSlot處便可。另外,若是發現slotToExpunge被重置,則第10行代碼if判斷爲true,就使用cleanSomeSlots方法從slotToExpunge爲起點開始進行清理髒entry的過程。

下面用一個實例來有個直觀的感覺,示例代碼就不給出了,代碼debug時table狀態以下圖所示:

1.2狀況示意圖.png 如圖所示,當前的staleSolt爲i=4,首先先進行前向搜索髒entry,當i=3的時候遇到髒entry,slotToExpung更新爲3,當i=2的時候tabel[2]爲null,所以前向搜索髒entry的過程結束。而後進行後向環形查找,知道i=7的時候遇到table[7]爲null,結束後向查找過程,而且在該過程並無找到能夠覆蓋的entry。最後只能在staleSlot(4)處插入新entry,而後從slotToExpunge(3)爲起點進行cleanSomeSlots進行髒entry的清理。是否是上面的1.2的狀況。

這些核心方法,經過源碼又給出示例圖,應該最終都能掌握了,也還挺有意思的。若以爲不錯,對個人辛勞付出能給出鼓勵歡迎點贊,給小弟鼓勵,在此謝過 :)。

當咱們調用threadLocal的get方法時,當table[i]不是和所要找的key相同的話,會繼續經過threadLocalMap的 getEntryAfterMiss方法向後環形去找,該方法爲:

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;
複製代碼

} 當key==null的時候,即遇到髒entry也會調用expungeStleEntry對髒entry進行清理。

當咱們調用threadLocal.remove方法時候,實際上會調用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; } } } 一樣的能夠看出,當遇到了key爲null的髒entry的時候,也會調用expungeStaleEntry清理掉髒entry。

從以上set,getEntry,remove方法看出,在threadLocal的生命週期裏,針對threadLocal存在的內存泄漏的問題,都會經過expungeStaleEntry,cleanSomeSlots,replaceStaleEntry這三個方法清理掉key爲null的髒entry。

2.4 爲何使用弱引用? 從文章開頭經過threadLocal,threadLocalMap,entry的引用關係看起來threadLocal存在內存泄漏的問題彷佛是由於threadLocal是被弱引用修飾的。那爲何要使用弱引用呢?

若是使用強引用

假設threadLocal使用的是強引用,在業務代碼中執行threadLocalInstance==null操做,以清理掉threadLocal實例的目的,可是由於threadLocalMap的Entry強引用threadLocal,所以在gc的時候進行可達性分析,threadLocal依然可達,對threadLocal並不會進行垃圾回收,這樣就沒法真正達到業務邏輯的目的,出現邏輯錯誤

若是使用弱引用

假設Entry弱引用threadLocal,儘管會出現內存泄漏的問題,可是在threadLocal的生命週期裏(set,getEntry,remove)裏,都會針對key爲null的髒entry進行處理。

從以上的分析能夠看出,使用弱引用的話在threadLocal生命週期裏會盡量的保證不出現內存泄漏的問題,達到安全的狀態。

2.5 Thread.exit() 當線程退出時會執行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; } 從源碼能夠看出當線程結束時,會令threadLocals=null,也就意味着GC的時候就能夠將threadLocalMap進行垃圾回收,換句話說threadLocalMap生命週期實際上thread的生命週期相同。

  1. threadLocal最佳實踐 經過這篇文章對threadLocal的內存泄漏作了很詳細的分析,咱們能夠徹底理解threadLocal內存泄漏的來龍去脈,那麼實踐中咱們應該怎麼作?

每次使用完ThreadLocal,都調用它的remove()方法,清除數據。 在使用線程池的狀況下,沒有及時清理ThreadLocal,不只是內存泄漏的問題,更嚴重的是可能致使業務邏輯出現問題。因此,使用ThreadLocal就跟加鎖完要解鎖同樣,用完就清理。 參考資料

《java高併發程序設計》 blog.xiaohansong.com/2016/08/06/…

轉自: www.jianshu.com/p/dde92ec37…

相關文章
相關標籤/搜索