面試官:聽說你看過ThreadLocal源碼?我來瞅瞅?

 

全文共10000+字,31張圖,這篇文章同樣耗費了不少的時間和精力才創作完成,請大家點點關注+在看,感謝。

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

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

  • ThreadLocalThreadLocalMap數據結構

  • ThreadLocalMapHash算法

  • ThreadLocalMapHash衝突如何解決?

  • ThreadLocalMap擴容機制?

  • ThreadLocalMap中過期key的清理機制?探測式清理啓發式清理流程?

  • ThreadLocalMap.set()方法實現原理?

  • ThreadLocalMap.get()方法實現原理?

  • 項目中ThreadLocal使用情況?遇到的坑?

  • ……

上述的一些問題你是否都已經掌握的很清楚了呢?本文將圍繞這些問題使用圖文方式來剖析ThreadLocal點點滴滴

目錄

ThreadLocal代碼演示

ThreadLocal的數據結構

GC 之後key是否爲null?

ThreadLocal.set()方法源碼詳解

ThreadLocalMap Hash算法

ThreadLocalMap Hash衝突

ThreadLocalMap.set()詳解

ThreadLocalMap過期key的探測式清理流程

ThreadLocalMap擴容機制

ThreadLocalMap.get()詳解

ThreadLocalMap過期key的啓發式清理流程

InheritableThreadLocal

ThreadLocal項目中使用實戰


註明: 本文源碼基於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使用即可。

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