ThreadLocal 超強圖解,這次終於懂了~

前言

大家好,我是小彭。

在前面的文章裏,我們聊到了散列表的開放尋址法和分離鏈表法,也聊到了 HashMapLinkedHashMapWeakHashMap 等基於分離鏈表法實現的散列表。

今天,我們來討論 Java 標準庫中一個使用開放尋址法的散列表結構,也是 Java & Android “面試八股文” 的標準題庫之一 —— ThreadLocal。

本文源碼基於 Java 8 ThreadLocal。


思維導圖:


1. 回顧散列表的工作原理

在開始分析 ThreadLocal 的實現原理之前,我們先回顧散列表的工作原理。

散列表是基於散列思想實現的 Map 數據結構,將散列思想應用到散列表數據結構時,就是通過 hash 函數提取鍵(Key)的特徵值(散列值),再將鍵值對映射到固定的數組下標中,利用數組支持隨機訪問的特性,實現 O(1) 時間的存儲和查詢操作。

散列表示意圖

在從鍵值對映射到數組下標的過程中,散列表會存在 2 次散列衝突:

  • 第 1 次 - hash 函數的散列衝突: 這是一般意義上的散列衝突;
  • 第 2 次 - 散列值取餘轉數組下標: 本質上,將散列值轉數組下標也是一次 Hash 算法,也會存在散列衝突。

事實上,由於散列表是壓縮映射,所以我們無法避免散列衝突,只能保證散列表不會因爲散列衝突而失去正確性。常用的散列衝突解決方法有 2 類:

  • 開放尋址法: 例如 ThreadLocalMap;
  • 分離鏈表法: 例如 HashMap。

開放尋址(Open Addressing)的核心思想是: 在出現散列衝突時,在數組上重新探測出一個空閒位置。 經典的探測方法有線性探測、平方探測和雙散列探測。線性探測是最基本的探測方法,我們今天要分析的 ThreadLocal 中的 ThreadLocalMap 散列表就是採用線性探測的開放尋址法。


2. 認識 ThreadLocal 線程局部存儲

2.1 說一下 ThreadLocal 的特點?

ThreadLocal 提供了一種特殊的線程安全方式。

使用 ThreadLocal 時,每個線程可以通過 ThreadLocal#getThreadLocal#set 方法訪問資源在當前線程的副本,而不會與其他線程產生資源競爭。這意味着 ThreadLocal 並不考慮如何解決資源競爭,而是爲每個線程分配獨立的資源副本,從根本上避免發生資源衝突,是一種無鎖的線程安全方法。

用一個表格總結 ThreadLocal 的 API:

public API 描述
set(T) 設置當前線程的副本
T get() 獲取當前線程的副本
void remove() 移除當前線程的副本
ThreadLocal<S> withInitial(Supplier<S>) 創建 ThreadLocal 並指定缺省值創建工廠
protected API 描述
T initialValue() 設置缺省值

2.2 ThreadLocal 如何實現線程隔離?(重點理解)

ThreadLocal 在每個線程的 Thread 對象實例數據中分配獨立的內存區域,當我們訪問 ThreadLocal 時,本質上是在訪問當前線程的 Thread 對象上的實例數據,不同線程訪問的是不同的實例數據,因此實現線程隔離。

Thread 對象中這塊數據就是一個使用線性探測的 ThreadLocalMap 散列表,ThreadLocal 對象本身就作爲散列表的 Key ,而 Value 是資源的副本。當我們訪問 ThreadLocal 時,就是先獲取當前線程實例數據中的 ThreadLocalMap 散列表,再通過當前 ThreadLocal 作爲 Key 去匹配鍵值對。

ThreadLocal.java

// 獲取當前線程的副本
public T get() {
    // 先獲取當前線程實例數據中的 ThreadLocalMap 散列表
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    // 通過當前 ThreadLocal 作爲 Key 去匹配鍵值對
    ThreadLocalMap.Entry e = map.getEntry(this);
    // 詳細源碼分析見下文 ...
}

// 獲取線程 t 的 threadLocals 字段,即 ThreadLocalMap 散列表
ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

// 靜態內部類
static class ThreadLocalMap {
    // 詳細源碼分析見下文 ...
}

Thread.java

// Thread 對象的實例數據
ThreadLocal.ThreadLocalMap threadLocals = null;
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;

// 線程退出之前,會置空threadLocals變量,以便隨後GC
private void exit() {
    // ...
    threadLocals = null;
    inheritableThreadLocals = null;
    inheritedAccessControlContext = null;
    // ...
}

ThreadLocal 示意圖

2.3 使用 InheritableThreadLocal 繼承父線程的局部存儲

在業務開發的過程中,我們可能希望子線程可以訪問主線程中的 ThreadLocal 數據,然而 ThreadLocal 是線程隔離的,包括在父子線程之間也是線程隔離的。爲此,ThreadLocal 提供了一個相似的子類 InheritableThreadLocal,ThreadLocal 和 InheritableThreadLocal 分別對應於線程對象上的兩塊內存區域:

  • 1、ThreadLocal 字段: 在所有線程間隔離;

  • 2、InheritableThreadLocal 字段: 子線程會繼承父線程的 InheritableThreadLocal 數據。父線程在創建子線程時,會批量將父線程的有效鍵值對數據拷貝到子線程的 InheritableThreadLocal,因此子線程可以複用父線程的局部存儲。

在 InheritableThreadLocal 中,可以重寫 childValue() 方法修改拷貝到子線程的數據。

public class InheritableThreadLocal<T> extends ThreadLocal<T> {

    // 參數:父線程的數據
    // 返回值:拷貝到子線程的數據,默認爲直接傳遞
    protected T childValue(T parentValue) {
        return parentValue;
    }
}

需要特別注意:

  • 注意 1 - InheritableThreadLocal 區域在拷貝後依然是線程隔離的: 在完成拷貝後,父子線程對 InheritableThreadLocal 的操作依然是相互獨立的。子線程對 InheritableThreadLocal 的寫不會影響父線程的 InheritableThreadLocal,反之亦然;

  • 注意 2 - 拷貝過程在父線程執行: 這是容易混淆的點,雖然拷貝數據的代碼寫在子線程的構造方法中,但是依然是在父線程執行的。子線程是在調用 start() 後纔開始執行的。

InheritableThreadLocal 示意圖

2.4 ThreadLocal 的自動清理與內存泄漏問題

ThreadLocal 提供具有自動清理數據的能力,具體分爲 2 個顆粒度:

  • 1、自動清理散列表: ThreadLocal 數據是 Thread 對象的實例數據,當線程執行結束後,就會跟隨 Thread 對象 GC 而被清理;

  • 2、自動清理無效鍵值對: ThreadLocal 是使用弱鍵的動態散列表,當 Key 對象不再被持有強引用時,垃圾收集器會按照弱引用策略自動回收 Key 對象,並在下次訪問 ThreadLocal 時清理無效鍵值對。

引用關係示意圖

然而,自動清理無效鍵值對會存在 “滯後性”,在滯後的這段時間內,無效的鍵值對數據沒有及時回收,就發生內存泄漏。

  • 舉例 1: 如果創建 ThreadLocal 的線程一直持續運行,整個散列表的數據就會一致存在。比如線程池中的線程(大體)是複用的,這部分複用線程中的 ThreadLocal 數據就不會被清理;
  • 舉例 2: 如果在數據無效後沒有再訪問過 ThreadLocal 對象,那麼自然就沒有機會觸發清理;
  • 舉例 3: 即使訪問 ThreadLocal 對象,也不一定會觸發清理(原因見下文源碼分析)。

綜上所述:雖然 ThreadLocal 提供了自動清理無效數據的能力,但是爲了避免內存泄漏,在業務開發中應該及時調用 ThreadLocal#remove 清理無效的局部存儲。

2.5 ThreadLocal 的使用場景

  • 場景 1 - 無鎖線程安全: ThreadLocal 提供了一種特殊的線程安全方式,從根本上避免資源競爭,也體現了空間換時間的思想;

  • 場景 2 - 線程級別單例: 一般的單例對象是對整個進程可見的,使用 ThreadLocal 也可以實現線程級別的單例;

  • 場景 3 - 共享參數: 如果一個模塊有非常多地方需要使用同一個變量,相比於在每個方法中重複傳遞同一個參數,使用一個 ThreadLocal 全局變量也是另一種傳遞參數方式。

2.6 ThreadLocal 使用示例

我們採用 Android Handler 機制中的 Looper 消息循環作爲 ThreadLocal 的學習案例:

android.os.Looper.java

// /frameworks/base/core/java/android/os/Looper.java

public class Looper {

    // 靜態 ThreadLocal 變量,全局共享同一個 ThreadLocal 對象
    static final ThreadLocal<Looper> sThreadLocal = new ThreadLocal<Looper>();

    private static void prepare(boolean quitAllowed) {
        if (sThreadLocal.get() != null) {
            throw new RuntimeException("Only one Looper may be created per thread");
        }
        // 設置 ThreadLocal 變量的值,即設置當前線程關聯的 Looper 對象
        sThreadLocal.set(new Looper(quitAllowed));
    }

    public static Looper myLooper() {
        // 獲取 ThreadLocal 變量的值,即獲取當前線程關聯的 Looper 對象
        return sThreadLocal.get();
    }

    public static void prepare() {
        prepare(true);
    }
    ...
}

示例代碼

new Thread(new Runnable() {
    @Override
    public void run() {
        Looper.prepare();
        // 兩個線程獨立訪問不同的 Looper 對象
        System.out.println(Looper.myLooper());
    }
}).start();
    
new Thread(new Runnable() {
    @Override
    public void run() {
        Looper.prepare();
        // 兩個線程獨立訪問不同的 Looper 對象
        System.out.println(Looper.myLooper());
    }
}).start();

要點如下:

  • 1、Looper 中的 ThreadLocal 被聲明爲靜態類型,泛型參數爲 Looper,全局共享同一個 ThreadLocal 對象;
  • 2、Looper#prepare() 中調用 ThreadLocal#set() 設置當前線程關聯的 Looper 對象;
  • 3、Looper#myLooper() 中調用 ThreadLocal#get() 獲取當前線程關聯的 Looper 對象。

我們可以畫出 Looper 中訪問 ThreadLocal 的 Timethreads 圖,可以看到不同線程獨立訪問不同的 Looper 對象,即線程間不存在資源競爭。

Looper ThreadLocal 示意圖

2.7 阿里巴巴 ThreadLocal 編程規約

在《阿里巴巴 Java 開發手冊》中,亦有關於 ThreadLocal API 的編程規約:

  • 【強制】 SimpleDateFormate 是線程不安全的類,一般不要定義爲 static ****變量。如果定義爲 static,必須加鎖,或者使用 DateUtils 工具類(使用 ThreadLocal 做線程隔離)。

DataFormat.java

private static final ThreadLocal<DataFormat> df = new ThreadLocal<DateFormat>(){
    // 設置缺省值 / 初始值
    @Override
    protected DateFormat initialValue(){
        return new SimpleDateFormat("yyyy-MM-dd");
    }
};

// 使用:
DateUtils.df.get().format(new Date());
  • 【參考】 (原文過於囉嗦,以下是小彭翻譯轉述)ThreadLocal 變量建議使用 static 全局變量,可以保證變量在類初始化時創建,所有類實例可以共享同一個靜態變量(例如,在 Android Looper 的案例中,ThreadLocal 就是使用 static 修飾的全局變量)。
  • 【強制】 必須回收自定義的 ThreadLocal 變量,尤其在線程池場景下,線程經常被反覆用,如果不清理自定義的 ThreadLocal 變量,則可能會影響後續業務邏輯和造成內存泄漏等問題。儘量在代碼中使用 try-finally 塊回收,在 finally 中調用 remove() 方法。

3. ThreadLocal 源碼分析

這一節,我們來分析 ThreadLocal 中主要流程的源碼。

3.1 ThreadLocal 的屬性

ThreadLocal 只有一個 threadLocalHashCode 散列值屬性:

  • 1、threadLocalHashCode 相當於 ThreadLocal 的自定義散列值,在創建 ThreadLocal 對象時,會調用 nextHashCode() 方法分配一個散列值;

  • 2、ThreadLocal 每次調用 nextHashCode() 方法都會將散列值追加 HASH_INCREMENT,並記錄在一個全局的原子整型 nextHashCode 中。

提示: ThreadLocal 的散列值序列爲:0、HASH_INCREMENT、HASH_INCREMENT * 2、HASH_INCREMENT * 3、…

public class ThreadLocal<T> {

    // 疑問 1:OK,threadLocalHashCode 類似於 hashCode(),那爲什麼 ThreadLocal 不重寫 hashCode()
    // ThreadLocal 的散列值,類似於重寫 Object#hashCode()
    private final int threadLocalHashCode = nextHashCode();

    // 全局原子整型,每調用一次 nextHashCode() 累加一次
    private static AtomicInteger nextHashCode = new AtomicInteger();

    // 疑問:爲什麼 ThreadLocal 散列值的增量是 0x61c88647?
    private static final int HASH_INCREMENT = 0x61c88647;

    private static int nextHashCode() {
        // 返回上一次 nextHashCode 的值,並累加 HASH_INCREMENT
        return nextHashCode.getAndAdd(HASH_INCREMENT);
    }
}

static class ThreadLocalMap {
    // 詳細源碼分析見下文 ...
}

不出意外的話又有小朋友出來舉手提問了🙋🏻♀️

  • 🙋🏻♀️疑問 1:OK,threadLocalHashCode 類似於 hashCode(),那爲什麼 ThreadLocal 不重寫 hashCode()?

如果重寫 Object#hashCode(),那麼 threadLocalHashCode 散列值就會對所有散列表生效。而 threadLocalHashCode 散列值是專門針對數組爲 2 的整數冪的散列表設計的,在其他散列表中不一定表現良好。因此 ThreadLocal 沒有重寫 Object#hashCode(),讓 threadLocalHashCode 散列值只在 ThreadLocal 內部的 ThreadLocalMap 使用。

常規做法

public class ThreadLocal<T> {

    // ThreadLocal 未重寫 hashCode()
    @Override
    public int hashCode() {
        return threadLocalHashCode;
    }
}
  • 🙋🏻♀️疑問 2:爲什麼使用 ThreadLocal 作爲散列表的 Key,而不是常規思維用 Thread Id 作爲 Key?

如果使用 Thread Id 作爲 Key,那麼就需要在每個 ThreadLocal 對象中維護散列表,而不是每個線程維護一個散列表。此時,當多個線程併發訪問同一個 ThreadLocal 對象中的散列表時,就需要通過加鎖保證線程安全。而 ThreadLocal 的方案讓每個線程訪問獨立的散列表,就可以從根本上規避線程競爭。

3.2 ThreadLocal 的 API

分析代碼,可以總結出 ThreadLocal API 的用法和注意事項:

  • 1、ThreadLocal#get: 獲取當前線程的副本;
  • 2、ThreadLocal#set: 設置當前線程的副本;
  • 3、ThreadLocal#remove: 移除當前線程的副本;
  • 4、ThreadLocal#initialValue: 由子類重寫來設置缺省值:
    • 4.1 如果未命中(Map 取值爲 nul),則會調用 initialValue() 創建並設置缺省值;
    • 4.2 ThreadLocal 的缺省值只會在緩存未命中時創建,即缺省值採用懶初始化策略;
    • 4.3 如果先設置後又移除副本,再次 get 獲取副本未命中時依然會調用 initialValue() 創建並設置缺省值。
  • 5、ThreadLocal#withInitial: 方便設置缺省值,而不需要實現子類。

在 ThreadLocal 的 API 會通過 getMap() 方法獲取當前線程的 Thread 對象中的 threadLocals 字段,這是線程隔離的關鍵。

ThreadLocal.java

public ThreadLocal() {
    // do nothing
}

// 子類可重寫此方法設置缺省值(方法命名爲 defaultValue 獲取更貼切)
protected T initialValue() {
    // 默認不提供缺省值
    return null;
}

// 幫助方法:不重寫 ThreadLocal 也可以設置缺省值
// supplier:缺省值創建工廠
public static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier) {
    return new SuppliedThreadLocal<>(supplier);
}

// 1. 獲取當前線程的副本
public T get() {
    Thread t = Thread.currentThread();
    // ThreadLocalMap 詳細源碼分析見下文
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        // 存在匹配的Entry
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            T result = (T)e.value;
            return result;
        }
    }
    // 未命中,則獲取並設置缺省值(即缺省值採用懶初始化策略)
    return setInitialValue();
}

// 獲取並設置缺省值
private T setInitialValue() {
    T value = initialValue();
    // 其實源碼中是並不是直接調用set(),而是複製了一份 set() 方法的源碼
    // 這是爲了防止子類重寫 set() 方法後改變缺省值邏輯
    set(value);
    return value;
}
  
// 2. 設置當前線程的副本
public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        // 直到設置值的時候才創建(即 ThreadLocalMap 採用懶初始化策略)
        createMap(t, value);
}

// 3. 移除當前線程的副本
public void remove() {
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null)
        m.remove(this);
}

ThreadLocalMap getMap(Thread t) {
    // 重點:獲取當前線程的 threadLocals 字段
    return t.threadLocals;
}

// ThreadLocal 缺省值幫助類
static final class SuppliedThreadLocal<T> extends ThreadLocal<T> {

    private final Supplier<? extends T> supplier;

    SuppliedThreadLocal(Supplier<? extends T> supplier) {
        this.supplier = Objects.requireNonNull(supplier);
    }

    // 重寫 initialValue() 以設置缺省值
    @Override
    protected T initialValue() {
        return supplier.get();
    }
}

3.3 InheritableThreadLocal 如何繼承父線程的局部存儲?

父線程在創建子線程時,在子線程的構造方法中會批量將父線程的有效鍵值對數據拷貝到子線程,因此子線程可以複用父線程的局部存儲。

Thread.java

// Thread 對象的實例數據
ThreadLocal.ThreadLocalMap threadLocals = null;
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;

// 構造方法
public Thread() {
    init(null, null, "Thread-" + nextThreadNum(), 0);
}

private void init(ThreadGroup g, Runnable target, String name, long stackSize, AccessControlContext acc, boolean inheritThreadLocals) {
    ...
    if (inheritThreadLocals && parent.inheritableThreadLocals != null)
        // 拷貝父線程的 InheritableThreadLocal 散列表
        this.inheritableThreadLocals = ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
    ...
}

ThreadLocal.java

// 帶 Map 的構造方法
static ThreadLocalMap createInheritedMap(ThreadLocalMap parentMap) {
    return new ThreadLocalMap(parentMap);
}

static class ThreadLocalMap {

    private ThreadLocalMap(ThreadLocalMap parentMap) {
        // 詳細源碼分析見下文 ...
        Object value = key.childValue(e.value);
        ...
    }   
}

InheritableThreadLocal 在拷貝父線程散列表的過程中,會調用 InheritableThreadLocal#childValue() 嘗試轉換爲子線程需要的數據,默認是直接傳遞,可以重寫這個方法修改拷貝的數據。

InheritableThreadLocal.java

public class InheritableThreadLocal<T> extends ThreadLocal<T> {

    // 參數:父線程的數據
    // 返回值:拷貝到子線程的數據,默認爲直接傳遞
    protected T childValue(T parentValue) {
        return parentValue;
    }

下面,我們來分析 ThreadLocalMap 的源碼。

4. ThreadLocalMap 源碼分析

ThreadLocalMap 是 ThreadLocal 內部使用的散列表,也是 ThreadLocal 的靜態內部類。這一節,我們就來分析 ThreadLocalMap 散列表中主要流程的源碼。

4.1 ThreadLocalMap 的屬性

先用一個表格整理 ThreadLocalMap 的屬性:

屬性 描述
Entry[] table 底層數組
int size 有效鍵值對數量
int threshold 擴容閾值(數組容量的 2/3)
int INITIAL_CAPACITY 默認數組容量(16)

可以看到,散列表必備底層數組 table、鍵值對數量 size、擴容閾值 threshold 等屬性都有,並且也要求數組的長度是 2 的整數倍。主要區別在於 Entry 節點上:

  • 1、ThreadLocal 本身就是散列表的鍵 Key;
  • 2、擴容閾值爲數組容量的 2/3;
  • 3、ThreadLocalMap#Entry 節點沒有 next 指針,因爲 ThreadLocalMap 採用線性探測解決散列衝突,所以不存在鏈表指針;
  • 4、ThreadLocalMap#Entry 在鍵值對的 Key 上使用弱引用,這與 WeakHashMap 相似。

ThreadLocal.java

static class ThreadLocalMap {

    // 默認數組容量(容量必須是 2 的整數倍)
    private static final int INITIAL_CAPACITY = 16;

    // 底層數組
    private Entry[] table;

    // 有效鍵值對數量
    private int size = 0;

    // 擴容閾值
    private int threshold; // Default to 0

    private void setThreshold(int len) {
        threshold = len * 2 / 3;
    }

    // 鍵值對節點
    static class Entry extends WeakReference<ThreadLocal<?>> {
        // next:開放尋址法沒有 next 指針
        // Key:與 WeakHashMap 相同,少了 key 的強引用
        // Hash:位於 ThreadLocal#threadLocalHashCode
        // Value:當前線程的副本
        Object value;

        Entry(ThreadLocal<?> k, Object v) {
            super(k/*注意:只有 Key 是弱引用*/);
            value = v;
        }
    }
}

不出意外的話又有小朋友出來舉手提問了🙋🏻♀️

  • 🙋🏻♀️疑問 3:爲什麼 ThreadLocalMap 要求數組的容量是 2 的整數冪?(回答過多少次了,把手給我放下)

  • 🙋🏻♀️疑問 4:爲什麼 Key 是弱引用,而不是 Entry 或 Value 是弱引用?

首先,Entry 一定要持有強引用,而不能持有弱引用。這是因爲 Entry 是 ThreadLocalMap 內部維護數據結構的實現細節,並不會暴露到 ThreadLocalMap 外部,即除了 ThreadLocalMap 本身之外沒有其它地方持有 Entry 的強引用。所以,如果持有 Entry 的弱引用,即使 ThreadLocalMap 外部依然在使用 Key 對象,ThreadLocalMap 內部依然會回收鍵值對,這與預期不符。

其次,不管是 Key 還是 Value 使用弱引用都可以實現自動清理,至於使用哪一種方法各有優缺點,適用場景也不同。Key 弱引用的優點是外部不需要持有 Value 的強引用,缺點是存在 “重建 Key 不等價” 問題。

由於 ThreadLocal 的應用場景是線程局部存儲,我們沒有重建多個 ThreadLocal 對象指向同一個鍵值對的需求,也沒有重寫 Object#equals() 方法,所以不存在重建 Key 的問題,使用 Key 弱引用更方便。

類型 優點 缺點 場景
Key 弱引用 外部不需要持有 Value 的強引用,使用更簡單 重建 Key 不等價 未重寫 equals
Value 弱引用 重建 Key 等價 外部需要持有 Value 的強引用 重寫 equals

提示: 關於 “重建 Key 對象不等價的問題” 的更多詳細論述過程,我們在這篇文章裏討論過 《WeakHashMap 和 HashMap 的區別是什麼,何時使用?》,去看看。

4.2 ThreadLocalMap 的構造方法

ThreadLocalMap 有 2 個構造方法:

  • 1、帶首個鍵值對的構造方法: 在首次添加元素或首次查詢數據生成缺省值時,纔會調用此構造方法創建 ThreadLocalMap 對象,並添加首個鍵值對;

  • 2、帶 Map 的構造方法: 在創建子線程時,父線程會調用此構造方法創建 ThreadLocalMap 對象,並添加批量父線程 ThreadLocalMap 中的有效鍵值對。

ThreadLocal.java

// 帶首個鍵值對的構造方法
void createMap(Thread t, T firstValue) {
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}

// 帶 Map 的構造方法
static ThreadLocalMap createInheritedMap(ThreadLocalMap parentMap) {
    return new ThreadLocalMap(parentMap);
}

static class ThreadLocalMap {

    // -> 帶首個鍵值對的構造方法
    ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
        // 創建底層數組(默認長度爲 16)
        table = new Entry[INITIAL_CAPACITY];
        // 散列值轉數組下標
        int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
        // 添加首個元素(首個元素一定不會衝突)
        table[i] = new Entry(firstKey, firstValue);
        // 鍵值對數量
        size = 1;
        // 設置擴容閾值
        setThreshold(INITIAL_CAPACITY);
    }

    // -> 帶 Map 的構造方法
    private ThreadLocalMap(ThreadLocalMap parentMap) {
        Entry[] parentTable = parentMap.table;
        int len = parentTable.length;
        // 設置擴容閾值
        setThreshold(len);
        // 創建底層數組(使用 parent 的長度)
        table = new Entry[len];

        // 逐個添加鍵值對
        for (int j = 0; j < len; j++) {
            Entry e = parentTable[j];
            if (e != null) {
                // 如果鍵值對的 Key 被回收,則跳過
                ThreadLocal<Object> key = (ThreadLocal<Object>) e.get();
                if (key != null) {
                    // 構造新的鍵值對
                    Object value = key.childValue(e.value);
                    Entry c = new Entry(key, value);
                    // 散列值轉數組下標
                    int h = key.threadLocalHashCode & (len - 1);
                    // 處理散列衝突
                    while (table[h] != null)
                        // 線性探測
                        h = nextIndex(h, len);
                    table[h] = c;
                    // 鍵值對數量
                    size++;
                }
            }
        }
    }
}

4.3 回顧線性探測的工作原理

ThreadLocalMap 後續的源碼有難度,爲了幫助理解,我將文章 “第一節 · 回顧散列表的工作原理” 中有關線性探測方法的部分移在這裏。

  • 添加鍵值對: 先將散列值取餘映射到數組下標,然後從數組下標位置開始探測與目標 Key 相等的節點。如果找到,則將舊 Value 替換爲新 Value,否則沿着數組順序線性探測。直到線性探測遇到空閒位置,則說明節點不存在,需要添加新節點。如果在添加鍵值對後數組沒有空閒位置,就觸發擴容;

  • 查找鍵值對: 查找類似。也是先將散列值映射到數組下標,然後從數組下標位置開始線性探測。直到線性探測遇到空閒位置,則說明節點不存在;

  • 刪除鍵值對: 刪除類似。由於查找操作在遇到空閒位置時,會認爲鍵值對不存在於散列表中,如果刪除操作時 “真刪除”,就會使得一組連續段產生斷層,導致查找操作失效。因此,刪除操作要做 “假刪除”,刪除操作只是將節點標記爲 “Deleted”,查找操作在遇到 “Deleted” 標記的節點時會繼續向下探測。

開放尋址法示意圖

可以看到,在線性探測中的 “連續段” 非常重要: 線性探測在判斷節點是否存在於散列表時,並不是線性遍歷整個數組,而只會線性遍歷從散列值映射的數組下標後的連續段。

4.4 ThreadLocalMap 的獲取方法

ThreadLocalMap 的獲取方法相對簡單,所以我們先分析,區分 2 種情況:

  • 1、數組下標直接命中目標 Key,則直接返回,也不清理無效數據(這就是前文提到訪問 ThreadLocal 不一定會觸發清理的源碼體現);
  • 2、數組下標未命中目標 Key,則開始線性探測。探測過程中如果遇到 Key == null 的無效節點,則會調用 expungeStaleEntry() 清理連續段(說明即使觸發清理,也不一定會掃描整個散列表)。

expungeStaleEntry() 是 ThreadLocalMap 核心的連續段清理方法,下文提到的 replaceStaleEntry() 和 cleanSomeSlots() 等清理方法都會直接或間接調用到 expungeStaleEntry()。 它的邏輯很簡單:就是線性遍歷從 staleSlot 位置開始的連續段:

  • 1、k == null 的無效節點: 清理;
  • 2、k ≠ null 的有效節點,再散列到新的位置上。

ThreadLocalMap#getEntry 方法示意圖

不出意外的話又有小朋友出來舉手提問了🙋🏻♀️

  • 🙋🏻♀️疑問 5:清理無效節點我理解,爲什麼要對有效節點再散列呢?

線性探測只會遍歷連續段,而清理無效節點會導致連續段產生斷層。如果沒有對有效節點做再散列,那麼有效節點在下次查詢時就有可能探測不到了。

ThreadLocal.java

static class ThreadLocalMap {
        
    // 獲取 Key 匹配的鍵值對
    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)
                // Key 對象被回收,觸發連續段清理
                // 連續段清理在一個 while 循環中只會觸發一次,因爲這個段中 k == null 的節點都被清理出去了
                // 如果連續段清理後,i 位置爲 null,那麼目標節點一定不存在
                expungeStaleEntry(i);
            else
                // 未命中,探測下一個位置
                i = nextIndex(i, len);
            e = tab[i];
        }
        return null;
    }

    // -> 清理連續段中無效數據
    // staleSlot:起點
    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 {
                // 疑問 5:清理無效節點我理解,爲什麼要對有效節點再散列呢?
                // 再散列有效節點
                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;
    }

    // -> 線性探測下一個數組位置
    private static int nextIndex(int i, int len) {
        return ((i + 1 < len) ? i + 1 : 0);
    }
}

4.5 ThreadLocalMap 的添加方法

ThreadLocalMap#set 的流程非常複雜,我將主要步驟概括爲 6 步:

  • 1、先將散列值映射到數組下標,並且開始線性探測;
  • 2、如果探測中遇到目標節點,則將舊 Value 更新爲新 Value;
  • 3、如果探測中遇到無效節點,則會調用 replaceStaleEntry() 清理連續段並添加鍵值對;
  • 4、如果未探測到目標節點或無效節點,則創建並添加新節點;
  • 5、添加新節點後調用 cleanSomeSlots() 方法清理部分數據;
  • 6、如果沒有發生清理並且達到擴容閾值,則觸發 rehash() 擴容。

replaceStaleEntry(): 清理連續段中的無效節點的同時,如果目標節點存在則更新 Value 後替換到 staleSlot 無效節點位置,如果不存在則創建新節點替換到 staleSlot 無效節點位置。

cleanSomeSlots(): 對數式清理,清理複雜度比全數組清理低,在大多數情況只會掃描 log(len) 個元素。如果掃描過程中遇到無效節點,則從該位置執行一次連續段清理,再從連續段的下一個位置重新掃描 log(len) 個元素,直接結束對數掃描。

ThreadLocalMap#set 示意圖

ThreadLocal.java

static class ThreadLocalMap {

    private void set(ThreadLocal<?> key, Object value) {
        Entry[] tab = table;
        int len = tab.length;
        // 1、散列值轉數組下標
        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) {
                // 2、命中,將舊 Value 替換爲新 Value
                e.value = value;
                return;
            }

            if (k == null) {
                // 3、清理無效節點,並插入鍵值對
                replaceStaleEntry(key, value, i);
                return;
            }
        }

        // 4、如果未探測到目標節點或無效節點,則創建並添加新節點
        tab[i] = new Entry(key, value);
        int sz = ++size;
        // cleanSomeSlots:清理部分數據
        // 5、添加新節點後調用 cleanSomeSlots() 方法清理部分數據
        if (!cleanSomeSlots(i, sz /*有效數據個數*/) && sz >= threshold)
            // 6、如果沒有發生清理並且達到擴容閾值,則觸發 rehash() 擴容
            rehash();
    }

    // -> 3、清理無效節點,並插入鍵值對
    // key-value:插入的鍵值對
    private void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) {
        Entry[] tab = table;
        int len = tab.length;
        Entry e;

        // slotToExpunge:記錄清理的起點
        int slotToExpunge = staleSlot;
        // 3.1 向前探測找到連續段中的第一個無效節點
        for (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len))
            if (e.get() == null)
                slotToExpunge = i;

        // 3.2 向後探測目標節點
        for (int i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
            ThreadLocal<?> k = e.get();

            if (k == key) {
                // 3.2.1 命中,將目標節點替換到 staleSlot 位置
                e.value = value;
                tab[i] = tab[staleSlot];
                tab[staleSlot] = e;

                // 3.2.2 如果連續段在 staleSlot 之前沒有無效節點,則從 staleSlot 的下一個無效節點開始清理
                if (slotToExpunge == staleSlot)
                    slotToExpunge = i;
                // 3.2.3 如果連續段中還有其他無效節點,則清理
                // expungeStaleEntry:連續段清理
                // cleanSomeSlots:對數式清理
                cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
                return;
            }

            // 如果連續段在 staleSlot 之前沒有無效節點,則從 staleSlot 的下一個無效節點開始清理
            if (k == null && slotToExpunge == staleSlot)
                slotToExpunge = i;
        }

        // 3.3 創建新節點並插入 staleSlot 位置
        tab[staleSlot].value = null;
        tab[staleSlot] = new Entry(key, value);

        // 3.4 如果連續段中還有其他無效節點,則清理
        if (slotToExpunge != staleSlot)
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len /*數組長度*/);
    }

    // 5、對數式清理
    // i:起點
    // 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) {
                // 發現無效節點,重新探測 log2(len)
                n = len;
                removed = true;
                // 連續段清理
                i = expungeStaleEntry(i);
            }
        } while ( (n >>>= 1) != 0); // 探測 log2(len)
        return removed;
    }
}

4.6 ThreadLocalMap 的擴容方法

ThreadLocalMap 的擴容方法相對於添加方法比較好理解。在添加方法中,如果添加鍵值對後散列值的長度超過擴容閾值,就會調用 rehash() 方法擴容,主體流程分爲 3步:

  • 1、先完整掃描散列表清理無效數據,清理後用較低的閾值判斷是否需要擴容;
  • 2、創建新數組;
  • 3、將舊數組上無效的節點忽略,將有效的節點再散列到新數組上。

ThreadLocaoMap#rehash 示意圖

ThreadLocal.java

static class ThreadLocalMap {

    // 擴容(在容量到達 threshold 擴容閾值時調用)
    private void rehash() {
        // 1、全數組清理
        expungeStaleEntries();
        
        // 2、用較低的閾值判斷是否需要擴容
        if (size >= threshold - threshold / 4)
            // 3、真正執行擴容
            resize();
    }

    // -> 1、完整散列表清理
    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)
                // 很奇怪爲什麼不修改 j 指針
                expungeStaleEntry(j);
        }
    }

    // -> 3、真正執行擴容
    private void resize() {
        Entry[] oldTab = table;
        // 擴容爲 2 倍
        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) {
                    // 清除無效鍵值的 Value
                    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;
    }
}

4.7 ThreadLocalMap 的移除方法

ThreadLocalMap 的移除方法是添加方法的逆運算,ThreadLocalMap 也沒有做動態縮容。

與常規的移除操作不同的是,ThreadLocalMap 在刪除時會執行 expungeStaleEntry() 清除無效節點,並對連續段中的有效節點做再散列,所以 ThreadLocalMap 是 “真刪除”。

ThreadLocal.java

static class ThreadLocalMap {

    // 移除
    private void remove(ThreadLocal<?> key) {
        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)]) {
            if (e.get() == key) {
                // 清除弱引用關係
                e.clear();
                // 清理連續段
                expungeStaleEntry(i);
                return;
            }
        }
    }
}

4.8 ThreadLocalMap 複雜度分析

總結下 ThreadLocalMap 的時間複雜度,以下 K 爲連續段的長度,N 是數組長度。

  • 獲取方法: 平均時間複雜度爲 O(K);
  • 添加方法: 平均時間複雜度爲 O(K),在觸發擴容的添加操作中時間複雜度爲 O(N),基於攤還分析後時間複雜度依然是 O(K);
  • 移除方法: 移除是 “真刪除”,平均時間複雜度爲 O(K)。

4.9 訪問 ThreadLocal 一定會清理無效數據嗎?

不一定。只有擴容會觸發完整散列表清理,其他情況都不能保證清理,甚至不會觸發。


5. 總結

  • 1、ThreadLocal 是一種特殊的無鎖線程安全方式,通過爲每個線程分配獨立的資源副本,從根本上避免發生資源衝突;
  • 2、ThreadLocal 在所有線程間隔離,InheritableThreadLocal 在創建子線程時會拷貝父線程中 InheritableThreadLocal 的有效鍵值對;
  • 3、雖然 ThreadLocal 提供了自動清理數據的能力,但是自動清理存在滯後性。爲了避免內存泄漏,在業務開發中應該及時調用 remove 清理無效的局部存儲;
  • 4、ThreadLocal 是採用線性探測解決散列衝突的散列表。

ThreadLocal 思維導圖


參考資料

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