從原始碼角度來剖析ThreadLocal到底有沒有記憶體洩漏?
1. 前言
ThreadLocal 也是一個使用頻率較高的類,在框架中也經常見到,比如 Spring。
有關 ThreadLocal 原始碼分析的文章不少,其中有個問題常被提及:ThreadLocal 是否存在記憶體洩漏?
不少文章對此講述比較模糊,經常讓人看完腦子還是一頭霧水,我也有此困惑。因此找時間跟小夥伴討論了一番,總算對這個問題有了一定的理解,這裡記錄和分享一下,希望對有同樣困惑的朋友們有所幫助。當然,若有理解不當的地方也歡迎指正。
囉嗦就到這裡,下面先從 ThreadLocal 的一個應用場景開始分析吧。
2. 應用場景
ThreadLocal 的應用場景不少,這裡舉個簡單的例子:單點登入攔截。
也就是在處理一個 HTTP 請求之前,判斷使用者是否登入:
- 若未登入,跳轉到登入頁面;
- 若已登入,獲取並儲存使用者的登入資訊。
先定義一個 UserInfoHolder 類儲存使用者的登入資訊,其內部用 ThreadLocal 儲存,示例如下:
publicclassUserInfoHolder{ privatestaticfinalThreadLocal<Map<String,String>>USER_INFO_THREAD_LOCAL=newThreadLocal<>(); publicstaticvoidset(Map<String,String>map){ USER_INFO_THREAD_LOCAL.set(map); } publicstaticMap<String,String>get(){ returnUSER_INFO_THREAD_LOCAL.get(); } publicstaticvoidclear(){ USER_INFO_THREAD_LOCAL.remove(); } //... }
通過 UserInfoHolder 可以儲存和獲取使用者的登入資訊,以便在業務中使用。
Spring 專案中,如果我們想在處理一個 HTTP 請求之前或之後做些額外的處理,通常定義一個類繼承 HandlerInterceptorAdapter,然後重寫它的一些方法。舉例如下(僅供參考,省略了一些程式碼):
publicclassLoginInterceptorextendsHandlerInterceptorAdapter{ //... @Override publicbooleanpreHandle(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler) throwsException{ //... //請求執行前,獲取使用者登入資訊並儲存 Map<String,String>userInfoMap=getUserInfo(); UserInfoHolder.set(userInfoMap); returntrue; } @Override publicvoidafterCompletion(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler,Exceptionex){ //請求執行後,清理掉使用者資訊 UserInfoHolder.clear(); } }
在本例中,我們在處理一個請求之前獲取使用者的資訊,在處理完請求之後,將使用者資訊清空。應該有朋友在框架或者自己的專案中見過類似程式碼。
下面我們深入 ThreadLocal 的內部,來分析這些方法做了些什麼,跟記憶體洩漏又是怎麼扯上關係的。
3. 原始碼剖析
3.1 類簽名
先從頭開始,也就是類簽名:
publicclassThreadLocal<T>{ }
可見它就是一個普通的類,並沒有實現任何介面、也無父類繼承。
3.2 構造器
ThreadLocal 只有一個無參構造器:
publicThreadLocal(){ }
此外,JDK 1.8 引入了一個使用 lambda 表示式初始化的靜態方法 withInitial,如下:
publicstatic<S>ThreadLocal<S>withInitial(Supplier<?extendsS>supplier){ returnnewSuppliedThreadLocal<>(supplier); }
該方法也可以初始化一個物件,和構造器也比較接近。
3.3 ThreadLocalMap
3.3.1 主要程式碼
ThreadLocalMap 是 ThreadLocal 的一個內部巢狀類。
由於 ThreadLocal 的主要操作實際都是通過 ThreadLocalMap 的方法實現的,因此先分析 ThreadLocalMap 的主要程式碼:
publicclassThreadLocal<T>{ //生成ThreadLocal的雜湊碼,用於計算在Entry陣列中的位置 privatefinalintthreadLocalHashCode=nextHashCode(); privatestaticfinalintHASH_INCREMENT=0x61c88647; privatestaticintnextHashCode(){ returnnextHashCode.getAndAdd(HASH_INCREMENT); } //... staticclassThreadLocalMap{ staticclassEntryextendsWeakReference<ThreadLocal<?>>{ Objectvalue; Entry(ThreadLocal<?>k,Objectv){ super(k); value=v; } } //初始容量,必須是2的次冪 privatestaticfinalintINITIAL_CAPACITY=16; //儲存資料的陣列 privateEntry[]table; //table中的Entry數量 privateintsize=0; //擴容的閾值 privateintthreshold;//Defaultto0 //設定擴容閾值 privatevoidsetThreshold(intlen){ threshold=len*2/3; } //第一次新增元素使用的構造器 ThreadLocalMap(ThreadLocal<?>firstKey,ObjectfirstValue){ table=newEntry[INITIAL_CAPACITY]; inti=firstKey.threadLocalHashCode&(INITIAL_CAPACITY-1); table[i]=newEntry(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
publicvoidset(Tvalue){ //獲取當前執行緒 Threadt=Thread.currentThread(); //從Thread中獲取ThreadLocalMap ThreadLocalMapmap=getMap(t); if(map!=null) map.set(this,value); else createMap(t,value); } ThreadLocalMapgetMap(Threadt){ returnt.threadLocals; }
threadLocals 是 Thread 持有的一個 ThreadLocalMap 引用,預設是 null:
publicclassThreadimplementsRunnable{ //其他程式碼... ThreadLocal.ThreadLocalMapthreadLocals=null; }
- 執行流程
若從當前 Thread 拿到的 ThreadLocalMap 為何,表示該屬性並未初始化,執行 createMap 初始化:
voidcreateMap(Threadt,TfirstValue){ t.threadLocals=newThreadLocalMap(this,firstValue); }
若已存在,則呼叫 ThreadLocalMap 的 set 方法:
privatevoidset(ThreadLocal<?>key,Objectvalue){ Entry[]tab=table; intlen=tab.length; //1.計算key在陣列中的下標i inti=key.threadLocalHashCode&(len-1); //1.1若陣列下標為i的位置有元素 //判斷 i 位置的 Entry 是否為空;不為空則從 i 開始向後遍歷陣列 for(Entrye=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]=newEntry(key,value); intsz=++size; //若未清理過期的Entry,且陣列的大小達到閾值,執行rehash操作 if(!cleanSomeSlots(i,sz)&&sz>=threshold) rehash(); }
先總結下 set 方法主要流程:
首先根據 key 的 threadLocalHashCode 計算它的陣列下標:
- 如果陣列下標的 Entry 不為空,表示該位置已經有元素。由於可能存在雜湊衝突,因此這個位置的元素可能並不是要找的元素,所以遍歷陣列去比較
- 如果找到等於當前 key 的 Entry,則用新值替換舊值,返回。
- 如果遍歷過程中,遇到 Entry 不為空、但是 Entry 的 key 為空的情況,則會做一些清理工作。
- 如果陣列下標的 Entry 為空,直接將元素放到這裡,必要時進行擴容。
- replaceStaleEntry:替換過期的值,並清理一些過期的 Entry
privatevoidreplaceStaleEntry(ThreadLocal<?>key,Objectvalue, intstaleSlot){ Entry[]tab=table; intlen=tab.length; Entrye; //從staleSlot開始向前遍歷,若遇到過期的槽(Entry的key為空),更新slotToExpunge //直到Entry為空停止遍歷 intslotToExpunge=staleSlot; for(inti=prevIndex(staleSlot,len); (e=tab[i])!=null; i=prevIndex(i,len)) if(e.get()==null) slotToExpunge=i; //從staleSlot開始向後遍歷,若遇到與當前key相等的Entry,更新舊值,並將二者換位置 //目的是把它放到「應該」在的位置 for(inti=nextIndex(staleSlot,len); (e=tab[i])!=null; i=nextIndex(i,len)){ ThreadLocal<?>k=e.get(); if(k==key){ //更新舊值 e.value=value; //換位置 tab[i]=tab[staleSlot]; tab[staleSlot]=e; //Startexpungeatprecedingstaleentryifitexists if(slotToExpunge==staleSlot) slotToExpunge=i; cleanSomeSlots(expungeStaleEntry(slotToExpunge),len); return; } if(k==null&&slotToExpunge==staleSlot) slotToExpunge=i; } //Ifkeynotfound,putnewentryinstaleslot //若未找到key,說明Entry此前並不存在,新增 tab[staleSlot].value=null; tab[staleSlot]=newEntry(key,value); //Ifthereareanyotherstaleentriesinrun,expungethem if(slotToExpunge!=staleSlot) cleanSomeSlots(expungeStaleEntry(slotToExpunge),len); }
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
//staleSlot表示過期的槽位(即Entry陣列的下標) privateintexpungeStaleEntry(intstaleSlot){ Entry[]tab=table; intlen=tab.length; //1.將給定位置的Entry置為null tab[staleSlot].value=null; tab[staleSlot]=null; size--; //Rehashuntilweencounternull Entrye; inti; //遍歷陣列 for(i=nextIndex(staleSlot,len); (e=tab[i])!=null; i=nextIndex(i,len)){ //獲取Entry的key ThreadLocal<?>k=e.get(); if(k==null){ //若key為null,表示Entry過期,將Entry置空 e.value=null; tab[i]=null; size--; }else{ //key不為空,表示Entry未過期 //計算key的位置,若Entry不在它「應該」在的位置,把它移到「應該」在的位置 inth=k.threadLocalHashCode&(len-1); if(h!=i){ tab[i]=null; //UnlikeKnuth6.4AlgorithmR,wemustscanuntil //nullbecausemultipleentriescouldhavebeenstale. while(tab[h]!=null) h=nextIndex(h,len); tab[h]=e; } } } returni; }
該方法主要做了哪些工作呢?
- 清空給定位置的 Entry
- 從給定位置的下一個開始向後遍歷陣列
- 若遇到 Entry 為 null,結束遍歷
- 若遇到 key 為空的 Entry(即過期的),就將該 Entry 置空
- 若遇到 key 不為空的 Entry,而且經過計算,該 Entry 並不在它「應該」在的位置,則將其移動到它「應該」在的位置
- 返回 staleSlot 後面的、Entry 為 null 的索引下標
- cleanSomeSlots:清理一些槽(Slot)
privatebooleancleanSomeSlots(inti,intn){ booleanremoved=false; Entry[]tab=table; intlen=tab.length; do{ i=nextIndex(i,len); Entrye=tab[i]; //Entry不為空、key為空,即Entry過期 if(e!=null&&e.get()==null){ n=len; removed=true; //清理i後面連續過期的Entry,直到Entry為null,返回該Entry的下標 i=expungeStaleEntry(i); } }while((n>>>=1)!=0); returnremoved; }
該方法做了什麼呢?從給定位置的下一個開始掃描陣列,若遇到 key 為空的 Entry(過期的),則清理該位置及其後面過期的槽。
值得注意的是,該方法迴圈執行的次數為 log(n)。由於該方法是在 set 方法內部被呼叫的,也就是新增/更新時:
- 如果不掃描和清理,set 方法執行速度很快,但是會存在一些垃圾(過期的 Entry);
- 如果每次都掃描清理,不會存在垃圾,但是插入效能會降低到 O(n)。
因此,這個次數其實就一種平衡策略:Entry 陣列較小時,就少清理幾次;陣列較大時,就多清理幾次。
- rehash:調整 Entry 陣列
privatevoidrehash(){ //清理陣列中過期的Entry expungeStaleEntries(); //Uselowerthresholdfordoublingtoavoidhysteresis if(size>=threshold-threshold/4) resize(); } //從頭開始清理整個Entry陣列 privatevoidexpungeStaleEntries(){ Entry[]tab=table; intlen=tab.length; for(intj=0;j<len;j++){ Entrye=tab[j]; if(e!=null&&e.get()==null) expungeStaleEntry(j); } }
該方法主要作用:
- 清理陣列中過期的 Entry
- 若清理後 Entry 的數量大於等於 threshold 的 3/4,則執行 resize 方法進行擴容
- resize 方法:Entry 陣列擴容
/** *Doublethecapacityofthetable. */ privatevoidresize(){ Entry[]oldTab=table; intoldLen=oldTab.length; intnewLen=oldLen*2;//新長度為舊長度的兩倍 Entry[]newTab=newEntry[newLen]; intcount=0; //遍歷舊的Entry陣列,將陣列中的值移到新陣列中 for(intj=0;j<oldLen;++j){ Entrye=oldTab[j]; if(e!=null){ ThreadLocal<?>k=e.get(); //若Entry的key已過期,則將Entry清理掉 if(k==null){ e.value=null;//HelptheGC }else{ //計算在新陣列中的位置 inth=k.threadLocalHashCode&(newLen-1); //雜湊衝突,線性探測下一個位置 while(newTab[h]!=null) h=nextIndex(h,newLen); newTab[h]=e; count++; } } } //設定新的閾值 setThreshold(newLen); size=count; table=newTab; }
該方法的作用是 Entry 陣列擴容,主要流程:
- 建立一個新陣列,長度為原陣列的 2 倍;
- 從下標 0 開始遍歷舊陣列的所有元素
- 若元素已過期(key 為空),則將 value 也置空
- 將未過期的元素移到新陣列
3.4.2 get 方法
分析完了 set 方法,再看 get 方法就相對容易了不少。
- get 方法:獲取 ThreadLocal 對應的 Entry
publicTget(){ Threadt=Thread.currentThread(); ThreadLocalMapmap=getMap(t); if(map!=null){ ThreadLocalMap.Entrye=map.getEntry(this); if(e!=null){ @SuppressWarnings("unchecked") Tresult=(T)e.value; returnresult; } } returnsetInitialValue(); }
get 方法首先獲取當前執行緒的 ThreadLocalMap 並判斷:
- 若 Map 已存在,從 Map 中取值
- 若 Map 不存在,或者 Map 中獲取的值為空,執行 setInitialValue 方法
- setInitialValue 方法:獲取/設定初始值
privateTsetInitialValue(){ //獲取初始值 Tvalue=initialValue(); Threadt=Thread.currentThread(); ThreadLocalMapmap=getMap(t); if(map!=null) map.set(this,value); else createMap(t,value); returnvalue; } protectedTinitialValue(){ returnnull; }
選取初始值,這個初始值預設為空(該方法是 protected,可以由子類初始化)。
- 若 Thread 的 ThreadLocalMap 已初始化,則將初始值存入 Map
- 否則,建立 ThreadLocalMap
- 返回初始值
除了初始值,其他邏輯跟 set 方法是一樣的,這裡不再贅述。
PS: 可以看到初始值是惰性初始化的。
- getEntry:從 Entry 陣列中獲取給定 key 對應的 Entry
privateEntrygetEntry(ThreadLocal<?>key){ //計算下標 inti=key.threadLocalHashCode&(table.length-1); Entrye=table[i]; //查詢命中 if(e!=null&&e.get()==key) returne; else returngetEntryAfterMiss(key,i,e); } //key未命中 privateEntrygetEntryAfterMiss(ThreadLocal<?>key,inti,Entrye){ Entry[]tab=table; intlen=tab.length; //遍歷陣列 while(e!=null){ ThreadLocal<?>k=e.get(); if(k==key) returne;//是要找的key,返回 if(k==null) expungeStaleEntry(i);//Entry已過期,清理Entry else i=nextIndex(i,len);//向後遍歷 e=tab[i]; } returnnull; }
3.4.3 remove 方法
- remove 方法:移除 ThreadLocal 對應的 Entry
publicvoidremove(){ ThreadLocalMapm=getMap(Thread.currentThread()); if(m!=null) m.remove(this); }
這裡呼叫了 ThreadLocalMap 的 remove 方法:
privatevoidremove(ThreadLocal<?>key){ Entry[]tab=table; intlen=tab.length; inti=key.threadLocalHashCode&(len-1); for(Entrye=tab[i]; e!=null; e=tab[i=nextIndex(i,len)]){ if(e.get()==key){ e.clear(); expungeStaleEntry(i); return; } } }
其中 e.clear 呼叫的是 Entry 的父類 Reference 的 clear 方法:
publicvoidclear(){ 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 方法。