面試官:小夥子,聽說你看過ThreadLocal原始碼?(萬字圖文深度解析ThreadLocal)
前言
(高清無損原圖.pdf關注公眾號後回覆 ThreadLocal
獲取,文末有公眾號連結)
前幾天寫了一篇AQS
相關的文章:我畫了35張圖就是為了讓你深入 AQS,反響不錯,還上了部落格園首頁編輯推薦,有生之年系列呀,哈哈。
這次趁熱打鐵再寫一篇ThreadLocal
的文章,同樣是深入原理,圖文並茂。
全文共10000+字,31張圖,這篇文章同樣耗費了不少的時間和精力才創作完成,原創不易,請大家點點關注+在看,感謝。
對於ThreadLocal
,大家的第一反應可能是很簡單呀,執行緒的變數副本,每個執行緒隔離。那這裡有幾個問題大家可以思考一下:
- ThreadLocal的key是弱引用,那麼在 threadLocal.get()的時候,發生GC之後,key是否為null?
- ThreadLocal中ThreadLocalMap的資料結構?
- ThreadLocalMap的Hash演算法?
- ThreadLocalMap中Hash衝突如何解決?
- ThreadLocalMap擴容機制?
- ThreadLocalMap中過期key的清理機制?探測式清理和啟發式清理流程?
- ThreadLocalMap.set()方法實現原理?
- ThreadLocalMap.get()方法實現原理?
- 專案中ThreadLocal使用情況?遇到的坑?
- ……
上述的一些問題你是否都已經掌握的很清楚了呢?本文將圍繞這些問題使用圖文方式來剖析ThreadLocal
的點點滴滴。
全文目錄
- ThreadLocal程式碼演示
- ThreadLocal的資料結構
- GC 之後key是否為null?
- ThreadLocal.set()方法原始碼詳解
- ThreadLocalMap Hash演算法
- ThreadLocalMap Hash衝突
- ThreadLocalMap.set()詳解
7.1 ThreadLocalMap.set()原理圖解
7.2 ThreadLocalMap.set()原始碼詳解 - ThreadLocalMap過期key的探測式清理流程
- ThreadLocalMap擴容機制
- ThreadLocalMap.get()詳解
10.1 ThreadLocalMap.get()圖解 - ThreadLocalMap過期key的啟發式清理流程
- InheritableThreadLocal
- ThreadLocal專案中使用實戰
13.1 ThreadLocal使用場景
13.2 分散式TraceId解決方案
註明: 本文原始碼基於JDK 1.8
ThreadLocal程式碼演示
我們先看下ThreadLocal
使用示例:
public class ThreadLocalTest {
private List<String> messages = Lists.newArrayList();
public static final ThreadLocal<ThreadLocalTest> holder = ThreadLocal.withInitial(ThreadLocalTest::new);
public static void add(String message) {
holder.get().messages.add(message);
}
public static List<String> clear() {
List<String> messages = holder.get().messages;
holder.remove();
System.out.println("size: " + holder.get().messages.size());
return messages;
}
public static void main(String[] args) {
ThreadLocalTest.add("一枝花算不算浪漫");
System.out.println(holder.get().messages);
ThreadLocalTest.clear();
}
}
列印結果:
[一枝花算不算浪漫]
size: 0
ThreadLocal
物件可以提供執行緒區域性變數,每個執行緒Thread
擁有一份自己的副本變數,多個執行緒互不干擾。
ThreadLocal的資料結構
Thread
類有一個型別為ThreadLocal.ThreadLocalMap
的例項變數threadLocals
,也就是說每個執行緒有一個自己的ThreadLocalMap
。
ThreadLocalMap
有自己的獨立實現,可以簡單地將它的key
視作ThreadLocal
,value
為程式碼中放入的值(實際上key
並不是ThreadLocal
本身,而是它的一個弱引用)。
每個執行緒在往ThreadLocal
裡放值的時候,都會往自己的ThreadLocalMap
裡存,讀也是以ThreadLocal
作為引用,在自己的map
裡找對應的key
,從而實現了執行緒隔離。
ThreadLocalMap
有點類似HashMap
的結構,只是HashMap
是由陣列+連結串列實現的,而ThreadLocalMap
中並沒有連結串列結構。
我們還要注意Entry
, 它的key
是ThreadLocal<?> k
,繼承自WeakReference
, 也就是我們常說的弱引用型別。
GC 之後key是否為null?
迴應開頭的那個問題, ThreadLocal
的key
是弱引用,那麼在threadLocal.get()
的時候,發生GC
之後,key
是否是null
?
為了搞清楚這個問題,我們需要搞清楚Java
的四種引用型別:
- 強引用:我們常常new出來的物件就是強引用型別,只要強引用存在,垃圾回收器將永遠不會回收被引用的物件,哪怕記憶體不足的時候
- 軟引用:使用SoftReference修飾的物件被稱為軟引用,軟引用指向的物件在記憶體要溢位的時候被回收
- 弱引用:使用WeakReference修飾的物件被稱為弱引用,只要發生垃圾回收,若這個物件只被弱引用指向,那麼就會被回收
- 虛引用:虛引用是最弱的引用,在 Java 中使用 PhantomReference 進行定義。虛引用中唯一的作用就是用佇列接收物件即將死亡的通知
接著再來看下程式碼,我們使用反射的方式來看看GC
後ThreadLocal
中的資料情況:
public class ThreadLocalDemo {
public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException, InterruptedException {
Thread t = new Thread(()->test("abc",false));
t.start();
t.join();
System.out.println("--gc後--");
Thread t2 = new Thread(() -> test("def", true));
t2.start();
t2.join();
}
private static void test(String s,boolean isGC) {
try {
new ThreadLocal<>().set(s);
if (isGC) {
System.gc();
}
Thread t = Thread.currentThread();
Class<? extends Thread> clz = t.getClass();
Field field = clz.getDeclaredField("threadLocals");
field.setAccessible(true);
Object threadLocalMap = field.get(t);
Class<?> tlmClass = threadLocalMap.getClass();
Field tableField = tlmClass.getDeclaredField("table");
tableField.setAccessible(true);
Object[] arr = (Object[]) tableField.get(threadLocalMap);
for (Object o : arr) {
if (o != null) {
Class<?> entryClass = o.getClass();
Field valueField = entryClass.getDeclaredField("value");
Field referenceField = entryClass.getSuperclass().getSuperclass().getDeclaredField("referent");
valueField.setAccessible(true);
referenceField.setAccessible(true);
System.out.println(String.format("弱引用key:%s,值:%s", referenceField.get(o), valueField.get(o)));
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
結果如下:
弱引用key:java.lang.ThreadLocal@433619b6,值:abc
弱引用key:java.lang.ThreadLocal@418a15e3,值:java.lang.ref.SoftReference@bf97a12
--gc後--
弱引用key:null,值:def
如圖所示,因為這裡建立的ThreadLocal
並沒有指向任何值,也就是沒有任何引用:
new ThreadLocal<>().set(s);
所以這裡在GC
之後,key
就會被回收,我們看到上面debug
中的referent=null
, 如果改動一下程式碼:
這個問題剛開始看,如果沒有過多思考,弱引用,還有垃圾回收,那麼肯定會覺得是null
。
其實是不對的,因為題目說的是在做 threadlocal.get()
操作,證明其實還是有強引用存在的,所以 key
並不為 null
,如下圖所示,ThreadLocal
的強引用仍然是存在的。
如果我們的強引用不存在的話,那麼 key
就會被回收,也就是會出現我們 value
沒被回收,key
被回收,導致 value
永遠存在,出現記憶體洩漏。
ThreadLocal.set()方法原始碼詳解
ThreadLocal
中的set
方法原理如上圖所示,很簡單,主要是判斷ThreadLocalMap
是否存在,然後使用ThreadLocal
中的set
方法進行資料處理。
程式碼如下:
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
主要的核心邏輯還是在ThreadLocalMap
中的,一步步往下看,後面還有更詳細的剖析。
ThreadLocalMap Hash演算法
既然是Map
結構,那麼ThreadLocalMap
當然也要實現自己的hash
演算法來解決散列表陣列衝突問題。
int i = key.threadLocalHashCode & (len-1);
ThreadLocalMap
中hash
演算法很簡單,這裡i
就是當前key在散列表中對應的陣列下標位置。
這裡最關鍵的就是threadLocalHashCode
值的計算,ThreadLocal
中有一個屬性為HASH_INCREMENT = 0x61c88647
public class ThreadLocal<T> {
private final int threadLocalHashCode = nextHashCode();
private static AtomicInteger nextHashCode = new AtomicInteger();
private static final int HASH_INCREMENT = 0x61c88647;
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
static class ThreadLocalMap {
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);
}
}
}
每當建立一個ThreadLocal
物件,這個ThreadLocal.nextHashCode
這個值就會增長 0x61c88647
。
這個值很特殊,它是斐波那契數 也叫 黃金分割數。hash
增量為 這個數字,帶來的好處就是 hash
分佈非常均勻。
我們自己可以嘗試下:
可以看到產生的雜湊碼分佈很均勻,這裡不去細糾斐波那契具體演算法,感興趣的可以自行查閱相關資料。
ThreadLocalMap Hash衝突
註明: 下面所有示例圖中,綠色塊
Entry
代表正常資料,灰色塊代表Entry
的key
值為null
,已被垃圾回收。白色塊表示Entry
為null
。
雖然ThreadLocalMap
中使用了黃金分隔數來作為hash
計算因子,大大減少了Hash
衝突的概率,但是仍然會存在衝突。
HashMap
中解決衝突的方法是在陣列上構造一個連結串列結構,衝突的資料掛載到連結串列上,如果連結串列長度超過一定數量則會轉化成紅黑樹。
而ThreadLocalMap
中並沒有連結串列結構,所以這裡不能適用HashMap
解決衝突的方式了。
如上圖所示,如果我們插入一個value=27
的資料,通過hash
計算後應該落入第4個槽位中,而槽位4已經有了Entry
資料。
此時就會線性向後查詢,一直找到Entry
為null
的槽位才會停止查詢,將當前元素放入此槽位中。當然迭代過程中還有其他的情況,比如遇到了Entry
不為null
且key
值相等的情況,還有Entry
中的key
值為null
的情況等等都會有不同的處理,後面會一一詳細講解。
這裡還畫了一個Entry
中的key
為null
的資料(Entry=2的灰色塊資料),因為key
值是弱引用型別,所以會有這種資料存在。在set
過程中,如果遇到了key
過期的Entry
資料,實際上是會進行一輪探測式清理操作的,具體操作方式後面會講到。
ThreadLocalMap.set()詳解
ThreadLocalMap.set()原理圖解
看完了ThreadLocal
hash演算法後,我們再來看set
是如何實現的。
往ThreadLocalMap
中set
資料(新增或者更新資料)分為好幾種情況,針對不同的情況我們畫圖來說說明。
第一種情況: 通過hash
計算後的槽位對應的Entry
資料為空:
這裡直接將資料放到該槽位即可。
第二種情況: 槽位資料不為空,key
值與當前ThreadLocal
通過hash
計算獲取的key
值一致:
這裡直接更新該槽位的資料。
第三種情況: 槽位資料不為空,往後遍歷過程中,在找到Entry
為null
的槽位之前,沒有遇到key
過期的Entry
:
遍歷雜湊陣列,線性往後查詢,如果找到Entry
為null
的槽位,則將資料放入該槽位中,或者往後遍歷過程中,遇到了key值相等的資料,直接更新即可。
第四種情況: 槽位資料不為空,往後遍歷過程中,在找到Entry
為null
的槽位之前,遇到key
過期的Entry
,如下圖,往後遍歷過程中,一到了index=7
的槽位資料Entry
的key=null
:
雜湊陣列下標為7位置對應的Entry
資料key
為null
,表明此資料key
值已經被垃圾回收掉了,此時就會執行replaceStaleEntry()
方法,該方法含義是替換過期資料的邏輯,以index=7位起點開始遍歷,進行探測式資料清理工作。
初始化探測式清理過期資料掃描的開始位置:slotToExpunge = staleSlot = 7
以當前staleSlot
開始 向前迭代查詢,找其他過期的資料,然後更新過期資料起始掃描下標slotToExpunge
。for
迴圈迭代,直到碰到Entry
為null
結束。
如果找到了過期的資料,繼續向前迭代,直到遇到Entry=null
的槽位才停止迭代,如下圖所示,slotToExpunge被更新為0:
以當前節點(index=7
)向前迭代,檢測是否有過期的Entry
資料,如果有則更新slotToExpunge
值。碰到null
則結束探測。以上圖為例slotToExpunge
被更新為0。
上面向前迭代的操作是為了更新探測清理過期資料的起始下標slotToExpunge
的值,這個值在後面會講解,它是用來判斷當前過期槽位staleSlot
之前是否還有過期元素。
接著開始以staleSlot
位置(index=7)向後迭代,如果找到了相同key值的Entry資料:
從當前節點staleSlot
向後查詢key
值相等的Entry
元素,找到後更新Entry
的值並交換staleSlot
元素的位置(staleSlot
位置為過期元素),更新Entry
資料,然後開始進行過期Entry
的清理工作,如下圖所示:
向後遍歷過程中,如果沒有找到相同key值的Entry資料:
從當前節點staleSlot
向後查詢key
值相等的Entry
元素,直到Entry
為null
則停止尋找。通過上圖可知,此時table
中沒有key
值相同的Entry
。
建立新的Entry
,替換table[stableSlot]
位置:
替換完成後也是進行過期元素清理工作,清理工作主要是有兩個方法:expungeStaleEntry()
和cleanSomeSlots()
,具體細節後面會講到,請繼續往後看。
ThreadLocalMap.set()原始碼詳解
上面已經用圖的方式解析了set()
實現的原理,其實已經很清晰了,我們接著再看下原始碼:
java.lang.ThreadLocal.ThreadLocalMap.set()
:
private void set(ThreadLocal<?> key, Object value) {
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)]) {
ThreadLocal<?> k = e.get();
if (k == key) {
e.value = value;
return;
}
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
這裡會通過key
來計算在散列表中的對應位置,然後以當前key
對應的桶的位置向後查詢,找到可以使用的桶。
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
什麼情況下桶才是可以使用的呢?
k = key
說明是替換操作,可以使用- 碰到一個過期的桶,執行替換邏輯,佔用過期桶
- 查詢過程中,碰到桶中
Entry=null
的情況,直接使用
接著就是執行for
迴圈遍歷,向後查詢,我們先看下nextIndex()
、prevIndex()
方法實現:
private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}
private static int prevIndex(int i, int len) {
return ((i - 1 >= 0) ? i - 1 : len - 1);
}
接著看剩下for
迴圈中的邏輯:
- 遍歷當前
key
值對應的桶中Entry
資料為空,這說明雜湊陣列這裡沒有資料衝突,跳出for
迴圈,直接set
資料到對應的桶中 - 如果
key
值對應的桶中Entry
資料不為空
2.1 如果k = key
,說明當前set
操作是一個替換操作,做替換邏輯,直接返回
2.2 如果key = null
,說明當前桶位置的Entry
是過期資料,執行replaceStaleEntry()
方法(核心方法),然後返回 for
迴圈執行完畢,繼續往下執行說明向後迭代的過程中遇到了entry
為null
的情況
3.1 在Entry
為null
的桶中建立一個新的Entry
物件
3.2 執行++size
操作- 呼叫
cleanSomeSlots()
做一次啟發式清理工作,清理雜湊陣列中Entry
的key
過期的資料
4.1 如果清理工作完成後,未清理到任何資料,且size
超過了閾值(陣列長度的2/3),進行rehash()
操作
4.2rehash()
中會先進行一輪探測式清理,清理過期key
,清理完成後如果size >= threshold - threshold / 4,就會執行真正的擴容邏輯(擴容邏輯往後看)
接著重點看下replaceStaleEntry()
方法,replaceStaleEntry()
方法提供替換過期資料的功能,我們可以對應上面第四種情況的原理圖來再回顧下,具體程式碼如下:
java.lang.ThreadLocal.ThreadLocalMap.replaceStaleEntry()
:
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
int staleSlot) {
Entry[] tab = table;
int len = tab.length;
Entry e;
int slotToExpunge = staleSlot;
for (int i = prevIndex(staleSlot, len);
(e = tab[i]) != null;
i = prevIndex(i, len))
if (e.get() == null)
slotToExpunge = i;
for (int i = 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;
if (slotToExpunge == staleSlot)
slotToExpunge = i;
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
}
if (k == null && slotToExpunge == staleSlot)
slotToExpunge = i;
}
tab[staleSlot].value = null;
tab[staleSlot] = new Entry(key, value);
if (slotToExpunge != staleSlot)
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}
slotToExpunge
表示開始探測式清理過期資料的開始下標,預設從當前的staleSlot
開始。以當前的staleSlot
開始,向前迭代查詢,找到沒有過期的資料,for
迴圈一直碰到Entry
為null
才會結束。如果向前找到了過期資料,更新探測清理過期資料的開始下標為i,即slotToExpunge=i
for (int i = prevIndex(staleSlot, len);
(e = tab[i]) != null;
i = prevIndex(i, len)){
if (e.get() == null){
slotToExpunge = i;
}
}
接著開始從staleSlot
向後查詢,也是碰到Entry
為null
的桶結束。
如果迭代過程中,碰到k == key,這說明這裡是替換邏輯,替換新資料並且交換當前staleSlot
位置。如果slotToExpunge == staleSlot
,這說明replaceStaleEntry()
一開始向前查詢過期資料時並未找到過期的Entry
資料,接著向後查詢過程中也未發現過期資料,修改開始探測式清理過期資料的下標為當前迴圈的index,即slotToExpunge = i
。最後呼叫cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
進行啟發式過期資料清理。
if (k == key) {
e.value = value;
tab[i] = tab[staleSlot];
tab[staleSlot] = e;
if (slotToExpunge == staleSlot)
slotToExpunge = i;
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
}
cleanSomeSlots()
和expungeStaleEntry()
方法後面都會細講,這兩個是和清理相關的方法,一個是過期key
相關Entry
的啟發式清理(Heuristically scan
),另一個是過期key
相關Entry
的探測式清理。
如果k != key則會接著往下走,k == null
說明當前遍歷的Entry
是一個過期資料,slotToExpunge == staleSlot
說明,一開始的向前查詢資料並未找到過期的Entry
。如果條件成立,則更新slotToExpunge
為當前位置,這個前提是前驅節點掃描時未發現過期資料。
if (k == null && slotToExpunge == staleSlot)
slotToExpunge = i;
往後迭代的過程中如果沒有找到k == key
的資料,且碰到Entry
為null
的資料,則結束當前的迭代操作。此時說明這裡是一個新增的邏輯,將新的資料新增到table[staleSlot]
對應的slot
中。
tab[staleSlot].value = null;
tab[staleSlot] = new Entry(key, value);
最後判斷除了staleSlot
以外,還發現了其他過期的slot
資料,就要開啟清理資料的邏輯:
if (slotToExpunge != staleSlot)
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
ThreadLocalMap過期key的探測式清理流程
上面我們有提及ThreadLocalMap
的兩種過期key
資料清理方式:探測式清理和啟發式清理。
我們先講下探測式清理,也就是expungeStaleEntry
方法,遍歷雜湊陣列,從開始位置向後探測清理過期資料,將過期資料的Entry
設定為null
,沿途中碰到未過期的資料則將此資料rehash
後重新在table
陣列中定位,如果定位的位置已經有了資料,則會將未過期的資料放到最靠近此位置的Entry=null
的桶中,使rehash
後的Entry
資料距離正確的桶的位置更近一些。操作邏輯如下:
如上圖,set(27)
經過hash計算後應該落到index=4
的桶中,由於index=4
桶已經有了資料,所以往後迭代最終資料放入到index=7
的桶中,放入後一段時間後index=5
中的Entry
資料key
變為了null
如果再有其他資料set
到map
中,就會觸發探測式清理操作。
如上圖,執行探測式清理後,index=5
的資料被清理掉,繼續往後迭代,到index=7
的元素時,經過rehash
後發現該元素正確的index=4
,而此位置已經已經有了資料,往後查詢離index=4
最近的Entry=null
的節點(剛被探測式清理掉的資料:index=5),找到後移動index= 7
的資料到index=5
中,此時桶的位置離正確的位置index=4
更近了。
經過一輪探測式清理後,key
過期的資料會被清理掉,沒過期的資料經過rehash
重定位後所處的桶位置理論上更接近i= key.hashCode & (tab.len - 1)
的位置。這種優化會提高整個散列表查詢效能。
接著看下expungeStaleEntry()
具體流程,我們還是以先原理圖後原始碼講解的方式來一步步梳理:
我們假設expungeStaleEntry(3)
來呼叫此方法,如上圖所示,我們可以看到ThreadLocalMap
中table
的資料情況,接著執行清理操作:
第一步是清空當前staleSlot
位置的資料,index=3
位置的Entry
變成了null
。然後接著往後探測:
執行完第二步後,index=4的元素挪到index=3的槽位中。
繼續往後迭代檢查,碰到正常資料,計算該資料位置是否偏移,如果被偏移,則重新計算slot
位置,目的是讓正常資料儘可能存放在正確位置或離正確位置更近的位置
在往後迭代的過程中碰到空的槽位,終止探測,這樣一輪探測式清理工作就完成了,接著我們繼續看看具體實現原始碼:
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;
Entry e;
int i;
for (i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null;
tab[i] = null;
size--;
} else {
int h = k.threadLocalHashCode & (len - 1);
if (h != i) {
tab[i] = null;
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
}
}
return i;
}
這裡我們還是以staleSlot=3
來做示例說明,首先是將tab[staleSlot]
槽位的資料清空,然後設定size--
接著以staleSlot
位置往後迭代,如果遇到k==null
的過期資料,也是清空該槽位資料,然後size--
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null;
tab[i] = null;
size--;
}
如果key
沒有過期,重新計算當前key
的下標位置是不是當前槽位下標位置,如果不是,那麼說明產生了hash
衝突,此時以新計算出來正確的槽位位置往後迭代,找到最近一個可以存放entry
的位置。
int h = k.threadLocalHashCode & (len - 1);
if (h != i) {
tab[i] = null;
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
這裡是處理正常的產生Hash
衝突的資料,經過迭代後,有過Hash
衝突資料的Entry
位置會更靠近正確位置,這樣的話,查詢的時候 效率才會更高。
ThreadLocalMap擴容機制
在ThreadLocalMap.set()
方法的最後,如果執行完啟發式清理工作後,未清理到任何資料,且當前雜湊陣列中Entry
的數量已經達到了列表的擴容閾值(len*2/3)
,就開始執行rehash()
邏輯:
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
接著看下rehash()
具體實現:
private void rehash() {
expungeStaleEntries();
if (size >= threshold - threshold / 4)
resize();
}
private void expungeStaleEntries() {
Entry[] tab = table;
int len = tab.length;
for (int j = 0; j < len; j++) {
Entry e = tab[j];
if (e != null && e.get() == null)
expungeStaleEntry(j);
}
}
這裡首先是會進行探測式清理工作,從table
的起始位置往後清理,上面有分析清理的詳細流程。清理完成之後,table
中可能有一些key
為null
的Entry
資料被清理掉,所以此時通過判斷size >= threshold - threshold / 4
也就是size >= threshold* 3/4
來決定是否擴容。
我們還記得上面進行rehash()
的閾值是size >= threshold
,所以當面試官套路我們ThreadLocalMap
擴容機制的時候 我們一定要說清楚這兩個步驟:
接著看看具體的resize()
方法,為了方便演示,我們以oldTab.len=8
來舉例:
擴容後的tab
的大小為oldLen * 2
,然後遍歷老的散列表,重新計算hash
位置,然後放到新的tab
陣列中,如果出現hash
衝突則往後尋找最近的entry
為null
的槽位,遍歷完成之後,oldTab
中所有的entry
資料都已經放入到新的tab
中了。重新計算tab
下次擴容的閾值,具體程式碼如下:
private void resize() {
Entry[] oldTab = table;
int oldLen = oldTab.length;
int newLen = oldLen * 2;
Entry[] newTab = new Entry[newLen];
int count = 0;
for (int j = 0; j < oldLen; ++j) {
Entry e = oldTab[j];
if (e != null) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null;
} else {
int h = k.threadLocalHashCode & (newLen - 1);
while (newTab[h] != null)
h = nextIndex(h, newLen);
newTab[h] = e;
count++;
}
}
}
setThreshold(newLen);
size = count;
table = newTab;
}
ThreadLocalMap.get()詳解
上面已經看完了set()
方法的原始碼,其中包括set
資料、清理資料、優化資料桶的位置等操作,接著看看get()
操作的原理。
ThreadLocalMap.get()圖解
第一種情況: 通過查詢key
值計算出散列表中slot
位置,然後該slot
位置中的Entry.key
和查詢的key
一致,則直接返回:
第二種情況: slot
位置中的Entry.key
和要查詢的key
不一致:
我們以get(ThreadLocal1)
為例,通過hash
計算後,正確的slot
位置應該是4,而index=4
的槽位已經有了資料,且key
值不等於ThreadLocal1
,所以需要繼續往後迭代查詢。
迭代到index=5
的資料時,此時Entry.key=null
,觸發一次探測式資料回收操作,執行expungeStaleEntry()
方法,執行完後,index 5,8
的資料都會被回收,而index 6,7
的資料都會前移,此時繼續往後迭代,到index = 6
的時候即找到了key
值相等的Entry
資料,如下圖所示:
ThreadLocalMap.get()原始碼詳解
java.lang.ThreadLocal.ThreadLocalMap.getEntry()
:
private Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
if (e != null && e.get() == key)
return e;
else
return getEntryAfterMiss(key, i, e);
}
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;
while (e != null) {
ThreadLocal<?> k = e.get();
if (k == key)
return e;
if (k == null)
expungeStaleEntry(i);
else
i = nextIndex(i, len);
e = tab[i];
}
return null;
}
ThreadLocalMap過期key的啟發式清理流程
上面多次提及到ThreadLocalMap
過期可以的兩種清理方式:探測式清理(expungeStaleEntry())、啟發式清理(cleanSomeSlots())
探測式清理是以當前Entry
往後清理,遇到值為null
則結束清理,屬於線性探測清理。
而啟發式清理被作者定義為:Heuristically scan some cells looking for stale entries.
具體程式碼如下:
private boolean cleanSomeSlots(int i, int n) {
boolean removed = false;
Entry[] tab = table;
int len = tab.length;
do {
i = nextIndex(i, len);
Entry e = tab[i];
if (e != null && e.get() == null) {
n = len;
removed = true;
i = expungeStaleEntry(i);
}
} while ( (n >>>= 1) != 0);
return removed;
}
InheritableThreadLocal
我們使用ThreadLocal
的時候,在非同步場景下是無法給子執行緒共享父執行緒中建立的執行緒副本資料的。
為了解決這個問題,JDK中還有一個InheritableThreadLocal
類,我們來看一個例子:
public class InheritableThreadLocalDemo {
public static void main(String[] args) {
ThreadLocal<String> threadLocal = new ThreadLocal<>();
ThreadLocal<String> inheritableThreadLocal = new InheritableThreadLocal<>();
threadLocal.set("父類資料:threadLocal");
inheritableThreadLocal.set("父類資料:inheritableThreadLocal");
new Thread(new Runnable() {
@Override
public void run() {
System.out.println("子執行緒獲取父類threadLocal資料:" + threadLocal.get());
System.out.println("子執行緒獲取父類inheritableThreadLocal資料:" + inheritableThreadLocal.get());
}
}).start();
}
}
列印結果:
子執行緒獲取父類threadLocal資料:null
子執行緒獲取父類inheritableThreadLocal資料:父類資料:inheritableThreadLocal
實現原理是子執行緒是通過在父執行緒中通過呼叫new Thread()
方法來建立子執行緒,Thread#init
方法在Thread
的構造方法中被呼叫。在init
方法中拷貝父執行緒資料到子執行緒中:
private void init(ThreadGroup g, Runnable target, String name,
long stackSize, AccessControlContext acc,
boolean inheritThreadLocals) {
if (name == null) {
throw new NullPointerException("name cannot be null");
}
if (inheritThreadLocals && parent.inheritableThreadLocals != null)
this.inheritableThreadLocals =
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
this.stackSize = stackSize;
tid = nextThreadID();
}
但InheritableThreadLocal
仍然有缺陷,一般我們做非同步化處理都是使用的執行緒池,而InheritableThreadLocal
是在new Thread
中的init()
方法給賦值的,而執行緒池是執行緒複用的邏輯,所以這裡會存在問題。
當然,有問題出現就會有解決問題的方案,阿里巴巴開源了一個TransmittableThreadLocal
元件就可以解決這個問題,這裡就不再延伸,感興趣的可自行查閱資料。
ThreadLocal專案中使用實戰
ThreadLocal使用場景
我們現在專案中日誌記錄用的是ELK+Logstash
,最後在Kibana
中進行展示和檢索。
現在都是分散式系統統一對外提供服務,專案間呼叫的關係可以通過traceId來關聯,但是不同專案之間如何傳遞traceId
呢?
這裡我們使用org.slf4j.MDC
來實現此功能,內部就是通過ThreadLocal
來實現的,具體實現如下:
當前端傳送請求到服務A時,服務A會生成一個類似UUID
的traceId
字串,將此字串放入當前執行緒的ThreadLocal
中,在呼叫服務B的時候,將traceId
寫入到請求的Header
中,服務B在接收請求時會先判斷請求的Header
中是否有traceId
,如果存在則寫入自己執行緒的ThreadLocal
中。
圖中的requestId
即為我們各個系統鏈路關聯的traceId
,系統間互相呼叫,通過這個requestId
即可找到對應鏈路,這裡還有會有一些其他場景:
針對於這些場景,我們都可以有相應的解決方案,如下所示
Feign遠端呼叫解決方案
服務傳送請求:
@Component
@Slf4j
public class FeignInvokeInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
String requestId = MDC.get("requestId");
if (StringUtils.isNotBlank(requestId)) {
template.header("requestId", requestId);
}
}
}
服務接收請求:
@Slf4j
@Component
public class LogInterceptor extends HandlerInterceptorAdapter {
@Override
public void afterCompletion(HttpServletRequest arg0, HttpServletResponse arg1, Object arg2, Exception arg3) {
MDC.remove("requestId");
}
@Override
public void postHandle(HttpServletRequest arg0, HttpServletResponse arg1, Object arg2, ModelAndView arg3) {
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String requestId = request.getHeader(BaseConstant.REQUEST_ID_KEY);
if (StringUtils.isBlank(requestId)) {
requestId = UUID.randomUUID().toString().replace("-", "");
}
MDC.put("requestId", requestId);
return true;
}
}
執行緒池非同步呼叫,requestId傳遞
因為MDC
是基於ThreadLocal
去實現的,非同步過程中,子執行緒並沒有辦法獲取到父執行緒ThreadLocal
儲存的資料,所以這裡可以自定義執行緒池執行器,修改其中的run()
方法:
public class MyThreadPoolTaskExecutor extends ThreadPoolTaskExecutor {
@Override
public void execute(Runnable runnable) {
Map<String, String> context = MDC.getCopyOfContextMap();
super.execute(() -> run(runnable, context));
}
@Override
private void&nb