1. 程式人生 > >面試官:小夥子,聽說你看過ThreadLocal原始碼?(萬字圖文深度解析ThreadLocal)

面試官:小夥子,聽說你看過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的點點滴滴。

全文目錄

  1. ThreadLocal程式碼演示
  2. ThreadLocal的資料結構
  3. GC 之後key是否為null?
  4. ThreadLocal.set()方法原始碼詳解
  5. ThreadLocalMap Hash演算法
  6. ThreadLocalMap Hash衝突
  7. ThreadLocalMap.set()詳解
    7.1 ThreadLocalMap.set()原理圖解
    7.2 ThreadLocalMap.set()原始碼詳解
  8. ThreadLocalMap過期key的探測式清理流程
  9. ThreadLocalMap擴容機制
  10. ThreadLocalMap.get()詳解
    10.1 ThreadLocalMap.get()圖解
    10.2 ThreadLocalMap.get()原始碼詳解
  11. ThreadLocalMap過期key的啟發式清理流程
  12. InheritableThreadLocal
  13. 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視作ThreadLocalvalue為程式碼中放入的值(實際上key並不是ThreadLocal本身,而是它的一個弱引用)。

每個執行緒在往ThreadLocal裡放值的時候,都會往自己的ThreadLocalMap裡存,讀也是以ThreadLocal作為引用,在自己的map裡找對應的key,從而實現了執行緒隔離。

ThreadLocalMap有點類似HashMap的結構,只是HashMap是由陣列+連結串列實現的,而ThreadLocalMap中並沒有連結串列結構。

我們還要注意Entry, 它的keyThreadLocal<?> k ,繼承自WeakReference, 也就是我們常說的弱引用型別。

GC 之後key是否為null?

迴應開頭的那個問題, ThreadLocalkey是弱引用,那麼在threadLocal.get()的時候,發生GC之後,key是否是null

為了搞清楚這個問題,我們需要搞清楚Java的四種引用型別:

  • 強引用:我們常常new出來的物件就是強引用型別,只要強引用存在,垃圾回收器將永遠不會回收被引用的物件,哪怕記憶體不足的時候
  • 軟引用:使用SoftReference修飾的物件被稱為軟引用,軟引用指向的物件在記憶體要溢位的時候被回收
  • 弱引用:使用WeakReference修飾的物件被稱為弱引用,只要發生垃圾回收,若這個物件只被弱引用指向,那麼就會被回收
  • 虛引用:虛引用是最弱的引用,在 Java 中使用 PhantomReference 進行定義。虛引用中唯一的作用就是用佇列接收物件即將死亡的通知

接著再來看下程式碼,我們使用反射的方式來看看GCThreadLocal中的資料情況:

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);

ThreadLocalMaphash演算法很簡單,這裡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代表正常資料,灰色塊代表Entrykey值為null,已被垃圾回收。白色塊表示Entrynull

雖然ThreadLocalMap中使用了黃金分隔數來作為hash計算因子,大大減少了Hash衝突的概率,但是仍然會存在衝突。

HashMap中解決衝突的方法是在陣列上構造一個連結串列結構,衝突的資料掛載到連結串列上,如果連結串列長度超過一定數量則會轉化成紅黑樹。

ThreadLocalMap中並沒有連結串列結構,所以這裡不能適用HashMap解決衝突的方式了。

如上圖所示,如果我們插入一個value=27的資料,通過hash計算後應該落入第4個槽位中,而槽位4已經有了Entry資料。

此時就會線性向後查詢,一直找到Entrynull的槽位才會停止查詢,將當前元素放入此槽位中。當然迭代過程中還有其他的情況,比如遇到了Entry不為nullkey值相等的情況,還有Entry中的key值為null的情況等等都會有不同的處理,後面會一一詳細講解。

這裡還畫了一個Entry中的keynull的資料(Entry=2的灰色塊資料),因為key值是弱引用型別,所以會有這種資料存在。在set過程中,如果遇到了key過期的Entry資料,實際上是會進行一輪探測式清理操作的,具體操作方式後面會講到。

ThreadLocalMap.set()詳解

ThreadLocalMap.set()原理圖解

看完了ThreadLocal hash演算法後,我們再來看set是如何實現的。

ThreadLocalMapset資料(新增或者更新資料)分為好幾種情況,針對不同的情況我們畫圖來說說明。

第一種情況: 通過hash計算後的槽位對應的Entry資料為空:

這裡直接將資料放到該槽位即可。

第二種情況: 槽位資料不為空,key值與當前ThreadLocal通過hash計算獲取的key值一致:

這裡直接更新該槽位的資料。

第三種情況: 槽位資料不為空,往後遍歷過程中,在找到Entrynull的槽位之前,沒有遇到key過期的Entry

遍歷雜湊陣列,線性往後查詢,如果找到Entrynull的槽位,則將資料放入該槽位中,或者往後遍歷過程中,遇到了key值相等的資料,直接更新即可。

第四種情況: 槽位資料不為空,往後遍歷過程中,在找到Entrynull的槽位之前,遇到key過期的Entry,如下圖,往後遍歷過程中,一到了index=7的槽位資料Entrykey=null

雜湊陣列下標為7位置對應的Entry資料keynull,表明此資料key值已經被垃圾回收掉了,此時就會執行replaceStaleEntry()方法,該方法含義是替換過期資料的邏輯,以index=7位起點開始遍歷,進行探測式資料清理工作。

初始化探測式清理過期資料掃描的開始位置:slotToExpunge = staleSlot = 7

以當前staleSlot開始 向前迭代查詢,找其他過期的資料,然後更新過期資料起始掃描下標slotToExpungefor迴圈迭代,直到碰到Entrynull結束。

如果找到了過期的資料,繼續向前迭代,直到遇到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元素,直到Entrynull則停止尋找。通過上圖可知,此時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);

什麼情況下桶才是可以使用的呢?

  1. k = key 說明是替換操作,可以使用
  2. 碰到一個過期的桶,執行替換邏輯,佔用過期桶
  3. 查詢過程中,碰到桶中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迴圈中的邏輯:

  1. 遍歷當前key值對應的桶中Entry資料為空,這說明雜湊陣列這裡沒有資料衝突,跳出for迴圈,直接set資料到對應的桶中
  2. 如果key值對應的桶中Entry資料不為空
    2.1 如果k = key,說明當前set操作是一個替換操作,做替換邏輯,直接返回
    2.2 如果key = null,說明當前桶位置的Entry是過期資料,執行replaceStaleEntry()方法(核心方法),然後返回
  3. for迴圈執行完畢,繼續往下執行說明向後迭代的過程中遇到了entrynull的情況
    3.1 在Entrynull的桶中建立一個新的Entry物件
    3.2 執行++size操作
  4. 呼叫cleanSomeSlots()做一次啟發式清理工作,清理雜湊陣列中Entrykey過期的資料
    4.1 如果清理工作完成後,未清理到任何資料,且size超過了閾值(陣列長度的2/3),進行rehash()操作
    4.2 rehash()中會先進行一輪探測式清理,清理過期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迴圈一直碰到Entrynull才會結束。如果向前找到了過期資料,更新探測清理過期資料的開始下標為i,即slotToExpunge=i

for (int i = prevIndex(staleSlot, len);
     (e = tab[i]) != null;
     i = prevIndex(i, len)){

    if (e.get() == null){
        slotToExpunge = i;
    }
}

接著開始從staleSlot向後查詢,也是碰到Entrynull的桶結束。
如果迭代過程中,碰到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的資料,且碰到Entrynull的資料,則結束當前的迭代操作。此時說明這裡是一個新增的邏輯,將新的資料新增到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

如果再有其他資料setmap中,就會觸發探測式清理操作。

如上圖,執行探測式清理後,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) 來呼叫此方法,如上圖所示,我們可以看到ThreadLocalMaptable的資料情況,接著執行清理操作:

第一步是清空當前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中可能有一些keynullEntry資料被清理掉,所以此時通過判斷size >= threshold - threshold / 4 也就是size >= threshold* 3/4 來決定是否擴容。

我們還記得上面進行rehash()的閾值是size >= threshold,所以當面試官套路我們ThreadLocalMap擴容機制的時候 我們一定要說清楚這兩個步驟:

接著看看具體的resize()方法,為了方便演示,我們以oldTab.len=8來舉例:

擴容後的tab的大小為oldLen * 2,然後遍歷老的散列表,重新計算hash位置,然後放到新的tab陣列中,如果出現hash衝突則往後尋找最近的entrynull的槽位,遍歷完成之後,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會生成一個類似UUIDtraceId字串,將此字串放入當前執行緒的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