1. 程式人生 > 實用技巧 >從原始碼角度來剖析ThreadLocal到底有沒有記憶體洩漏?

從原始碼角度來剖析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 的分析。

二者都是「鍵-值對」構成的陣列,對雜湊衝突的處理方式不同,導致了它們在結構上產生了一些區別:

  1. HashMap 處理雜湊衝突使用的「連結串列法」。也就是當產生衝突時拉出一個連結串列,而且 JDK 1.8 進一步引入了紅黑樹進行優化。
  2. 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 計算它的陣列下標:

  1. 如果陣列下標的 Entry 不為空,表示該位置已經有元素。由於可能存在雜湊衝突,因此這個位置的元素可能並不是要找的元素,所以遍歷陣列去比較
    1. 如果找到等於當前 key 的 Entry,則用新值替換舊值,返回。
    2. 如果遍歷過程中,遇到 Entry 不為空、但是 Entry 的 key 為空的情況,則會做一些清理工作。
  2. 如果陣列下標的 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 的主要執行流程如下:

  1. 從 staleSlot 向前遍歷陣列,直到 Entry 為空時停止遍歷。這一步的主要目的是查詢 staleSlot 前面過期的 Entry 的陣列下標 slotToExpunge。
  2. 從 staleSlot 向後遍歷陣列
    1. 若 Entry 的 key 與給定的 key 相等,將該 Entry 與 staleSlot 下標的 Entry 互換位置。目的是為了讓新增的 Entry 放到它「應該」在的位置。
    2. 若找不到相等的 key,說明該 key 對應的 Entry 不在陣列中,將新值放到 staleSlot 位置。該操作其實就是處理雜湊衝突的「線性探測」方法:當某個位置已被佔用,向後探測下一個位置。
  3. 若 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;
}

該方法主要做了哪些工作呢?

  1. 清空給定位置的 Entry
  2. 從給定位置的下一個開始向後遍歷陣列
    1. 若遇到 Entry 為 null,結束遍歷
    2. 若遇到 key 為空的 Entry(即過期的),就將該 Entry 置空
    3. 若遇到 key 不為空的 Entry,而且經過計算,該 Entry 並不在它「應該」在的位置,則將其移動到它「應該」在的位置
  3. 返回 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 方法內部被呼叫的,也就是新增/更新時:

  1. 如果不掃描和清理,set 方法執行速度很快,但是會存在一些垃圾(過期的 Entry);
  2. 如果每次都掃描清理,不會存在垃圾,但是插入效能會降低到 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);
}
}

該方法主要作用:

  1. 清理陣列中過期的 Entry
  2. 若清理後 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 陣列擴容,主要流程:

  1. 建立一個新陣列,長度為原陣列的 2 倍;
  2. 從下標 0 開始遍歷舊陣列的所有元素
    1. 若元素已過期(key 為空),則將 value 也置空
    2. 將未過期的元素移到新陣列

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 並判斷:

  1. 若 Map 已存在,從 Map 中取值
  2. 若 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,可以由子類初始化)。

  1. 若 Thread 的 ThreadLocalMap 已初始化,則將初始值存入 Map
  2. 否則,建立 ThreadLocalMap
  3. 返回初始值

除了初始值,其他邏輯跟 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 方法的主要執行流程如下:

  1. 獲取當前執行緒的 ThreadLocalMap
  2. 以當前 ThreadLocal 作為 key,從 Map 中查詢相應的 Entry,將 Entry 的 key 置空
  3. 將該 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 的主要方法實現,並分析了它可能存在記憶體洩漏的場景。

  1. ThreadLocal 主要用於當前執行緒從共享變數中儲存一份「副本」,常用的一個場景就是單點登入儲存使用者的登入資訊。
  2. ThreadLocal 將資料儲存在 ThreadLocalMap 中,ThreadLocalMap 是由 Entry 構成的陣列,結構有點類似 HashMap。
  3. ThreadLocal 使用不當可能會造成記憶體洩漏。避免記憶體洩漏的方法是在方法呼叫結束前執行 ThreadLocal 的 remove 方法。