全網最全的 ThreadLocal 原理詳細解析 —— 原理篇
theme: jzman
本文已收錄到 AndroidFamily,技術和職場問題,請關注公眾號 [彭旭銳] 提問。
前言
大家好,我是小彭。
在前面的文章裡,我們聊到了散列表的開放定址法和分離連結串列法,也聊到了 HashMap、LinkedHashMap 和 WeakHashMap 等基於分離連結串列法實現的散列表。
今天,我們來討論 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#get
或 ThreadLocal#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
```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
```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()
方法修改拷貝到子執行緒的資料。
```java
public class InheritableThreadLocal
// 引數:父執行緒的資料
// 返回值:拷貝到子執行緒的資料,預設為直接傳遞
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 的學習案例:
```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);
}
...
} ```
示例程式碼
```java 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
```java
private static final ThreadLocal
// 使用: 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、…
```java
public class ThreadLocal
// 疑問 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 使用。
常規做法
```java
public class ThreadLocal
// 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()
建立並設定預設值。
- 4.1 如果未命中(Map 取值為 nul),則會呼叫
- 5、ThreadLocal#withInitial: 方便設定預設值,而不需要實現子類。
在 ThreadLocal 的 API 會通過 getMap() 方法獲取當前執行緒的 Thread 物件中的 threadLocals 欄位,這是執行緒隔離的關鍵。
ThreadLocal.java
```java public ThreadLocal() { // do nothing }
// 子類可重寫此方法設定預設值(方法命名為 defaultValue 獲取更貼切) protected T initialValue() { // 預設不提供預設值 return null; }
// 幫助方法:不重寫 ThreadLocal 也可以設定預設值
// supplier:預設值建立工廠
public static ThreadLocal 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
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
```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
```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
```java
public class InheritableThreadLocal
// 引數:父執行緒的資料
// 返回值:拷貝到子執行緒的資料,預設為直接傳遞
protected T childValue(T parentValue) {
return parentValue;
}
```
下面,我們來分析 ThreadLocalMap 的原始碼。
後續原始碼分析,見下一篇文章:全網比較全的 ThreadLocal 原理詳細解析 —— 原始碼篇。
版權宣告
本文為稀土掘金技術社群首發簽約文章,14天內禁止轉載,14天后未獲授權禁止轉載,侵權必究!
參考資料
- 資料結構與演算法分析 · Java 語言描述(第 5 章 · 雜湊)—— [美] Mark Allen Weiss 著
- 演算法導論(第 11 章 · 散列表)—— [美] Thomas H. Cormen 等 著
- 《阿里巴巴Java開發手冊》 楊冠寶 編著
- 資料結構與演算法之美(第 18~22 講) —— 王爭 著,極客時間 出品
- ThreadLocal 和 ThreadLocalMap原始碼分析 —— KingJack 著
- Why 0x61c88647? —— Dr. Heinz M. Kabutz 著
- LeetCode 周賽 336,多少人直接 CV?
- LeetCode 周賽 335,純純手速場!
- LeetCode 雙週賽 98,腦筋急轉彎轉不過來!
- Android IO 框架 Okio 的實現原理,到底哪裡 OK?
- 12 張圖看懂 CPU 快取一致性與 MESI 協議,真的一致嗎?
- Android 序列化框架 Gson 原理分析,可以優化嗎?
- 為什麼計算機中的負數要用補碼錶示?
- 什麼是二叉樹?
- 我把 CPU 三級快取的祕密,藏在這 8 張圖裡
- 全網最全的 ThreadLocal 原理詳細解析 —— 原理篇
- 程式設計師學習 CPU 有什麼用?
- WeakHashMap 和 HashMap 的區別是什麼,何時使用?
- 萬字 HashMap 詳解,基礎(優雅)永不過時 —— 原理篇
- Java 面試題:說一下 ArrayDeque 和 LinkedList 的區別?
- Java 面試題:說一下 ArrayList 和 LinkedList 的區別?
- Java 面試題:ArrayList 可以完全替代陣列嗎?
- 已經有 MESI 協議,為什麼還需要 volatile 關鍵字?
- JVM 系列(6)吊打面試官:為什麼 finalize() 方法只會執行一次?
- 使用字首和陣列解決"區間和查詢"問題
- NDK 系列(5):JNI 從入門到實踐,萬字爆肝詳解!