1. 前言
“ThreadLocal為什么會導致內存泄漏,如何避免?”
今天剛好有時間,決定徹底弄清楚內存泄漏的原因,並分享給大家。
我們通過一張圖來清楚地表示ThreadLocal的引用關系
1.1 何為內存泄漏?
首先我們有必要了解,到底何為「內存泄漏」?筆者這里引用百度百科的解釋。
內存泄漏(Memory Leak)是指程序中已動態分配的堆內存由於某種原因程序未釋放或無法釋放,造成系統內存的浪費,導致程序運行速度減慢甚至系統崩潰等嚴重后果。
站在Java的角度來說,就是JVM創建的對象永遠都無法訪問到,但是GC又不能回收對象所占用的內存。少量的內存泄漏並不會出現什么嚴重問題,無非是浪費了一些內存資源罷了,但是隨着時間的積累,內存泄漏的越來越多就會導致「內存溢出」,程序崩潰。
因此,開發者必須非常小心,盡量避免內存泄漏,一旦發現就要盡快解決,以免造成嚴重后果。
1.2 ThreadLocal介紹
本篇文章主要記錄ThreadLocal內存泄漏的原因,但是怕部分讀者可能還不太了解ThreadLocal,所以還是決定再稍微介紹一下。
多個線程訪問同一個共享變量時,如果不做同步控制,往往會出現「數據不一致」的問題,通常會使用synchronized關鍵字加鎖來解決,ThreadLocal則換了一個思路。
ThreadLocal本身並不存儲值,它依賴於Thread類中的ThreadLocalMap,當調用set(T value)時,ThreadLocal將自身作為Key,值作為Value存儲到Thread類中的ThreadLocalMap中,這就相當於所有線程讀寫的都是自身的一個私有副本,線程之間的數據是隔離的,互不影響,也就不存在線程安全問題了。
2. 內存泄漏的原因
ThreadLocalMap內部維護了一個Entry[] table來存儲鍵值對的映射關系,內存泄漏和Entry類有非常大的關系,下面是Entry的源碼:
static class Entry extends WeakReference<ThreadLocal<?>> { /** The value associated with this ThreadLocal. */ Object value; Entry(ThreadLocal<?> k, Object v) { super(k); value = v; } }
Entry將ThreadLocal作為Key,值作為value保存,它繼承自WeakReference,注意構造函數里的第一行代碼super(k),這意味着ThreadLocal對象是一個「弱引用」。有的同學可能對「弱引用」不太熟悉,這里再介紹一下Java的四種引用關系。
Java的四種引用
在JDK1.2之前,“引用”的概念過於狹隘,如果Reference類型的數據存儲的是另外一塊內存的起始地址,就稱該Reference數據是某塊地址、對象的引用,對象只有兩種狀態:被引用、未被引用。
這樣的描述未免過於僵硬,對於這一類對象則無法描述:內存足夠時暫不回收,內存吃緊時進行回收。例如:緩存數據。
在JDK1.2之后,Java對引用的概念做了一些擴充,將引用分為四種,由強到弱依次為:
強引用(Strongly Reference)
指代碼中普遍存在的賦值行為,如:Object o = new Object(),只要強引用關系還在,對象就永遠不會被回收。
軟引用(Soft Reference)
還有用處,但是非必須存活的對象,JVM會在內存溢出前對其進行回收,例如:緩存。
弱引用(Weak Reference)
非必須存活的對象,引用關系比軟引用還弱,不管內存是否夠用,下次GC一定回收。
虛引用(Phantom Reference)
也稱“幽靈引用”、“幻影引用”,最弱的引用關系,完全不影響對象的回收,等同於沒有引用,虛引用的唯一的目的是對象被回收時會收到一個系統通知。
綜上所述,由於ThreadLocal對象是弱引用,如果外部沒有強引用指向它,它就會被GC回收,導致Entry的Key為null,如果這時value外部也沒有強引用指向它,那么value就永遠也訪問不到了,按理也應該被GC回收,但是由於Entry對象還在強引用value,導致value無法被回收,這時「內存泄漏」就發生了,value成了一個永遠也無法被訪問,但是又無法被回收的對象。
Entry對象屬於ThreadLocalMap,ThreadLocalMap屬於Thread,如果線程本身的生命周期很短,短時間內就會被銷毀,那么「內存泄漏」立刻就會得到解決,只要線程被銷毀,value也會隨之被回收。問題是,線程本身是非常珍貴的計算機資源,很少會去頻繁的創建和銷毀,一般都是通過線程池來使用,這就將線程的生命周期大大拉長,「內存泄漏」的影響也會越來越大。
2.1 弱引用是原罪嗎?
網上有的文章將ThreadLocal內存泄漏的原因怪罪於Entry的Key的弱引用,這個說法是極其錯誤的!
不用弱引用就能避免「內存泄漏」了嗎?當然不是!!!
恰恰相反,使用弱引用是JDK在盡量避免程序出現「內存泄漏」,如下代碼:
public class Test { public static void main(String[] args) { ThreadLocal threadLocal = new ThreadLocal(); threadLocal.set(new Object()); threadLocal = null; } }
創建一個ThreadLocal對象,並設置一個Object對象,然后將其置空。如果Key不是弱引用的話,threadLocal無法被回收,也無法被訪問,object無法被回收,也無法被訪問,Key和Value同時出現了「內存泄漏」。
當Key是弱引用時,threadLocal由於外部沒有強引用了,GC可以將其回收,ThreadLocal通過key.get()==null可以判斷Key已經被回收了,當前Entry是一個廢棄的過期節點,因此ThreadLocal可以自發的清理這些過期節點,來避免「內存泄漏」。
ThreadLocalMap是一個容器,不可能只進不出,否則時間長了必然會導致「內存溢出」,這也是大家平時使用各種容器對象時需要注意的點!ThreadLocal通過弱引用技術,可以及時發現過期的節點並清理,因此,弱引用是ThreadLocal來避免「內存泄漏」的,而不是導致內存泄漏的元凶。
2.2 如何避免內存泄漏?
使用ThreadLocal時,一般建議將其聲明為static final的,避免頻繁創建ThreadLocal實例。
盡量避免存儲大對象,如果非要存,那么盡量在訪問完成后及時調用remove()刪除掉。
2.3 ThreadLocal做出的努力
ThreadLocal不是洪水猛獸,不要聽到「內存泄漏」就不敢使用它,只要你規范化使用是不會有問題的。再者,就算你不規范使用,ThreadLocal也做出了很多努力來最大程度的幫你避免發生「內存泄漏」。
前面已經說過,由於Key是弱引用,因此ThreadLocal可以通過key.get()==null來判斷Key是否已經被回收,如果Key被回收,就說明當前Entry是一個廢棄的過期節點,ThreadLocal會自發的將其清理掉。
ThreadLocal會在以下過程中清理過期節點:
調用set()方法時,采樣清理、全量清理,擴容時還會繼續檢查。
調用get()方法,沒有直接命中,向后環形查找時。
調用remove()時,除了清理當前Entry,還會向后繼續清理。
1、set()的清理邏輯
當線程調用ThreadLocal.set(T value)時,它會將ThreadLocal對象作為Key,值作為value設置到ThreadLocalMap中,源碼如下:
private void set(ThreadLocal<?> key, Object value) { Entry[] tab = table; int len = tab.length; // 計算下標,算法:hashCode & (len - 1),和HashMap一樣,這里不詳敘。 int i = key.threadLocalHashCode & (len-1); for (Entry e = tab[i]; /* 如果下標元素不是null,有兩種情況: 1.同一個Key,覆蓋value。 2.哈希沖突了。 */ e != null; /* 哈希沖突的解決方式:開放定址法的線性探測。 當前下標被占用了,就找next,找到尾巴還沒找到就從頭開始找。 直到找到沒有被占用的下標。 */ e = tab[i = nextIndex(i, len)]) { ThreadLocal<?> k = e.get(); if (k == key) { // 相同的Key,則覆蓋value。 e.value = value; return; } if (k == null) { /* 下標被占用,但是Key.get()為null。說明ThreadLocal被回收了。 需要進行替換。 */ replaceStaleEntry(key, value, i); return; } } tab[i] = new Entry(key, value); int sz = ++size; /* 1.判斷是否可以清理一些槽位。 2.如果清理成功,就無需擴容了,因為已經騰出一些位置留給下次使用。 3.如果清理失敗,則要判斷是否需要擴容。 */ if (!cleanSomeSlots(i, sz) && sz >= threshold) rehash(); }
如果Entry.get()==null
說明發生哈希沖突了,且舊Key已經被回收了,此時ThreadLocal會替換掉舊的value,避免發生「內存泄漏」。
如果沒有哈希沖突,ThreadLocal仍然會調用cleanSomeSlots
來清理部分節點,源碼如下:
/* 清理部分槽位。 1.如果清理成功,就不用擴容了,因為已經騰出一部分位置了。 2.出於性能考慮,不會做所有元素做清理工作,而是采樣清理。 set()時,n=size,搜索范圍較小。 */ 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,擴大搜索范圍 n = len; removed = true; // 真正清理的邏輯 i = expungeStaleEntry(i); } /* 采樣規則: n >>>= 1 (折半) 例:100 > 50 > 25 > 12 > 6 > 3 > 1 */ } while ( (n >>>= 1) != 0); return removed; }
真正的清理邏輯在expungeStaleEntry()
中,源碼如下:
/* 刪除過期的元素:占用下標,但是ThreadLocal實例已經被回收的元素。 */ private int expungeStaleEntry(int staleSlot) { Entry[] tab = table; int len = tab.length; // 清理當前Entry tab[staleSlot].value = null; tab[staleSlot] = null; size--; // Rehash until we encounter null Entry e; int i; // 繼續往后尋找,直到遇到null結束 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; // 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; }
清理時,並不是只清理掉當前Entry就結束了,而是會往后環形的繼續尋找過期的Entry,只要找到了就清理,直到遇到tab[i]==null就結束,清理的過程中還會對元素做一個rehash的操作。
2、get()的清理邏輯
線程調用ThreadLocal.get()時,會從ThreadLocalMap.getEntry(this)去查找,源碼如下:
/* 通過Key獲取Entry */ private Entry getEntry(ThreadLocal<?> key) { // 計算下標 int i = key.threadLocalHashCode & (table.length - 1); Entry e = table[i]; if (e != null && e.get() == key) { // 如果對應下標節點不為null,且Key相等,則命中直接返回 return e; } else { /* 否則有兩種情況: 1.Key不存在。 2.哈希沖突了,需要向后環形查找。 */ return getEntryAfterMiss(key, i, e); } }
如果命中則直接返回,如果沒有命中則可能是哈希沖突了、或者Key不存在/已被回收,接着調用getEntryAfterMiss()
查找,這里也會進行過期節點的清理:
/* 無法直接命中的查找邏輯 */ private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) { Entry[] tab = table; int len = tab.length; while (e != null) {// e==null說明Key不存在,直接返回null ThreadLocal<?> k = e.get(); if (k == key) // 找到了,說明是哈希沖突 return e; if (k == null) // Key存在,但是過期了,需要清理掉,並且返回null expungeStaleEntry(i); else // 向后環形查找 i = nextIndex(i, len); e = tab[i]; } return null; }
3、remove()的清理邏輯
線程調用ThreadLocal.remove()
本身就是清理當前節點的,但是為了避免發生「內存泄漏」,ThreadLocal還會檢查容器中是否還有其他過期節點,如果發現也會一並清理,主要邏輯在ThreadLocalMap.remove()
中:
// 通過Key刪除Entry private void remove(ThreadLocal<?> key) { Entry[] tab = table; int len = tab.length; // 計算下標 int i = key.threadLocalHashCode & (len-1); /* 刪除也是一樣,由於存在哈希沖突,不能直接定位到下標后直接刪除。 刪除前需要確認Key是否相等,如果不等需要往后環形查找。 */ for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) { if (e.get() == key) { /* 找到了就清理掉。 這里並沒有直接清理,而是將Key的Reference引用清空了, 然后再調用expungeStaleEntry()清理過期元素。 順便還可以清理后續節點。 */ e.clear(); expungeStaleEntry(i); return; } } }
內存泄漏的根本原因
所有Entry對象都被ThreadLocalMap類的實例化對象threadLocals持有,當ThreadLocal對象不再使用時,ThreadLocal對象在棧中的引用就會被回收,一旦沒有任何引用指向ThreadLocal對象,Entry只持有弱引用的key就會自動在下一次YGC時被回收,而此時持有強引用的Entry對象並不會被回收。
簡而言之: threadLocals對象中的entry對象不在使用后,沒有及時remove該entry對象 ,然而程序自身也無法通過垃圾回收機制自動清除,從而導致內存泄漏。