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