ThreadLocal 的應用及原理

2023-05-25 15:00:23

1. 是什麼

JDK 對 ThreadLocal 類的描述為:

此類提供執行緒區域性變數。這些變數與普通變數的不同之處在於,每個存取一個變數的執行緒(通過其get或set方法)都有自己的、獨立初始化的變數副本。ThreadLocal 範例通常是類中的私有靜態欄位,這些欄位希望將狀態與執行緒(例如,使用者ID或事務ID)相關聯。

說白了,ThreadLocal 就是用來存放執行緒自身相關資料的一個容器,這個容器叫做ThreadLocalMap,它是 Thread 類的一個成員變數,它本身也是一個雜湊表,key 是 ThreadLocal 本身,value 是存入的變數。也就是說,變數是存在當前執行緒的一個ThreadLocalMap中,每個執行緒在取這個變數的時候,就是取執行緒自己的本地變數,自然是執行緒安全的了,所以說 ThreadLocal 提供執行緒區域性變數,或者叫本地變數。

ThreadLocal 的特點有3個關鍵點:

  1. 執行緒並行:在多執行緒並行的場景下使用。
  2. 資料傳遞:通過 ThreadLocal ,在同一個執行緒中,不同元件中傳遞公共變數。
  3. 執行緒隔離:不同執行緒之間互不干擾,這種變數線上程的生命週期內起作用。

2. 怎麼用

ThreadLocal 的常用方法有:

  1. public ThreadLocal():通過構造器建立物件。一般是靜態的。
  2. <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier):初始化一個 ThreadLcoal。
  3. void set(T value):設定當前執行緒繫結的區域性變數。
  4. T get():獲取當前執行緒繫結的區域性變數。
  5. void remove():刪除當前執行緒當定的區域性變數。

2.1 使用入門

2.1.1 原始版本

現在模擬一個需求,一個執行緒在業務開始時初始化一個使用者 id(類似在一次web請求中上下文中初始化一下使用者資訊),業務結束時獲取這個使用者 id(比如用來列印紀錄檔,或者作為一個公共變數運用到業務編碼中),存在多個這樣的執行緒。

public class ThreadLocalTest {
    private String userId;

    private String getUserId() {
        return userId;
    }

    private void setUserId(String userId) {
        this.userId = userId;
    }

    public static void main(String[] args) {
        ThreadLocalTest test = new ThreadLocalTest();
        for (int i = 1; i < 6; i++) {
            Thread thread = new Thread(() -> {
                // 當前執行緒初始化userId
                test.setUserId(Thread.currentThread().getName() + "的userId");
                // 執行其他業務程式碼
                System.out.println("===執行業務程式碼===");
                // 當前執行緒獲取userId
                System.out.println(Thread.currentThread().getName() + "->" + test.getUserId());
            });
            thread.setName("執行緒" + i);
            thread.start();
        }
    }
}

一種可能的結果:

===執行業務程式碼===
執行緒2->執行緒1的userId
===執行業務程式碼===
執行緒1->執行緒3的userId
===執行業務程式碼===
執行緒3->執行緒3的userId
===執行業務程式碼===
執行緒4->執行緒4的userId

由於執行緒排程的不確定性,可能執行緒1執行到一半,切換到了執行緒2,於是執行緒2獲取到的 userId 是執行緒1設定的。也就是說,每個執行緒之間的變數不是隔離的,造成資料錯誤。

2.1.2 ThreadLocal 版本

每個執行緒中的變數都存放到自己的執行緒當中,所以這些變數叫做執行緒區域性變數很形象。

public class ThreadLocalTest {
    private static ThreadLocal<String> context = new ThreadLocal<>();

    private String getUserId() {
        return context.get();
    }

    private void setUserId(String userId) {
        context.set(userId);
    }

    public static void main(String[] args) {
        ThreadLocalTest test = new ThreadLocalTest();
        for (int i = 1; i < 5; i++) {
            Thread thread = new Thread(() -> {
                test.setUserId(Thread.currentThread().getName() + "的userId");
                System.out.println("===執行業務程式碼===");
                System.out.println(Thread.currentThread().getName() + "->" + test.getUserId());
                context.remove(); // 使用完清理執行緒區域性變數
            });
            thread.setName("執行緒" + i);
            thread.start();
        }
    }
}

這樣每個執行緒就互不干擾,不會取錯變數值。一種可能的結果如下:

===執行業務程式碼===
執行緒1->執行緒1的userId
===執行業務程式碼===
執行緒4->執行緒4的userId
===執行業務程式碼===
執行緒2->執行緒2的userId
===執行業務程式碼===
執行緒3->執行緒3的userId

2.1.3 synchronized 版本

如果只看結果的正確性,用 synchronized 給業務程式碼塊加鎖也是可以完成的。如下:

Thread thread = new Thread(() -> {
    synchronized (ThreadLocalTest.class) {
        test.setUserId(Thread.currentThread().getName() + "的userId");
        System.out.println("===執行業務程式碼===");
        System.out.println(Thread.currentThread().getName() + "->" + test.getUserId());
    }
});

這樣完全可以實現需求,但是 synchronized 的問題是什麼呢?我們總說誰誰誰是執行緒安全的類,因為它有 synchronized 修飾。就是因為 synchronized 讓多執行緒變成了單執行緒,它一次只允許一個執行緒執行,它能不安全嗎?但它帶來的代價是效能的下降,它不能並行執行,而 ThreadLocal 可以並行執行。

2.1.4 ThreadLocal 和 synchronized 對比

綜上,synchronized 和 ThreadLocal 兩個處理問題的角度和場景是不同的。

  • synchronized 的側重點在於保證操作的原子性,保證並行場景下共用變數的資料一致性。
  • ThreadLocal 強調執行緒隔離性,不同的執行緒互不干擾,保證並行場景下資料傳遞的正確性。在web請求上下文中較為常見。

3. ThreadLocal 原理

3.1 程式碼結構

ThreadLocal 的原理要從它的set(T value)get()方法的原始碼入手。在 set 值的時候,首先會獲取當前執行緒一個的成員變數ThreadLocalMapThreadLocalMap的 key 是當前ThreadLocal物件,value 是要存入的值。這個 key 和 value 會存到哪裡呢?ThreadLocalMap還有個內部類Entry,這個Entry繼承了WeakReference,key 賦值給弱參照,也就是當前的ThreadLocal物件,value 則賦值給Entry的成員變數valueThreadLocalMap也是一個雜湊表(所謂雜湊表,也叫雜湊表,它基於陣列,通過某種雜湊演演算法計算出一系列關鍵字對應的雜湊值,然後以這些雜湊值作為陣列索引將資料存放到對應位置,達到快速查詢的目的),它內部維護一個Entry陣列,來儲存鍵值對。存資料的時候也是通過雜湊函數計算ThreadLocal 物件對應的陣列下標,然後放入Entry陣列中。

3.2 記憶體漏失問題

ThreadLocal 會發生記憶體漏失嗎?我們結合程式碼慢慢分析。

在 2.1.1 節中有這樣的程式碼:

public class ThreadLocalTest {
    private static ThreadLocal<String> threadLocal = new ThreadLocal<>();

    private void setUserId(String userId) {
        threadLocal.set(userId);
    }
    // ...
}

首先,我們new了一個 ThreadLocal 物件,這裡存在一個強參照:threadLocal參照變數指向 ThreadLocal 物件。其次,當其他執行緒執行setUserId方法時,ThreadLocal 的set方法最終是把資料存到了ThreadLocalMap中的Entry,看原始碼我們會發現,存資料最終是呼叫Entry的構造器Entry(ThreadLocal<?> k, Object v)完成的,而k這個引數是傳入的this物件,說明什麼?我們使用 ThreadLocal 物件呼叫set,那this肯定是當前new出來的 ThreadLocal 物件!再次說明,我們new出來的 ThreadLocal 物件有兩個參照指向它:

  1. threadLocal變數的強參照。
  2. Entry中的弱參照。

此時再看一張圖(這張圖被廣泛參照,感謝原圖作者