面試官:小夥子,聽說你看過 ThreadLocal 源碼?(萬字圖文深度解析)

公衆號後臺回覆“面試”,獲取精品學習資料

掃描下方海報瞭解專欄詳情

本文來源公衆號投稿:壹枝花算不算浪漫

《Java工程師面試突擊(第3季)》重磅升級,由原來的70講增至160講,內容擴充一倍多,升級部分內容請參見文末

首先貼出一張圖:


前幾天寫了一篇AQS相關的文章:我畫了35張圖就是爲了讓你深入 AQS,反響不錯,還上了博客園首頁編輯推薦,有生之年系列呀,哈哈。

image.png

這次趁熱打鐵再寫一篇ThreadLocal的文章,同樣是深入原理,圖文並茂。

全文共10000+字,31張圖,這篇文章同樣耗費了不少的時間和精力才創作完成,原創不易,請大家多多關注我的公衆號壹枝花算不算浪漫,感謝。

對於ThreadLocal,大家的第一反應可能是很簡單呀,線程的變量副本,每個線程隔離。那這裏有幾個問題大家可以思考一下:

  • ThreadLocal的key是弱引用,那麼在 threadLocal.get()的時候,發生GC之後,key是否爲null

  • ThreadLocalThreadLocalMap數據結構

  • ThreadLocalMapHash算法

  • ThreadLocalMapHash衝突如何解決?

  • 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的數據結構

image.png

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?

迴應開頭的那個問題, ThreadLocal 的key是弱引用,那麼在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
image.png

如圖所示,因爲這裏創建的ThreadLocal並沒有指向任何值,也就是沒有任何引用:

new ThreadLocal<>().set(s);

所以這裏在GC之後,key就會被回收,我們看到上面debug中的referent=null, 如果改動一下代碼:

image.png

這個問題剛開始看,如果沒有過多思考,弱引用,還有垃圾回收,那麼肯定會覺得是null

其實是不對的,因爲題目說的是在做 threadlocal.get() 操作,證明其實還是有強引用存在的,所以 key 並不爲 null,如下圖所示,ThreadLocal強引用仍然是存在的。

image.png

如果我們的強引用不存在的話,那麼 key 就會被回收,也就是會出現我們 value 沒被回收,key 被回收,導致 value 永遠存在,出現內存泄漏。

ThreadLocal.set()方法源碼詳解

image.png

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 分佈非常均勻

我們自己可以嘗試下:

YKbSGn.png

可以看到產生的哈希碼分佈很均勻,這裏不去細糾斐波那契具體算法,感興趣的可以自行查閱相關資料。

ThreadLocalMap Hash衝突

註明: 下面所有示例圖中,綠色塊Entry代表正常數據灰色塊代表Entrykey值爲null已被垃圾回收白色塊表示Entrynull

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

HashMap中解決衝突的方法是在數組上構造一個鏈表結構,衝突的數據掛載到鏈表上,如果鏈表長度超過一定數量則會轉化成紅黑樹

ThreadLocalMap中並沒有鏈表結構,所以這裏不能適用HashMap解決衝突的方式了。

Ynzr5D.png

如上圖所示,如果我們插入一個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數據爲空:

YuSniD.png

這裏直接將數據放到該槽位即可。

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

image.png

這裏直接更新該槽位的數據。

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

image.png

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

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

Yu77qg.png

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

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

以當前staleSlot開始 向前迭代查找,找其他過期的數據,然後更新過期數據起始掃描下標slotToExpungefor循環迭代,直到碰到Entrynull結束。

如果找到了過期的數據,繼續向前迭代,直到遇到Entry=null的槽位才停止迭代,如下圖所示,slotToExpunge被更新爲0

YuHSMT.png

以當前節點(index=7)向前迭代,檢測是否有過期的Entry數據,如果有則更新slotToExpunge值。碰到null則結束探測。以上圖爲例slotToExpunge被更新爲0。

上面向前迭代的操作是爲了更新探測清理過期數據的起始下標slotToExpunge的值,這個值在後面會講解,它是用來判斷當前過期槽位staleSlot之前是否還有過期元素。

接着開始以staleSlot位置(index=7)向後迭代,如果找到了相同key值的Entry數據:

YuHEJ1.png

從當前節點staleSlot向後查找key值相等的Entry元素,找到後更新Entry的值並交換staleSlot元素的位置(staleSlot位置爲過期元素),更新Entry數據,然後開始進行過期Entry的清理工作,如下圖所示:

Yu4oWT.png

向後遍歷過程中,如果沒有找到相同key值的Entry數據:

YuHMee.png

從當前節點staleSlot向後查找key值相等的Entry元素,直到Entrynull則停止尋找。通過上圖可知,此時table中沒有key值相同的Entry

創建新的Entry,替換table[stableSlot]位置:

YuH3FA.png

替換完成後也是進行過期元素清理工作,清理工作主要是有兩個方法: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()方法實現:

YZSC5j.png

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數據距離正確的桶的位置更近一些。操作邏輯如下:

YuH2OU.png

如上圖,set(27) 經過hash計算後應該落到index=4的桶中,由於index=4桶已經有了數據,所以往後迭代最終數據放入到index=7的桶中,放入後一段時間後index=5中的Entry數據key變爲了null

YuHb6K.png

如果再有其他數據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()具體流程,我們還是以先原理圖後源碼講解的方式來一步步梳理:

Yuf301.png

我們假設expungeStaleEntry(3) 來調用此方法,如上圖所示,我們可以看到ThreadLocalMaptable的數據情況,接着執行清理操作:

YufupF.png

第一步是清空當前staleSlot位置的數據,index=3位置的Entry變成了null。然後接着往後探測:

YufAwq.png

執行完第二步後,index=4的元素挪到index=3的槽位中。

繼續往後迭代檢查,碰到正常數據,計算該數據位置是否偏移,如果被偏移,則重新計算slot位置,目的是讓正常數據儘可能存放在正確位置或離正確位置更近的位置

YuWjTP.png

在往後迭代的過程中碰到空的槽位,終止探測,這樣一輪探測式清理工作就完成了,接着我們繼續看看具體實現源代碼

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擴容機制的時候 我們一定要說清楚這兩個步驟:

YuqwPs.png

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

Yu2QOI.png

擴容後的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一致,則直接返回:

YuWfdx.png

第二種情況: slot位置中的Entry.key和要查找的key不一致:

YuWyz4.png

我們以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數據,如下圖所示:

YuW8JS.png

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.

YK5HJ0.png

具體代碼如下:

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中。

YeMO3t.png

圖中的requestId即爲我們各個系統鏈路關聯的traceId,系統間互相調用,通過這個requestId即可找到對應鏈路,這裏還有會有一些其他場景:

Ym3861.png

針對於這些場景,我們都可以有相應的解決方案,如下所示

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 run(Runnable runnable, Map<String, String> context) {
        if (context != null) {
            MDC.setContextMap(context);
        }
        try {
            runnable.run();
        } finally {
            MDC.remove();
        }
    }
}

使用MQ發送消息給第三方系統

在MQ發送的消息體中自定義屬性requestId,接收方消費消息後,自己解析requestId使用即可。

END

《Java工程師面試突擊第三季》加餐部分大綱:(注:1-66講的大綱請掃描文末二維碼,在課程詳情頁獲取)

詳細的課程內容,大家可以掃描下方二維碼瞭解:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章