面經手冊 · 第12篇《面試官,ThreadLocal 你要這麼問,我就掛了!》

小傅哥發表於2020-09-24


作者:小傅哥
部落格:https://bugstack.cn

沉澱、分享、成長,讓自己和他人都能有所收穫!?

一、前言

說到底,你真的會造火箭嗎?

常說面試造火箭,入職擰螺絲。但你真的有造火箭的本事嗎,大部分都是不敢承認自己的知識盲區和技術瓶頸以及經驗不足的自嘲。

面試時

  • 我希望你懂資料結構,因為這樣的你在使用HashMap、ArrayList、LinkedList,更加得心應手。
  • 我希望你懂雜湊演算法,因為這樣的你在設計路由時,會有很多選擇;除法雜湊法平方雜湊法斐波那契(Fibonacci)雜湊法等。
  • 我希望你懂開原始碼,因為這樣的你在遇到問題時,可以快速定位,還可能創造出一些系統服務的中介軟體,來更好的解耦系統。
  • 我希望你懂設計模式,因為這樣的你可以寫出可擴充套件、易維護的程式,讓整個團隊都能向更好的方向發展。

所以,從不是CRUD選擇了你,也不是造螺絲讓你成為工具人。而是你的技術能力決定你的眼界,眼界又決定了你寫出的程式碼!

二、面試題

謝飛機,小記 還沒有拿到 offer 的飛機,早早起了床,吃完兩根油條,又跑到公司找面試官取經!

靈魂畫手 & 老紀

面試官:飛機,聽坦克說,你最近貪黑起早的學習呀。

謝飛機:嗯嗯,是的,最近頭髮都快掉沒了!

面試官:那今天我們聊聊 ThreadLocal,一般可以用在什麼場景中?

謝飛機:嗯,ThreadLocal 要解決的是執行緒內資源共享 (This class provides thread-local variables.),所以一般會用在全鏈路監控中,或者是像日誌框架 MDC 這樣的元件裡。

面試官:飛機不錯哈,最近確實學習了。那你知道 ThreadLocal是怎樣的資料結構嗎,採用的是什麼雜湊方式?

謝飛機:陣列?嗯,怎麼雜湊的不清楚...

面試官:那 ThreadLocal 有記憶體洩漏的風險,是怎麼發生的呢?另外你瞭解在這個過程的,探測式清理和啟發式清理嗎?

謝飛機:這...,盲區了,盲區了,可樂我放桌上了,我回家再看看書!

三、ThreadLocal 分析

ThreadLocal,作者:Josh Bloch and Doug Lea,兩位大神?

如果僅是日常業務開發來看,這是一個比較冷門的類,使用頻率並不高。並且它提供的方法也非常簡單,一個功能只是潦潦數行程式碼。,如果深挖實現部分的原始碼,就會發現事情並不那麼簡單。這裡涉及了太多的知識點,包括;資料結構拉鍊儲存斐波那契雜湊神奇的0x61c88647弱引用Reference過期key探測清理和啟發式清理等等。

接下來,我們就逐步學習這些盲區知識。本文涉及了較多的程式碼和實踐驗證圖稿,歡迎關注公眾號:bugstack蟲洞棧,回覆下載得到一個連結開啟後,找到ID:19?獲取!*

1. 應用場景

1.1 SimpleDateFormat

private SimpleDateFormat f = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

public void seckillSku(){
    String dateStr = f.format(new Date());
    // 業務流程
}

你寫過這樣的程式碼嗎?如果還在這麼寫,那就已經犯了一個執行緒安全的錯誤。SimpleDateFormat,並不是一個執行緒安全的類。

1.1.1 執行緒不安全驗證
private static SimpleDateFormat f = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

public static void main(String[] args) {
    while (true) {
        new Thread(() -> {
            String dateStr = f.format(new Date());
            try {
                Date parseDate = f.parse(dateStr);
                String dateStrCheck = f.format(parseDate);
                boolean equals = dateStr.equals(dateStrCheck);
                if (!equals) {
                    System.out.println(equals + " " + dateStr + " " + dateStrCheck);
                } else {
                    System.out.println(equals);
                }
            } catch (ParseException e) {
                System.out.println(e.getMessage());
            }
        }).start();
    }
}

這是一個多執行緒下 SimpleDateFormat 的驗證程式碼。當 equals 為false 時,證明執行緒不安全。執行結果如下;

true
true
false 2020-09-23 11:40:42 2230-09-23 11:40:42
true
true
false 2020-09-23 11:40:42 2020-09-23 11:40:00
false 2020-09-23 11:40:42 2020-09-23 11:40:00
false 2020-09-23 11:40:00 2020-09-23 11:40:42
true
false 2020-09-23 11:40:42 2020-08-31 11:40:42
true
1.1.2 使用 ThreadLocal 優化

為了執行緒安全最直接的方式,就是每次呼叫都直接 new SimpleDateFormat。但這樣的方式終究不是最好的,所以我們使用 ThreadLocal ,來優化這段程式碼。

private static ThreadLocal<SimpleDateFormat> threadLocal = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
public static void main(String[] args) {
    while (true) {
        new Thread(() -> {
            String dateStr = threadLocal.get().format(new Date());
            try {
                Date parseDate = threadLocal.get().parse(dateStr);
                String dateStrCheck = threadLocal.get().format(parseDate);
                boolean equals = dateStr.equals(dateStrCheck);
                if (!equals) {
                    System.out.println(equals + " " + dateStr + " " + dateStrCheck);
                } else {
                    System.out.println(equals);
                }
            } catch (ParseException e) {
                System.out.println(e.getMessage());
            }
        }).start();
    }
}

如上我們把 SimpleDateFormat ,放到 ThreadLocal 中進行使用,即不需要重複new物件,也避免了執行緒不安全問題。測試結果如下;

true
true
true
true
true
true
true
...

1.2 鏈路追蹤

近幾年基於谷歌Dapper論文實現非入侵全鏈路追蹤,使用的越來越廣了。簡單說這就是一套監控系統,但不需要你硬編碼的方式進行監控方法,而是基於它的設計方案採用 javaagent + 位元組碼 插樁的方式,動態採集方法執行資訊。如果你想了解位元組碼插樁技術,可以閱讀我的位元組碼程式設計專欄:https://bugstack.cn/itstack-demo-agent/itstack-demo-agent.html

重點,動態採集方法執行資訊。這塊是主要部分,跟 ThreadLocal 相關。位元組碼插樁解決的是非入侵式程式設計,那麼在一次服務呼叫時,在各個系統間以及系統內多個方法的呼叫,都需要進行採集。這個時候就需要使用 ThreadLocal 記錄方法執行ID,當然這裡還有跨執行緒呼叫使用的也是增強版本的 ThreadLocal,但無論如何基本原理不變。

1.2.1 追蹤程式碼

這裡舉例全鏈路方法呼叫鏈追蹤,部分程式碼

public class TrackContext {

    private static final ThreadLocal<String> trackLocal = new ThreadLocal<>();

    public static void clear(){
        trackLocal.remove();
    }

    public static String getLinkId(){
        return trackLocal.get();
    }

    public static void setLinkId(String linkId){
        trackLocal.set(linkId);
    }

}
@Advice.OnMethodEnter()
public static void enter(@Advice.Origin("#t") String className, @Advice.Origin("#m") String methodName) {
    Span currentSpan = TrackManager.getCurrentSpan();
    if (null == currentSpan) {
        String linkId = UUID.randomUUID().toString();
        TrackContext.setLinkId(linkId);
    }
    TrackManager.createEntrySpan();
}

@Advice.OnMethodExit()
public static void exit(@Advice.Origin("#t") String className, @Advice.Origin("#m") String methodName) {
    Span exitSpan = TrackManager.getExitSpan();
    if (null == exitSpan) return;
    System.out.println("鏈路追蹤(MQ):" + exitSpan.getLinkId() + " " + className + "." + methodName + " 耗時:" + (System.currentTimeMillis() - exitSpan.getEnterTime().getTime()) + "ms");
}
  • 以上這部分就是非入侵監控中,鏈路追蹤的過程。具體的案例和程式碼可以參考閱讀,系列專題文章《基於JavaAgent的全鏈路監控》
  • 這也只是其中一個實現方式,位元組碼插樁使用的是 byte-buddy,其實還是使用,ASM 或者 Javassist
1.2.2 測試結果

測試方法

配置引數:-javaagent:E:\itstack\GIT\itstack.org\itstack-demo-agent\itstack-demo-agent-06\target\itstack-demo-agent-06-1.0.0-SNAPSHOT.jar=testargs

public void http_lt1(String name) {
    try {
        Thread.sleep((long) (Math.random() * 500));
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.out.println("測試結果:hi1 " + name);
    http_lt2(name);
}

public void http_lt2(String name) {
    try {
        Thread.sleep((long) (Math.random() * 500));
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.out.println("測試結果:hi2 " + name);
    http_lt3(name);
}

執行結果

onTransformation:class org.itstack.demo.test.ApiTest
測試結果:hi2 悟空
測試結果:hi3 悟空
鏈路追蹤(MQ):90c7d543-c7b8-4ec3-af4d-b4d4f5cff760 org.itstack.demo.test.ApiTest.http_lt3 耗時:104ms

init: 256MB	 max: 3614MB	 used: 44MB	 committed: 245MB	 use rate: 18%
init: 2MB	 max: 0MB	 used: 13MB	 committed: 14MB	 use rate: 95%

name: PS Scavenge	 count:0	 took:0	 pool name:[PS Eden Space, PS Survivor Space]
name: PS MarkSweep	 count:0	 took:0	 pool name:[PS Eden Space, PS Survivor Space, PS Old Gen]
-------------------------------------------------------------------------------------------------
鏈路追蹤(MQ):90c7d543-c7b8-4ec3-af4d-b4d4f5cff760 org.itstack.demo.test.ApiTest.http_lt2 耗時:233ms

init: 256MB	 max: 3614MB	 used: 44MB	 committed: 245MB	 use rate: 18%
init: 2MB	 max: 0MB	 used: 13MB	 committed: 14MB	 use rate: 96%

name: PS Scavenge	 count:0	 took:0	 pool name:[PS Eden Space, PS Survivor Space]
name: PS MarkSweep	 count:0	 took:0	 pool name:[PS Eden Space, PS Survivor Space, PS Old Gen]
  • 以上是鏈路追蹤的測試結果,可以看到兩個方法都會打出相應的編碼ID:90c7d543-c7b8-4ec3-af4d-b4d4f5cff760
  • 這部分也就是全鏈路追蹤的核心應用,而且還可以看到這裡列印了一些系統簡單的JVM監控指標,這也是監控的一部分。

咳咳,除此之外所有需要活動方法呼叫鏈的,都需要使用到 ThreadLocal,例如 MDC 日誌框架等。接下來我們開始詳細分析 ThreadLocal 的實現。

2. 資料結構

瞭解一個功能前,先了解它的資料結構。這就相當於先看看它的地基,有了這個根本也就好往後理解了。以下是 ThreadLocal 的簡單使用以及部分原始碼。

new ThreadLocal<String>().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 底層採用的是陣列結構儲存資料,同時還有雜湊值計算下標,這說明它是一個雜湊表的陣列結構,演示如下圖;

小傅哥 & threadLocal 資料結構

如上圖是 ThreadLocal 存放資料的底層資料結構,包括知識點如下;

  1. 它是一個陣列結構。
  2. Entry,這裡沒用再開啟,其實它是一個弱引用實現,static class Entry extends WeakReference<ThreadLocal<?>>。這說明只要沒用強引用存在,發生GC時就會被垃圾回收。
  3. 資料元素採用雜湊雜湊方式進行儲存,不過這裡的雜湊使用的是 斐波那契(Fibonacci)雜湊法,後面會具體分析。
  4. 另外由於這裡不同於HashMap的資料結構,發生雜湊碰撞不會存成連結串列或紅黑樹,而是使用拉鍊法進行儲存。也就是同一個下標位置發生衝突時,則+1向後定址,直到找到空位置或垃圾回收位置進行儲存。

3. 雜湊演算法

既然 ThreadLocal 是基於陣列結構的拉鍊法儲存,那就一定會有雜湊的計算。但我們翻閱原始碼後,發現這個雜湊計算與 HashMap 中的雜湊求陣列下標計算的雜湊方式不一樣。如果你忘記了HashMap,可以翻閱文章《HashMap 原始碼分析,插入、查詢》《HashMap 擾動函式、負載因子》

3.1 神祕的數字 0x61c88647

當我們檢視 ThreadLocal 執行設定元素時,有這麼一段計算雜湊值的程式碼;

private static final int HASH_INCREMENT = 0x61c88647;

private static int nextHashCode() {
    return nextHashCode.getAndAdd(HASH_INCREMENT);
}

看到這裡你一定會有這樣的疑問,這是什麼方式計算雜湊?這個數字怎麼來的?

講到這裡,其實計算雜湊的方式,絕不止是我們平常看到 String 獲取雜湊值的一種方式,還包括;除法雜湊法平方雜湊法斐波那契(Fibonacci)雜湊法隨機數法等。

ThreadLocal 使用的就是 斐波那契(Fibonacci)雜湊法 + 拉鍊法儲存資料到陣列結構中。之所以使用斐波那契數列,是為了讓資料更加雜湊,減少雜湊碰撞。具體來自數學公式的計算求值,公式f(k) = ((k * 2654435769) >> X) << Y對於常見的32位整數而言,也就是 f(k) = (k * 2654435769) >> 28

第二個問題,數字 0x61c88647,是怎麼來的?

其實這是一個雜湊值的黃金分割點,也就是 0.618,你還記得你學過的數學嗎?計算方式如下;

// 黃金分割點:(√5 - 1) / 2 = 0.6180339887     1.618:1 == 1:0.618
System.out.println(BigDecimal.valueOf(Math.pow(2, 32) * 0.6180339887).intValue());      //-1640531527
  • 學過數學都應該知道,黃金分割點是,(√5 - 1) / 2,取10位近似 0.6180339887
  • 之後用 2 ^ 32 * 0.6180339887,得到的結果是:-1640531527,也就是 16 進位制的,0x61c88647。這個數呢也就是這麼來的

3.2 驗證雜湊

既然,Josh BlochDoug Lea,兩位老爺子選擇使用斐波那契數列,計算雜湊值。那一定有它的過人之處,也就是能更好的雜湊,減少雜湊碰撞。

接下來我們按照原始碼中獲取雜湊值和計算下標的方式,把這部分程式碼提出出來做驗證。

3.2.1 部分原始碼
private static AtomicInteger nextHashCode = new AtomicInteger();
 
private static final int HASH_INCREMENT = 0x61c88647;

// 計算雜湊
private static int nextHashCode() {
    return nextHashCode.getAndAdd(HASH_INCREMENT);
}

// 獲取下標
int i = key.threadLocalHashCode & (len-1);

如上,原始碼部分採用的是 AtomicInteger,原子方法計算下標。我們不需要保證執行緒安全,只需要簡單實現即可。另外 ThreadLocal 初始化陣列長度是16,我們也初始化這個長度。

3.2.2 單元測試
@Test
public void test_idx() {
    int hashCode = 0;
    for (int i = 0; i < 16; i++) {
        hashCode = i * HASH_INCREMENT + HASH_INCREMENT;
        int idx = hashCode & 15;
        System.out.println("斐波那契雜湊:" + idx + " 普通雜湊:" + (String.valueOf(i).hashCode() & 15));
    }
}

測試程式碼部分,採用的就是斐波那契數列,同時我們加入普通雜湊演算法進行比對雜湊效果。當然String 這個雜湊並沒有像 HashMap 中進行擾動

測試結果

斐波那契雜湊:7 普通雜湊:0
斐波那契雜湊:14 普通雜湊:1
斐波那契雜湊:5 普通雜湊:2
斐波那契雜湊:12 普通雜湊:3
斐波那契雜湊:3 普通雜湊:4
斐波那契雜湊:10 普通雜湊:5
斐波那契雜湊:1 普通雜湊:6
斐波那契雜湊:8 普通雜湊:7
斐波那契雜湊:15 普通雜湊:8
斐波那契雜湊:6 普通雜湊:9
斐波那契雜湊:13 普通雜湊:15
斐波那契雜湊:4 普通雜湊:0
斐波那契雜湊:11 普通雜湊:1
斐波那契雜湊:2 普通雜湊:2
斐波那契雜湊:9 普通雜湊:3
斐波那契雜湊:0 普通雜湊:4

Process finished with exit code 0

發現沒?,斐波那契雜湊的非常均勻,普通雜湊到15個以後已經開發生產碰撞。這也就是斐波那契雜湊的魅力,減少碰撞也就可以讓資料儲存的更加分散,獲取資料的時間複雜度基本保持在O(1)。

4. 原始碼解讀

4.1 初始化

new ThreadLocal<>()

初始化的過程也很簡單,可以按照自己需要的泛型進行設定。但在 ThreadLocal 的原始碼中有一點非常重要,就是獲取 threadLocal 的雜湊值的獲取,threadLocalHashCode

private final int threadLocalHashCode = nextHashCode();

/**
 * Returns the next hash code.
 */
private static int nextHashCode() {
    return nextHashCode.getAndAdd(HASH_INCREMENT);
}

如原始碼中,只要例項化一個 ThreadLocal ,就會獲取一個相應的雜湊值,則例我們做一個例子。

@Test
public void test_threadLocalHashCode() throws Exception {
    for (int i = 0; i < 5; i++) {
        ThreadLocal<Object> objectThreadLocal = new ThreadLocal<>();
        Field threadLocalHashCode = objectThreadLocal.getClass().getDeclaredField("threadLocalHashCode");
        threadLocalHashCode.setAccessible(true);
        System.out.println("objectThreadLocal:" + threadLocalHashCode.get(objectThreadLocal));
    }
}

因為 threadLocalHashCode ,是一個私有屬性,所以我們例項化後通過上面的方式進行獲取雜湊值。

objectThreadLocal:-1401181199
objectThreadLocal:239350328
objectThreadLocal:1879881855
objectThreadLocal:-774553914
objectThreadLocal:865977613

Process finished with exit code 0

這個值的獲取,也就是計算 ThreadLocalMap,儲存資料時,ThreadLocal 的陣列下標。只要是這同一個物件,在setget時,就可以設定和獲取對應的值。

4.2 設定元素

4.2.1 流程圖解

new ThreadLocal<>().set("小傅哥");

設定元素的方法,也就這麼一句程式碼。但設定元素的流程卻涉及的比較多,在詳細分析程式碼前,我們先來看一張設定元素的流程圖,從圖中先了解不同情況的流程之後再對比著學習原始碼。流程圖如下;

小傅哥 & 設定元素流程圖

乍一看可能感覺有點暈,我們從左往右看,分別有如下知識點;
0. 中間是 ThreadLocal 的陣列結構,之後在設定元素時分為四種不同的情況,另外元素的插入是通過斐波那契雜湊計算下標值,進行存放的。

  1. 情況1,待插入的下標,是空位置直接插入。
  2. 情況2,待插入的下標,不為空,key 相同,直接更新
  3. 情況3,待插入的下標,不為空,key 不相同,拉鍊法定址
  4. 情況4,不為空,key 不相同,碰到過期key。其實情況4,遇到的是弱引用發生GC時,產生的情況。碰到這種情況,ThreadLocal 會進行探測清理過期key,這部分清理內容後續講解。
4.2.2 原始碼分析
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();
}

在有了上面的圖解流程,再看程式碼部分就比較容易理解了,與之對應的內容包括,如下;

  1. key.threadLocalHashCode & (len-1);,斐波那契雜湊,計算陣列下標。
  2. Entry,是一個弱引用物件的實現類,static class Entry extends WeakReference<ThreadLocal<?>>,所以在沒有外部強引用下,會發生GC,刪除key。
  3. for迴圈判斷元素是否存在,當前下標不存在元素時,直接設定元素 tab[i] = new Entry(key, value);
  4. 如果元素存在,則會判斷是否key值相等 if (k == key),相等則更新值。
  5. 如果不相等,就到了我們的 replaceStaleEntry,也就是上圖說到的探測式清理過期元素。

綜上,就是元素存放的全部過程,整體結構的設計方式非常贊?,極大的利用了雜湊效果,也把弱引用使用的非常6!

4.3 擴容機制

4.3.1 擴容條件

只要使用到陣列結構,就一定會有擴容

if (!cleanSomeSlots(i, sz) && sz >= threshold)
    rehash();

在我們閱讀設定元素時,有以上這麼一塊程式碼,判斷是否擴容。

  • 首先,進行啟發式清理*cleanSomeSlots*,把過期元素清理掉,看空間是否
  • 之後,判斷sz >= threshold,其中 threshold = len * 2 / 3,也就是說陣列中天填充的元素,大於 len * 2 / 3,就需要擴容了。
  • 最後,就是我們要分析的重點,rehash();,擴容重新計算元素位置。
4.3.2 原始碼分析

探測式清理和校驗

private void rehash() {
    expungeStaleEntries();
    
    // Use lower threshold for doubling to avoid hysteresis
    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);
    }
}
  • 這部分是主要是探測式清理過期元素,以及判斷清理後是否滿足擴容條件,size >= threshold * 3/4
  • 滿足後執行擴容操作,其實擴容完的核心操作就是重新計算雜湊值,把元素填充到新的陣列中。

rehash() 擴容

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; // Help the GC
            } 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;
}

以上,程式碼就是擴容的整體操作,具體包括如下步驟;

  1. 首先把陣列長度擴容到原來的2倍,oldLen * 2,例項化新陣列。
  2. 遍歷for,所有的舊陣列中的元素,重新放到新陣列中。
  3. 在放置陣列的過程中,如果發生雜湊碰撞,則鏈式法順延。
  4. 同時這還有檢測key值的操作 if (k == null),方便GC。

4.4 獲取元素

4.4.1 流程圖解

new ThreadLocal<>().get();

同樣獲取元素也就這麼一句程式碼,如果沒有分析原始碼之前,你能考慮到它在不同的資料結構下,獲取元素時候都做了什麼操作嗎。我們先來看下圖,分為如下種情況;

小傅哥 & 獲取元素圖解

按照不同的資料元素儲存情況,基本包括如下情況;

  1. 直接定位到,沒有雜湊衝突,直接返回元素即可。
  2. 沒有直接定位到了,key不同,需要拉鍊式尋找。
  3. 沒有直接定位到了,key不同,拉鍊式尋找,遇到GC清理元素,需要探測式清理,再尋找元素。
4.4.2 原始碼分析
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;
}

好了,這部分就是獲取元素的原始碼部分,和我們圖中列舉的情況是一致的。expungeStaleEntry,是發現有 key == null 時,進行清理過期元素,並把後續位置的元素,前移。

4.5 元素清理

4.5.1 探測式清理[expungeStaleEntry]

探測式清理,是以當前遇到的 GC 元素開始,向後不斷的清理。直到遇到 null 為止,才停止 rehash 計算Rehash until we encounter null

expungeStaleEntry

private int expungeStaleEntry(int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;
    // expunge entry at staleSlot
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;
    // Rehash until we encounter null
    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;
                // Unlike Knuth 6.4 Algorithm R, we must scan until
                // null because multiple entries could have been stale.
                while (tab[h] != null)
                    h = nextIndex(h, len);
                tab[h] = e;
            }
        }
    }
    return i;
}

以上,探測式清理在獲取元素中使用到; new ThreadLocal<>().get() -> map.getEntry(this) -> getEntryAfterMiss(key, i, e) -> expungeStaleEntry(i)

4.5.2 啟發式清理[cleanSomeSlots]
Heuristically scan some cells looking for stale entries.
This is invoked when either a new element is added, or
another stale one has been expunged. It performs a
logarithmic number of scans, as a balance between no
scanning (fast but retains garbage) and a number of scans
proportional to number of elements, that would find all
garbage but would cause some insertions to take O(n) time.

啟發式清理,有這麼一段註釋,大概意思是;試探的掃描一些單元格,尋找過期元素,也就是被垃圾回收的元素。當新增新元素或刪除另一個過時元素時,將呼叫此函式。它執行對數掃描次數,作為不掃描(快速但保留垃圾)和與元素數量成比例的掃描次數之間的平衡,這將找到所有垃圾,但會導致一些插入花費O(n)時間。

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

while 迴圈中不斷的右移進行尋找需要被清理的過期元素,最終都會使用 expungeStaleEntry 進行處理,這裡還包括元素的移位。

四、總結

  • 寫到這算是把 ThreadLocal 知識點的一角分析完了,在 ThreadLocal 的家族裡還有 Netty 中用到的,FastThreadLocal。在全鏈路跨服務執行緒間獲取呼叫鏈路,還有 TransmittableThreadLocal,另外還有 JDK 本身自帶的一種執行緒傳遞解決方案 InheritableThreadLocal。但站在本文的基礎上,瞭解了最基礎的原理,在理解其他的擴充設計,就更容易接受了。
  • 此外在我們文中分析時經常會看到探測式清理,其實這也是非常耗時。為此我們在使用 ThreadLocal 一定要記得 new ThreadLocal<>().remove(); 操作。避免弱引用發生GC後,導致記憶體洩漏的問題。
  • 最後,你發現了嗎!我們學習這樣的底層原理性知識,都離不開資料結構和良好的設計方案,或者說是演算法的身影。這些程式碼才是支撐整個系統良好執行的地基,如果我們可以把一些思路抽取到我們開發的核心業務流程中,也是可以大大提升效能的。

五、系列推薦

相關文章