一篇文章,從原始碼深入詳解ThreadLocal記憶體洩漏問題

local0發表於2021-09-09

一篇文章,從原始碼深入詳解ThreadLocal記憶體洩漏問題

1. 造成記憶體洩漏的原因?

threadLocal是為了解決物件不能被多執行緒共享訪問的問題,通過threadLocal.set方法將物件例項儲存在每個執行緒自己所擁有的threadLocalMap中,這樣每個執行緒使用自己的物件例項,彼此不會影響達到隔離的作用,從而就解決了物件在被共享訪問帶來執行緒安全問題。如果將同步機制和threadLocal做一個橫向比較的話,同步機制就是通過控制執行緒訪問共享物件的順序,而threadLocal就是為每一個執行緒分配一個該物件,各用各的互不影響。打個比方說,現在有100個同學需要填寫一張表格但是隻有一支筆,同步就相當於A使用完這支筆後給B,B使用後給C用......老師就控制著這支筆的使用順序,使得同學之間不會產生衝突。而threadLocal就相當於,老師直接準備了100支筆,這樣每個同學都使用自己的,同學之間就不會產生衝突。很顯然這就是兩種不同的思路,同步機制以“時間換空間”,由於每個執行緒在同一時刻共享物件只能被一個執行緒訪問造成整體上響應時間增加,但是物件只佔有一份記憶體,犧牲了時間效率換來了空間效率即“時間換空間”。而threadLocal,為每個執行緒都分配了一份物件,自然而然記憶體使用率增加,每個執行緒各用各的,整體上時間效率要增加很多,犧牲了空間效率換來時間效率即“空間換時間”。

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

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記憶體洩漏就值得我們關注。

2. 已經做出了哪些改進?

實際上,為了解決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做了這樣的處理:

  1. 如果當前table[i]!=null的話說明hash衝突就需要向後環形查詢,若在查詢過程中遇到髒entry就通過replaceStaleEntry進行處理;
  2. 如果當前table[i]==null的話說明新的entry可以直接插入,但是插入後會呼叫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;
}
複製程式碼

入參:

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

  2. 引數n

    2.1. n的用途

    主要用於掃描控制(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步),主要做了這麼幾件事情:

  1. 清理當前髒entry,即將其value引用置為null,並且將table[staleSlot]也置為null。value置為null後該value域變為不可達,在下一次gc的時候就會被回收掉,同時table[staleSlot]為null後以便於存放新的entry;
  2. 從當前staleSlot位置向後環形(nextIndex)繼續搜尋,直到遇到雜湊桶(tab[i])為null的時候退出;
  3. 若在搜尋過程再次遇到髒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方法主要有這樣幾點:

  1. 從當前位置i處(位於i處的entry一定不是髒entry)為起點在初始小範圍(log2(n),n為雜湊表已插入entry的個數size)開始向後搜尋髒entry,若在整個搜尋過程沒有髒entry,方法結束退出
  2. 如果在搜尋過程中遇到髒entryt通過expungeStaleEntry方法清理掉當前髒entry,並且該方法會返回下一個雜湊桶(table[i])為null的索引位置為i。這時重新令搜尋起點為索引位置i,n為雜湊表的長度len,再次擴大搜尋範圍為log2(n')繼續搜尋。

下面,以一個例子更清晰的來說一下,假設當前table陣列的情況如下圖。

cleanSomeSlots執行情景圖.png

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

  2. 第二趟所搜先通過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返回。至此,第二趟搜尋結束;

  3. 由於在第二趟搜尋中發現髒entry,n增大為陣列的長度len,因此擴大搜尋範圍(增大迴圈次數)繼續向後環形搜尋;

  4. 直到在整個搜尋範圍裡都未發現髒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
10.    if (slotToExpunge != staleSlot)
		//執行cleanSomeSlots
11.        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](http://upload-images.jianshu.io/upload_images/2615789-423c8c8dfb2e9557.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
	如圖,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.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的生命週期相同。

3. threadLocal最佳實踐

通過這篇文章對threadLocal的記憶體洩漏做了很詳細的分析,我們可以完全理解threadLocal記憶體洩漏的前因後果,那麼實踐中我們應該怎麼做?

  1. 每次使用完ThreadLocal,都呼叫它的remove()方法,清除資料。
  2. 在使用執行緒池的情況下,沒有及時清理ThreadLocal,不僅是記憶體洩漏的問題,更嚴重的是可能導致業務邏輯出現問題。所以,使用ThreadLocal就跟加鎖完要解鎖一樣,用完就清理。

參考資料

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

相關文章