ThreadLocal到底有没有内存泄漏?从源码角度来剖析一波
1. 前言
ThreadLocal 也是一個使用頻率較高的類,在框架中也經常見到,比如 Spring。
有關 ThreadLocal 源碼分析的文章不少,其中有個問題常被提及:ThreadLocal 是否存在內存泄漏?
不少文章對此講述比較模糊,經常讓人看完腦子還是一頭霧水,我也有此困惑。因此找時間跟小伙伴討論了一番,總算對這個問題有了一定的理解,這里記錄和分享一下,希望對有同樣困惑的朋友們有所幫助。當然,若有理解不當的地方也歡迎指正。
啰嗦就到這里,下面先從 ThreadLocal 的一個應用場景開始分析吧。
2. 應用場景
ThreadLocal 的應用場景不少,這里舉個簡單的栗子:單點登錄攔截。
也就是在處理一個 HTTP 請求之前,判斷用戶是否登錄:
若未登錄,跳轉到登錄頁面;
若已登錄,獲取并保存用戶的登錄信息。
先定義一個 UserInfoHolder 類保存用戶的登錄信息,其內部用 ThreadLocal 存儲,示例如下:
public?class?UserInfoHolder?{private?static?final?ThreadLocal<Map<String,?String>>?USER_INFO_THREAD_LOCAL?=?new?ThreadLocal<>();public?static?void?set(Map<String,?String>?map)?{USER_INFO_THREAD_LOCAL.set(map);}public?static?Map<String,?String>?get()?{return?USER_INFO_THREAD_LOCAL.get();}public?static?void?clear()?{USER_INFO_THREAD_LOCAL.remove();}//?... }通過 UserInfoHolder 可以存儲和獲取用戶的登錄信息,以便在業務中使用。
Spring 項目中,如果我們想在處理一個 HTTP 請求之前或之后做些額外的處理,通常定義一個類繼承 HandlerInterceptorAdapter,然后重寫它的一些方法。舉例如下(僅供參考,省略了一些代碼):
public?class?LoginInterceptor?extends?HandlerInterceptorAdapter?{//?...@Overridepublic?boolean?preHandle(HttpServletRequest?request,?HttpServletResponse?response,?Object?handler)throws?Exception?{//?...//?請求執行前,獲取用戶登錄信息并保存Map<String,?String>?userInfoMap?=?getUserInfo();UserInfoHolder.set(userInfoMap);return?true;}@Overridepublic?void?afterCompletion(HttpServletRequest?request,?HttpServletResponse?response,?Object?handler,?Exception?ex)?{//?請求執行后,清理掉用戶信息UserInfoHolder.clear();} }在本例中,我們在處理一個請求之前獲取用戶的信息,在處理完請求之后,將用戶信息清空。應該有朋友在框架或者自己的項目中見過類似代碼。
下面我們深入 ThreadLocal 的內部,來分析這些方法做了些什么,跟內存泄漏又是怎么扯上關系的。
3. 源碼剖析
3.1 類簽名
先從頭開始,也就是類簽名:
public?class?ThreadLocal<T>?{ }可見它就是一個普通的類,并沒有實現任何接口、也無父類繼承。
3.2 構造器
ThreadLocal 只有一個無參構造器:
public?ThreadLocal()?{ }此外,JDK 1.8 引入了一個使用 lambda 表達式初始化的靜態方法 withInitial,如下:
public?static?<S>?ThreadLocal<S>?withInitial(Supplier<??extends?S>?supplier)?{return?new?SuppliedThreadLocal<>(supplier); }該方法也可以初始化一個對象,和構造器也比較接近。
3.3 ThreadLocalMap
3.3.1 主要代碼
ThreadLocalMap 是 ThreadLocal 的一個內部嵌套類。
由于 ThreadLocal 的主要操作實際都是通過 ThreadLocalMap 的方法實現的,因此先分析 ThreadLocalMap 的主要代碼:
public?class?ThreadLocal<T>?{//?生成?ThreadLocal?的哈希碼,用于計算在?Entry?數組中的位置private?final?int?threadLocalHashCode?=?nextHashCode();private?static?final?int?HASH_INCREMENT?=?0x61c88647;private?static?int?nextHashCode()?{return?nextHashCode.getAndAdd(HASH_INCREMENT);}//?...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;//?存儲數據的數組private?Entry[]?table;//?table?中的?Entry?數量private?int?size?=?0;//?擴容的閾值private?int?threshold;?//?Default?to?0//?設置擴容閾值private?void?setThreshold(int?len)?{threshold?=?len?*?2?/?3;}????//?第一次添加元素使用的構造器ThreadLocalMap(ThreadLocal<?>?firstKey,?Object?firstValue)?{table?=?new?Entry[INITIAL_CAPACITY];int?i?=?firstKey.threadLocalHashCode?&?(INITIAL_CAPACITY?-?1);table[i]?=?new?Entry(firstKey,?firstValue);size?=?1;setThreshold(INITIAL_CAPACITY);}//?...} }ThreadLocalMap 的內部結構其實跟 HashMap 很類似,可以對比前面「JDK源碼分析-HashMap(1)」對 HashMap 的分析。
二者都是「鍵-值對」構成的數組,對哈希沖突的處理方式不同,導致了它們在結構上產生了一些區別:
HashMap 處理哈希沖突使用的「鏈表法」。也就是當產生沖突時拉出一個鏈表,而且 JDK 1.8 進一步引入了紅黑樹進行優化。
ThreadLocalMap 則使用了「開放尋址法」中的「線性探測」。即,當某個位置出現沖突時,從當前位置往后查找,直到找到一個空閑位置。
其它部分大體是類似的。
3.3.2 注意事項
弱引用
有個值得注意的地方是:ThreadLocalMap 的 Entry 繼承了 WeakReference 類,也就是弱引用類型。
跟進 Entry 的父類,可以看到 ThreadLocal 最終賦值給了 WeakReference 的父類 Reference 的 referent 屬性。即,可以認為 Entry 持有了兩個對象的引用:ThreadLocal 類型的「弱引用」和 Object 類型的「強引用」,其中 ThreadLocal 為 key,Object 為 value。如圖所示:
ThreadLocal 在某些情況可能產生的「內存泄漏」就跟這個「弱引用」有關,后面再展開分析。
尋址
Entry 的 key 是 ThreadLocal 類型的,它是如何在數組中散列的呢?
ThreadLocal 有個 threadLocalHashCode 變量,每次創建 ThreadLocal 對象時,這個變量都會增加一個固定的值 HASH_INCREMENT,即 0x61c88647,這個數字似乎跟黃金分割、斐波那契數有關,但這不是重點,有興趣的朋友可以去深入研究下,這里我們知道它的目的就行了。與 HashMap 的 hash 算法的目的近似,就是為了散列的更均勻。
下面分析 ThreadLocal 的主要方法實現。
3.4 主要方法
ThreadLocal 主要有三個方法:set、get 和 remove,下面分別介紹。
3.4.1 set 方法
set 方法:新增/更新 Entry
threadLocals 是 Thread 持有的一個 ThreadLocalMap 引用,默認是 null:
public?class?Thread?implements?Runnable?{//?其他代碼...ThreadLocal.ThreadLocalMap?threadLocals?=?null; }執行流程
若從當前 Thread 拿到的 ThreadLocalMap 為空,表示該屬性并未初始化,執行 createMap 初始化:
void?createMap(Thread?t,?T?firstValue)?{t.threadLocals?=?new?ThreadLocalMap(this,?firstValue); }若已存在,則調用 ThreadLocalMap 的 set 方法:
private?void?set(ThreadLocal<?>?key,?Object?value)?{????Entry[]?tab?=?table;int?len?=?tab.length;//?1.?計算?key?在數組中的下標?iint?i?=?key.threadLocalHashCode?&?(len-1);//?1.1?若數組下標為?i?的位置有元素//?判斷 i 位置的 Entry 是否為空;不為空則從 i 開始向后遍歷數組for?(Entry?e?=?tab[i];e?!=?null;e?=?tab[i?=?nextIndex(i,?len)])?{ThreadLocal<?>?k?=?e.get();//?索引為?i?的元素就是要查找的元素,用新值覆蓋舊值,到此返回if?(k?==?key)?{e.value?=?value;return;}//?索引為?i?的元素并非要查找的元素,且該位置中?Entry?的?Key?已經是?null//?Key?為?null?表明該?Entry?已經過期了,此時用新值來替換這個位置的過期值if?(k?==?null)?{//?替換過期的?Entry,replaceStaleEntry(key,?value,?i);return;}}//?1.2?若數組下標為?i?的位置為空,將要存儲的元素放到?i?的位置tab[i]?=?new?Entry(key,?value);int?sz?=?++size;//?若未清理過期的?Entry,且數組的大小達到閾值,執行?rehash?操作if?(!cleanSomeSlots(i,?sz)?&&?sz?>=?threshold)rehash(); }先總結下 set 方法主要流程:
首先根據 key 的 threadLocalHashCode 計算它的數組下標:
如果數組下標的 Entry 不為空,表示該位置已經有元素。由于可能存在哈希沖突,因此這個位置的元素可能并不是要找的元素,所以遍歷數組去比較
如果找到等于當前 key 的 Entry,則用新值替換舊值,返回。
如果遍歷過程中,遇到 Entry 不為空、但是 Entry 的 key 為空的情況,則會做一些清理工作。
如果數組下標的 Entry 為空,直接將元素放到這里,必要時進行擴容。
replaceStaleEntry:替換過期的值,并清理一些過期的 Entry
replaceStaleEntry 的主要執行流程如下:
從 staleSlot 向前遍歷數組,直到 Entry 為空時停止遍歷。這一步的主要目的是查找 staleSlot 前面過期的 Entry 的數組下標 slotToExpunge。
從 staleSlot 向后遍歷數組
若 Entry 的 key 與給定的 key 相等,將該 Entry 與 staleSlot 下標的 Entry 互換位置。目的是為了讓新增的 Entry 放到它「應該」在的位置。
若找不到相等的 key,說明該 key 對應的 Entry 不在數組中,將新值放到 staleSlot 位置。該操作其實就是處理哈希沖突的「線性探測」方法:當某個位置已被占用,向后探測下一個位置。
若 staleSlot 前面存在過期的 Entry,則執行清理操作。
PS: 所謂 Entry「應該」在的位置,就是根據 key 的 threadLocalHashCode 與數組長度取余計算出來的位置,即 k.threadLocalHashCode & (len - 1) ,或者哈希沖突之后的位置,這里只是為了方便描述。
expungeStaleEntry:清理過期的 Entry
該方法主要做了哪些工作呢?
清空給定位置的 Entry
從給定位置的下一個開始向后遍歷數組
若遇到 Entry 為 null,結束遍歷
若遇到 key 為空的 Entry(即過期的),就將該 Entry 置空
若遇到 key 不為空的 Entry,而且經過計算,該 Entry 并不在它「應該」在的位置,則將其移動到它「應該」在的位置
返回 staleSlot 后面的、Entry 為 null 的索引下標
cleanSomeSlots:清理一些槽(Slot)
該方法做了什么呢?從給定位置的下一個開始掃描數組,若遇到 key 為空的 Entry(過期的),則清理該位置及其后面過期的槽。
值得注意的是,該方法循環執行的次數為 log(n)。由于該方法是在 set 方法內部被調用的,也就是新增/更新時:
如果不掃描和清理,set 方法執行速度很快,但是會存在一些垃圾(過期的 Entry);
如果每次都掃描清理,不會存在垃圾,但是插入性能會降低到 O(n)。
因此,這個次數其實就一種平衡策略:Entry 數組較小時,就少清理幾次;數組較大時,就多清理幾次。
rehash:調整 Entry 數組
該方法主要作用:
清理數組中過期的 Entry
若清理后 Entry 的數量大于等于 threshold 的 3/4,則執行 resize 方法進行擴容
resize 方法:Entry 數組擴容
該方法的作用是 Entry 數組擴容,主要流程:
創建一個新數組,長度為原數組的 2 倍;
從下標 0 開始遍歷舊數組的所有元素
若元素已過期(key 為空),則將 value 也置空
將未過期的元素移到新數組
3.4.2 get 方法
分析完了 set 方法,再看 get 方法就相對容易了不少。
get 方法:獲取 ThreadLocal 對應的 Entry
get 方法首先獲取當前線程的 ThreadLocalMap 并判斷:
若 Map 已存在,從 Map 中取值
若 Map 不存在,或者 Map 中獲取的值為空,執行 setInitialValue 方法
setInitialValue 方法:獲取/設置初始值
先取初始值,這個初始值默認為空(該方法是 protected,可以由子類初始化)。
若 Thread 的 ThreadLocalMap 已初始化,則將初始值存入 Map
否則,創建 ThreadLocalMap
返回初始值
除了初始值,其他邏輯跟 set 方法是一樣的,這里不再贅述。
PS: 可以看到初始值是惰性初始化的。
getEntry:從 Entry 數組中獲取給定 key 對應的 Entry
3.4.3 remove 方法
remove 方法:移除 ThreadLocal 對應的 Entry
這里調用了 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;}} }其中 e.clear 調用的是 Entry 的父類 Reference 的 clear 方法:
public?void?clear()?{this.referent?=?null; }其實就是將 Entry 的 key 置空。
remove 方法的主要執行流程如下:
獲取當前線程的 ThreadLocalMap
以當前 ThreadLocal 做為 key,從 Map 中查找相應的 Entry,將 Entry 的 key 置空
將該 ThreadLocal 對應的 Entry 置空,并向后遍歷清理 Entry 數組,也就是 expungeStaleEntry 方法的操作,前面已經分析過了,這里不再贅述。
3.4.4 主要方法小結
ThreadLocal 的主要方法 set、get 和 remove 前面已經分析過,這里簡單做個小結。
set 方法
以當前 ThreadLocal 為 key、新增的 Object 為 value 組成一個 Entry,放入 ThreadLocalMap,也就是 Entry 數組中。
計算 Entry 的位置后
若該槽為空,直接放到這里;并清理一些過期的 Entry,必要時進行擴容。
當遇到散列沖突時,線性探測向后查找數組中為空的、或者已經過期的槽,用新值替換。
get 方法
以當前 ThreadLocal 為 key,從 Entry 數組中查找對應 Entry 的 value
若 ThreadLocalMap 未初始化,則用給定初始值將其初始化
若 ThreadLocalMap 已初始化,從 Entry 數組查找 key
remove 方法:以當前 ThreadLocal 為 key,從 Entry 數組清理掉對應的 Entry,并且再清理該位置后面的、過期的 Entry
方法雖少,但是稍微有點繞,除了做本身的功能,都執行了一些額外的清理操作。
分析了這幾個方法的源碼之后,下面就來研究一下內存泄漏的問題。
4. 內存泄漏分析
首先說明一點,ThreadLocal 通常作為成員變量或靜態變量來使用(也就是共享的),比如前面應用場景中的例子。因為局部變量已經在同一條線程內部了,沒必要使用 ThreadLocal。
為便于理解,這里先給出了 Thread、ThreadLocal、ThreadLocalMap、Entry 這幾個類在 JVM 的內存示意圖:
簡單說明:
當一個線程運行時,棧中存在當前 Thread 的棧幀,它持有 ThreadLocalMap 的強引用。
ThreadLocal 所在的類持有一個 ThreadLocal 的強引用;同時,ThreadLocalMap 中的 Entry 持有一個 ThreadLocal 的弱引用。
4.1 場景一
若方法執行完畢、線程正常消亡,則 Thread 的 ThreadLocalMap 引用將斷開,如圖:
以后 GC 發生時,弱引用也會斷開,整個 ThreadLocalMap 都會被回收掉,不存在內存泄漏。
4.2 場景二
如果是線程池中的線程呢?也就是線程一直存活。經過 GC 后 Entry 持有的 ThreadLocal 引用斷開,Entry 的 key 為空,value 不為空,如圖所示:
此時,如果沒有任何 remove 或者 get 等清理 Entry 數組的動作,那么該 Entry 的 value 持有的 Object 就不會被回收掉。這樣就產生了內存泄漏。
這種情況其實也很容易避免,使用完執行 remove 方法就行了。
5. 小結
本文分析了 ThreadLocal 的主要方法實現,并分析了它可能存在內存泄漏的場景。
ThreadLocal 主要用于當前線程從共享變量中保存一份「副本」,常用的一個場景就是單點登錄保存用戶的登錄信息。
ThreadLocal 將數據存儲在 ThreadLocalMap 中,ThreadLocalMap 是由 Entry 構成的數組,結構有點類似 HashMap。
ThreadLocal 使用不當可能會造成內存泄漏。避免內存泄漏的方法是在方法調用結束前執行 ThreadLocal 的 remove 方法。
有道無術,術可成;有術無道,止于術
歡迎大家關注Java之道公眾號
好文章,我在看??
總結
以上是生活随笔為你收集整理的ThreadLocal到底有没有内存泄漏?从源码角度来剖析一波的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: PMBOK(第六版) PMP笔记——《十
- 下一篇: UNIX/Linux-进程控制(实例入门