Android進階:ThreadLocal

語言: CN / TW / HK

一、什麼是ThreadLocal

ThreadLocal用於儲存執行緒全域性變數,以方便呼叫。即,當前執行緒獨有,不與其他執行緒共享;可在當前執行緒任何地方獲取到該變數。

二、ThreadLocal的使用

1、如何儲存內容

ThreadLocal例項,並呼叫set函式,儲存中國字串,分別在當前執行緒和new-thread執行緒獲取該值。通過列印結果可以看到,雖然引用的是同個物件,但new-thread執行緒獲取到的值卻是null

use

執行結果:

main 中國
MainActivity: new-thread null
複製程式碼

這是什麼情況呢?

ThreadLocalset函式中,獲取當前執行緒的ThreadLocalMap例項,如何當前執行緒第一次使用ThreadLocal,則需要建立ThreadLocalMap例項,否則直接通過ThreadLocalMap例項的set函式進行儲存。

set

2、如何獲取內容

由於main執行緒前面set函式將內容儲存到ThreadLocalMap例項中,已經可以獲取到中國字串。而在new-thread執行緒中,由於是第一次使用ThreadLocalMap,所以此時mapnull,並呼叫setInitialValue函式。

get

setInitialValue函式中,呼叫了initialValue函式,該函式直接返回了null,這就是為什麼在new-thread執行緒獲取的值是null。因此setInitialValue函式主要為當前執行緒建立ThreadLocalMap物件。

setInitialValue

3、ThreadLocalMap

ThreadLocalMap內部持有一個數組table,用於儲存Entry元素。Entry繼承至WeakReference,並以ThreadLcoal例項作為key,和儲存內容 T作為value。當發生GC時,key就會被回收,從而導致該Entry過期。

Entry

每一個執行緒都持有一個ThreadLocalMap區域性變數threadLocas,如下圖所示。

image-20210118113312896

3.1 ThreadLocalMap的建立

ThreadLocalMap物件的建立,也就是ThreadLocal 物件呼叫了自身的createMap函式。

createMap

ThreadLocalMap的建構函式,建立了一個儲存Entry物件的table陣列,預設大小16。並通過threadLocalthreadLocalHashCode屬性計算出Entry在陣列的小標,進行儲存,並計算出閾值INITIAL_CAPACITY的2/3。

threadLocalHashCode屬性在ThreaLocal物件建立時會自動計算得出.

threadLocalHashCode

threadLocalHashCode作為ThreadLocal的唯一例項變數,在不同的例項中是不同的,通過nextHashCode.getAndAdd已經定義了下一個ThreadLcoal的例項的threadLocalHashCode值,而第一個ThreadLocal的threadLocalHashCode值則是從0開始,與下一個threadLocalHashCode間隔HASH_INCREMENT

通過threadLocalHashCode & (len-1)計算出來的陣列下標,分發很均勻,減少衝突。但是呢,衝突時還是會出現,如果發生衝突,則將新增的Entry放到後側entry=null的地方。

三、原始碼分析

1、ThreadLocalMap的set函式

在上一節中,分析了ThreadLocal例項的set函式,最終是呼叫了ThreadLocalMap例項的set函式進行儲存。

mapset

通過程式碼分析可知,ThreadLocalMapset函式主要分為三個主要步驟:

  1. 計算出當前ThreadLocaltable陣列的位置,然後向後遍歷,直到遍歷到的Entrynull則停止,遍歷到Entrykey與當前threadLocal例項的相等,直接更替value;

  2. 如果遍歷到Entry已過期(Entrykeynull),則呼叫replaceStaleEntry函式進行替換。

  3. 在遍歷結束後,未出現1和2兩種情況,則直接建立新的Entry,儲存到陣列最後側沒有Entry的位置。

在第2步驟和最後都會清理過期的Entry,這個稍後分析,先看看第2步驟,在檢測到過期的Entry,會呼叫replaceStaleEntry函式進行替換。

replaceStaleEntry

replaceStaleEntry函式,主要分為兩次遍歷,以當前過期的Entry為分割線,一次向前遍歷,一次向後遍歷。

在向前遍歷過程,如果發現有過期的Entry,則保留其位置slotToExpunge,直到有Entrynull為止。這裡只是判斷staleSlot前方是否有過期的Entry,然後方便後面進行清理。

在向後遍歷過程,如果發現有key相同的Entry,直接與staleSlot位置的Entry交換value(上圖註釋有問題)。如果沒有碰到相同的key,則建立新的Entry儲存到staleSlot位置。與此同時,如果向前遍歷沒有發現過期Entry,而在向後遍歷發現過期的ntry,則需要更新過期位置slotToExpunge,因為後面的清除內容是需要slotToExpunge

2、ThreadLocalMap清除過期Entry

在上一小節中,會通過expungeStaleEntry函式和cleanSomeSlots函式清理過期的Entry,它們又是如何實現呢?

expungeStaleEntry函式清理過期Entry過程被稱為:探測式清理。函式傳遞進來的引數是過期的Entry的位置,工作過程是先將該位置置為null,然後遍歷陣列後側所有位置的Entry,如果遍歷到有Entry過期,則直接置null,否則將它移到合適的位置:hash計算出來的位置或離該hash位置最近的位置。

expungeStaleEntry

經過這麼一次經歷,staleSlot位置到後側最近entry=null的位置就不存在過期的entry,而每個entry要麼在原有hash位置,要麼離原有hash位置最近。

expungeStaleEntry函式的工作範圍:

expungeStaleEntry (1)

expungeStaleEntry函式一開始會將起點,即陣列第3的位置設定為null。然後開始遍歷陣列後側元素,4和5位置無論是否在它的hash位置,在這裡都保持不變。遍歷到第6時,發現entry已過期,將第6設定為null。此時3和6位置變成白色了。

image-20210116175944199

A、遍歷到第7的時候,假設h != i成立,那麼第7位置的entry將被移到第6位置,空出第7位置。

image-20210116180006575

B、接著遍歷到第8位置,假設h != i不成立,則第8的entry的位置不變。

接著繼續遍歷後側元素,重複著A和B步驟,直到碰到entry為null,退出遍歷。例如這裡的第10位置,entry=null。

由於探測性清理,碰到entry=null的情況就會結束。而通過cleanSomeSlots函式進行啟發式清理,碰到entry=null不停止,而是由控制條件n決定,而在這個過程中,碰到過期entry,n又恢復到陣列長度,加大清理範圍。

clean

在啟發式清理過程,如果碰到過期Entry,會導致控制條件n恢復到陣列長度len,從而導致迴圈次數增加,則往後nextIndex次數增加,從而增加清理範圍。這種方式也不一定能完整清理後面所有過期元素,例如在控制n右移所有過程中,沒有碰到過期的entry,就結束了。

3、ThreadLocalMap的擴容機制

在第1節,呼叫ThreadLocalMapset函式最後,會呼叫reHash函式進行擴容。

rehash

在外層進行啟發式清理後,如果size>threshold則會進行rehash,而在rehash中,會清理整個陣列的過期Entry,如果清理後,陣列長度還大於3/4*threshod,則進行擴容resize

resize

resize函式直接建立新的陣列,長度為舊陣列的兩倍。然後重新計算舊陣列元素在新陣列的位置,複製。

四、記憶體洩露

正常情況下,用完ThreadLocal例項,將其置為null,在發生GC時,ThreadLocal物件就會被回收。但是此時如果執行緒還存活(例如執行緒池執行緒的複用),就會導致Entry的value物件得不到釋放,會造成記憶體洩露。所以,在使用完ThreadLocal例項後,呼叫remove函式清除一下。

疑惑

發生GC的時候,Key會被回收麼,還能獲取到值麼?

正常情況下,如果ThreadLocal例項同時被強引用,所以在發生GC的時候,是不會回收的,也就是此時WeakReference.get是有返回值的,不會被回收。

gc

推薦閱讀:Java引用與ThreadLocal