一文搞懂 ThreadLocal 原理
阿新 • • 發佈:2020-04-05
當多執行緒訪問共享可變資料時,涉及到執行緒間同步的問題,並不是所有時候,都要用到共享資料,所以就需要執行緒封閉出場了。
資料都被封閉在各自的執行緒之中,就不需要同步,這種通過將資料封閉線上程中而避免使用同步的技術稱為**執行緒封閉**。
本文主要介紹執行緒封閉中的其中一種體現:ThreadLocal,將會介紹什麼是 ThreadLocal;從 ThreadLocal 原始碼角度分析,最後介紹 ThreadLocal 的應用場景。
## 什麼是 ThreadLocal?
ThreadLocal 是 Java 裡一種特殊變數,它是一個執行緒級別變數,每個執行緒都有一個 ThreadLocal 就是每個執行緒都擁有了自己獨立的一個變數,競態條件被徹底消除了,在併發模式下是絕對安全的變數。
可以通過 `ThreadLocal value = new ThreadLocal();` 來使用。
會自動在每一個執行緒上建立一個 T 的副本,副本之間彼此獨立,互不影響,可以用 ThreadLocal 儲存一些引數,以便線上程中多個方法中使用,用以代替方法傳參的做法。
下面通過例子來了解下 ThreadLocal:
```
public class ThreadLocalDemo {
/**
* ThreadLocal變數,每個執行緒都有一個副本,互不干擾
*/
public static final ThreadLocal THREAD_LOCAL = new ThreadLocal<>();
public static void main(String[] args) throws Exception {
new ThreadLocalDemo().threadLocalTest();
}
public void threadLocalTest() throws Exception {
// 主執行緒設定值
THREAD_LOCAL.set("wupx");
String v = THREAD_LOCAL.get();
System.out.println("Thread-0執行緒執行之前," + Thread.currentThread().getName() + "執行緒取到的值:" + v);
new Thread(new Runnable() {
@Override
public void run() {
String v = THREAD_LOCAL.get();
System.out.println(Thread.currentThread().getName() + "執行緒取到的值:" + v);
// 設定 threadLocal
THREAD_LOCAL.set("huxy");
v = THREAD_LOCAL.get();
System.out.println("重新設定之後," + Thread.currentThread().getName() + "執行緒取到的值為:" + v);
System.out.println(Thread.currentThread().getName() + "執行緒執行結束");
}
}).start();
// 等待所有執行緒執行結束
Thread.sleep(3000L);
v = THREAD_LOCAL.get();
System.out.println("Thread-0執行緒執行之後," + Thread.currentThread().getName() + "執行緒取到的值:" + v);
}
}
```
首先通過 `static final` 定義了一個 `THREAD_LOCAL` 變數,其中 `static` 是為了確保全域性只有一個儲存 String 物件的 ThreadLocal 例項;`final` 確保 ThreadLocal 的例項不可更改,防止被意外改變,導致放入的值和取出來的不一致,另外還能防止 ThreadLocal 的記憶體洩漏。上面的例子是演示在不同的執行緒中獲取它會得到不同的結果,執行結果如下:
```
Thread-0執行緒執行之前,main執行緒取到的值:wupx
Thread-0執行緒取到的值:null
重新設定之後Thread-0執行緒取到的值為:huxy
Thread-0執行緒執行結束
Thread-0執行緒執行之後,main執行緒取到的值:wupx
```
首先在 `Thread-0` 執行緒執行之前,先給 `THREAD_LOCAL` 設定為 `wupx`,然後可以取到這個值,然後通過建立一個新的執行緒以後去取這個值,發現新執行緒取到的為 null,意外著這個變數在不同執行緒中取到的值是不同的,不同執行緒之間對於 ThreadLocal 會有對應的副本,接著線上程 `Thread-0` 中執行對 `THREAD_LOCAL` 的修改,將值改為 `huxy`,可以發現執行緒 `Thread-0` 獲取的值變為了 `huxy`,主執行緒依然會讀取到屬於它的副本資料 `wupx`,這就是執行緒的封閉。
看到這裡,我相信大家一定會好奇 ThreadLocal 是如何做到多個執行緒對同一物件 set 操作,但是 get 獲取的值還都是每個執行緒 set 的值呢,接下來就讓我們進入原始碼解析環節:
## ThreadLocal 原始碼解析
首先看下 ThreadLocal 都有哪些重要屬性:
```
// 當前 ThreadLocal 的 hashCode,由 nextHashCode() 計算而來,用於計算當前 ThreadLocal 在 ThreadLocalMap 中的索引位置
private final int threadLocalHashCode = nextHashCode();
// 雜湊魔數,主要與斐波那契雜湊法以及黃金分割有關
private static final int HASH_INCREMENT = 0x61c88647;
// 返回計算出的下一個雜湊值,其值為 i * HASH_INCREMENT,其中 i 代表呼叫次數
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
// 保證了在一臺機器中每個 ThreadLocal 的 threadLocalHashCode 是唯一的
private static AtomicInteger nextHashCode = new AtomicInteger();
```
其中的 `HASH_INCREMENT` 也不是隨便取的,它轉化為十進位制是 `1640531527`,`2654435769` 轉換成 int 型別就是 `-1640531527`,`2654435769` 等於 `(√5-1)/2` 乘以 2 的 32 次方。`(√5-1)/2` 就是黃金分割數,近似為 `0.618`,也就是說 `0x61c88647` 理解為一個黃金分割數乘以 2 的 32 次方,它可以保證 nextHashCode 生成的雜湊值,均勻的分佈在 2 的冪次方上,且小於 2 的 32 次方。
下面是 javaspecialists 中一篇文章對它的介紹:
> This number represents the golden ratio (sqrt(5)-1) times two to the power of 31 ((sqrt(5)-1) * (2^31)). The result is then a golden number, either 2654435769 or -1640531527.
下面用例子來證明下:
```
private static final int HASH_INCREMENT = 0x61c88647;
public static void main(String[] args) throws Exception {
int n = 5;
int max = 2 << (n - 1);
for (int i = 0; i < max; i++) {
System.out.print(i * HASH_INCREMENT & (max - 1));
System.out.print(" ");
}
}
```
執行結果為:`0 7 14 21 28 3 10 17 24 31 6 13 20 27 2 9 16 23 30 5 12 19 26 1 8 15 22 29 4 11 18 25 `
可以發現元素索引值完美的雜湊在陣列當中,並沒有出現衝突。
### ThreadLocalMap
除了上述屬性外,還有一個重要的屬性 ThreadLocalMap,ThreadLocalMap 是 ThreadLocal 的靜態內部類,當一個執行緒有多個 ThreadLocal 時,需要一個容器來管理多個 ThreadLocal,ThreadLocalMap 的作用就是管理執行緒中多個 ThreadLocal,原始碼如下:
```
static class ThreadLocalMap {
/**
* 鍵值對實體的儲存結構
*/
static class Entry extends WeakReference> {
// 當前執行緒關聯的 value,這個 value 並沒有用弱引用追蹤
Object value;
/**
* 構造鍵值對
*
* @param k k 作 key,作為 key 的 ThreadLocal 會被包裝為一個弱引用
* @param v v 作 value
*/
Entry(ThreadLocal> k, Object v) {
super(k);
value = v;
}
}
// 初始容量,必須為 2 的冪
private static final int INITIAL_CAPACITY = 16;
// 儲存 ThreadLocal 的鍵值對實體陣列,長度必須為 2 的冪
private Entry[] table;
// ThreadLocalMap 元素數量
private int size = 0;
// 擴容的閾值,預設是陣列大小的三分之二
private int threshold;
}
```
從原始碼中看到 ThreadLocalMap 其實就是一個簡單的 Map 結構,底層是陣列,有初始化大小,也有擴容閾值大小,陣列的元素是 Entry,**Entry 的 key 就是 ThreadLocal 的引用,value 是 ThreadLocal 的值**。ThreadLocalMap 解決 hash 衝突的方式採用的是**線性探測法**,如果發生衝突會繼續尋找下一個空的位置。
這樣的就有可能會發生記憶體洩漏的問題,下面讓我們進行分析:
### ThreadLocal 記憶體洩漏
ThreadLocal 在沒有外部強引用時,發生 GC 時會被回收,那麼 ThreadLocalMap 中儲存的 key 值就變成了 null,而 Entry 又被 threadLocalMap 物件引用,threadLocalMap 物件又被 Thread 物件所引用,那麼當 Thread 一直不終結的話,value 物件就會一直存在於記憶體中,也就導致了記憶體洩漏,直至 Thread 被銷燬後,才會被回收。
那麼如何避免記憶體洩漏呢?
在使用完 ThreadLocal 變數後,需要我們手動 remove 掉,防止 ThreadLocalMap 中 Entry 一直保持對 value 的強引用,導致 value 不能被回收,其中 remove 原始碼如下所示:
```
/**
* 清理當前 ThreadLocal 物件關聯的鍵值對
*/
public void remove() {
// 返回當前執行緒持有的 map
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null) {
// 從 map 中清理當前 ThreadLocal 物件關聯的鍵值對
m.remove(this);
}
}
```
remove 方法的時序圖如下所示:
![](https://img-blog.csdnimg.cn/20200405150646681.png)
remove 方法是先獲取到當前執行緒的 ThreadLocalMap,並且呼叫了它的 remove 方法,從 map 中清理當前 ThreadLocal 物件關聯的鍵值對,這樣 value 就可以被 GC 回收了。
那麼 ThreadLocal 是如何實現執行緒隔離的呢?
### ThreadLocal 的 set 方法
我們先去看下 ThreadLocal 的 set 方法,原始碼如下:
```
/**
* 為當前 ThreadLocal 物件關聯 value 值
*
* @param value 要儲存在此執行緒的執行緒副本的值
*/
public void set(T value) {
// 返回當前ThreadLocal所在的執行緒
Thread t = Thread.currentThread();
// 返回當前執行緒持有的map
ThreadLocalMap map = getMap(t);
if (map != null) {
// 如果 ThreadLocalMap 不為空,則直接儲存鍵值對
map.set(this, value);
} else {
// 否則,需要為當前執行緒初始化 ThreadLocalMap,並存儲鍵值對
createMap(t, value);
}
}
```
set 方法的作用是把我們想要儲存的 value 給儲存進去。set 方法的流程主要是:
- 先獲取到當前執行緒的引用
- 利用這個引用來獲取到 ThreadLocalMap
- 如果 map 為空,則去建立一個 ThreadLocalMap
- 如果 map 不為空,就利用 ThreadLocalMap 的 set 方法將 value 新增到 map 中
set 方法的時序圖如下所示:
![](https://img-blog.csdnimg.cn/2020040514180914.png)
其中 map 就是我們上面講到的 ThreadLocalMap,可以看到它是通過當前執行緒物件獲取到的 ThreadLocalMap,接下來我們看 getMap方法的原始碼:
```
/**
* 返回當前執行緒 thread 持有的 ThreadLocalMap
*
* @param t 當前執行緒
* @return ThreadLocalMap
*/
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
```
getMap 方法的作用主要是獲取當前執行緒內的 ThreadLocalMap 物件,原來這個 ThreadLocalMap 是執行緒的一個屬性,下面讓我們看看 Thread 中的相關程式碼:
```
/**
* ThreadLocal 的 ThreadLocalMap 是執行緒的一個屬性,所以在多執行緒環境下 threadLocals 是執行緒安全的
*/
ThreadLocal.ThreadLocalMap threadLocals = null;
```
可以看出每個執行緒都有 ThreadLocalMap 物件,被命名為 `threadLocals`,預設為 null,所以每個執行緒的 ThreadLocals 都是隔離獨享的。
呼叫 ThreadLocalMap.set() 時,會把當前 `threadLocal` 物件作為 key,想要儲存的物件作為 value,存入 map。
其中 ThreadLocalMap.set() 的原始碼如下:
```
/**
* 在 map 中儲存鍵值對
*
* @param key threadLocal
* @param value 要設定的 value 值
*/
private void set(ThreadLocal> key, Object value) {
Entry[] tab = table;
int len = tab.length;
// 計算 key 在陣列中的下標
int i = key.threadLocalHashCode & (len - 1);
// 遍歷一段連續的元素,以查詢匹配的 ThreadLocal 物件
for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
// 獲取該雜湊值處的ThreadLocal物件
ThreadLocal> k = e.get();
// 鍵值ThreadLocal匹配,直接更改map中的value
if (k == key) {
e.value = value;
return;
}
// 若 key 是 null,說明 ThreadLocal 被清理了,直接替換掉
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
// 直到遇見了空槽也沒找到匹配的ThreadLocal物件,那麼在此空槽處安排ThreadLocal物件和快取的value
tab[i] = new Entry(key, value);
int sz = ++size;
// 如果沒有元素被清理,那麼就要檢查當前元素數量是否超過了容量闕值(陣列大小的三分之二),以便決定是否擴容
if (!cleanSomeSlots(i, sz) && sz >= threshold) {
// 擴容的過程也是對所有的 key 重新雜湊的過程
rehash();
}
}
```
相信到這裡,大家應該對 Thread、ThreadLocal 以及 ThreadLocalMap 的關係有了進一步的理解,下圖為三者之間的關係:
![](https://img-blog.csdnimg.cn/20200405125741464.png)
### ThreadLocal 的 get 方法
瞭解完 set 方法後,讓我們看下 get 方法,原始碼如下:
```
/**
* 返回當前 ThreadLocal 物件關聯的值
*
* @return
*/
public T get() {
// 返回當前 ThreadLocal 所在的執行緒
Thread t = Thread.currentThread();
// 從執行緒中拿到 ThreadLocalMap
ThreadLocalMap map = getMap(t);
if (map != null) {
// 從 map 中拿到 entry
ThreadLocalMap.Entry e = map.getEntry(this);
// 如果不為空,讀取當前 ThreadLocal 中儲存的值
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T) e.value;
return result;
}
}
// 若 map 為空,則對當前執行緒的 ThreadLocal 進行初始化,最後返回當前的 ThreadLocal 物件關聯的初值,即 value
return setInitialValue();
}
```
get 方法的主要流程為:
- 先獲取到當前執行緒的引用
- 獲取當前執行緒內部的 ThreadLocalMap
- 如果 map 存在,則獲取當前 ThreadLocal 對應的 value 值
- 如果 map 不存在或者找不到 value 值,則呼叫 setInitialValue() 進行初始化
get 方法的時序圖如下所示:
![](https://img-blog.csdnimg.cn/20200405141521235.png)
其中每個 Thread 的 ThreadLocalMap 以 `threadLocal` 作為 key,儲存自己執行緒的 `value` 副本,也就是儲存在每個執行緒中,並沒有儲存在 ThreadLocal 物件中。
其中 ThreadLocalMap.getEntry() 方法的原始碼如下:
```
/**
* 返回 key 關聯的鍵值對實體
*
* @param key threadLocal
* @return
*/
private Entry getEntry(ThreadLocal> key) {
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
// 若 e 不為空,並且 e 的 ThreadLocal 的記憶體地址和 key 相同,直接返回
if (e != null && e.get() == key) {
return e;
} else {
// 從 i 開始向後遍歷找到鍵值對實體
return getEntryAfterMiss(key, i, e);
}
}
```
### ThreadLocalMap 的 resize 方法
當 ThreadLocalMap 中的 ThreadLocal 的個數超過容量閾值時,ThreadLocalMap 就要開始擴容了,我們一起來看下 resize 的原始碼:
```
/**
* 擴容,重新計算索引,標記垃圾值,方便 GC 回收
*/
private void resize() {
Entry[] oldTab = table;
int oldLen = oldTab.length;
int newLen = oldLen * 2;
// 新建一個數組,按照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();
// 若有垃圾值,則標記清理該元素的引用,以便GC回收
if (k == null) {
e.value = null;
} else {
// 計算 ThreadLocal 在新陣列中的位置
int h = k.threadLocalHashCode & (newLen - 1);
// 如果發生衝突,使用線性探測往後尋找合適的位置
while (newTab[h] != null) {
h = nextIndex(h, newLen);
}
newTab[h] = e;
count++;
}
}
}
// 設定新的擴容閾值,為陣列長度的三分之二
setThreshold(newLen);
size = count;
table = newTab;
}
```
resize 方法主要是進行擴容,同時會將垃圾值標記方便 GC 回收,擴容後陣列大小是原來陣列的兩倍。
## ThreadLocal 應用場景
ThreadLocal 的特性也導致了應用場景比較廣泛,主要的應用場景如下:
- 執行緒間資料隔離,各執行緒的 ThreadLocal 互不影響
- 方便同一個執行緒使用某一物件,避免不必要的引數傳遞
- 全鏈路追蹤中的 traceId 或者流程引擎中上下文的傳遞一般採用 ThreadLocal
- Spring 事務管理器採用了 ThreadLocal
- Spring MVC 的 RequestContextHolder 的實現使用了 ThreadLocal
# 總結
本文主要從原始碼的角度解析了 ThreadLocal,並分析了發生記憶體洩漏的原因,最後對它的應用場景進行了簡單介紹。
歡迎留言交流討論,原創不易,覺得文章不錯,請在看轉發支援一下。
更詳細的原始碼解析可以點選連結檢視:https://github.com/wupeixuan/JDKSourceCode1.8
> 參考
>
> 《Java併發程式設計實戰》
>
> https://www.javaspecialists.eu/archive/Issue164.html
>
> https://mp.weixin.qq.com/s/vURwBPgVuv4yGT1PeEHxZQ
>
> Java併發程式設計學習寶典
>
> 面試官系統精講Java原始碼及大廠真題
>
> Java 併發面試 78 講