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

2023-02-09 06:02:54

本文已收錄到 AndroidFamily,技術和職場問題,請關注公眾號 [彭旭銳] 提問。

前言

大家好,我是小彭。

在前面的文章裡,我們聊到了雜湊表的開放定址法和分離連結串列法,也聊到了 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 {
    // 詳細原始碼分析見下文 ...
}

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