引言
正如文章標題,本文重點在於剖析ThreadLocal的源碼,先對ThreadLocal下定義
ThreadLocal是線程級別的私有變量
即使你沒有使用過ThreadLocal也可以閲讀,本文會從ThreadLocal最基本的使用入手,結合源碼及圖片由淺入深地分享我在學習ThreadLocal源碼中的收穫和理解,希望對你有幫助.
一、初識ThreadLocalMap
1.1 ThreadLocal的使用
ThreadLocal<String> traceId = new ThreadLocal<>()
//ThreadLocal<String> traceId = ThreadLocal.withInitial(()->"initial");
...
traceId.set(UUID.randomUUID().toString);
...
traceId.get();
...
traceId.remove()
這是一個ThreadLocal精簡後的傳統使用場景,在過去的web應用裏,請求到達服務器以後可以分配一個traceId用於追蹤請求到響應的鏈路,或者存儲單個請求內的用户身份信息等等.儘管最終執行的時候分發到了不同的線程內,每個線程內可見的數據依舊是自己的用户身份信息或者traceId,在當前線程內的所有地方都可以通過ThreadLocal實例.get()的方式獲取到這個信息.得益於ThreadLocal的線程隔離性,無需擔心當前線程會取到其他線程的結果.
之所以説是在過去的web應用,是因為在當下一條請求到達服務器以後幾乎一定會經歷異步執行或途經多個微服務,這時ThreadLocal作為線程隔離的變量就不夠用了,無法把一條鏈路中所需的信息傳遞下去.但這並不代表ThreadLocal就無用武之地了,恰恰相反,JDK原生的InheritableThreadLocal(簡稱ITL,先眼熟,後文還會見到),用於跨線程傳遞的組件MDC和阿里開源的TransmittableThreadLocal(簡稱TTL)都是基於ThreadLocal實現的,這些僅僅是冰山一角.至於如何基於ThreadLocal做到上述的擴展,我之後也會抽空更新對於MDC和TTL的分享.
我在註釋中補充了ThreadLocal除了最常用的set(),get()和remove()以外,提供的public方法withInitial,它允許通過lambda表達式的方式給一個ThreadLocal賦予初始值.
1.2 剝開ThreadLocal的外殼
ThreadLocal對外暴露的public方法實際上就只有這麼多,你可以直接把ThreadLocal當成一個線程隔離的容器,通過set()和get()進行存取.但如果在工作和麪試中有人問你為什麼ThreadLocal具備線程隔離性,那隻會使用就不夠了
首先我們來看Thread內部的兩個私有變量
ThreadLocal.ThreadLocalMap threadLocals = null;
//1.1中提到的ITL
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
也就是説每一個線程內部實際上都持有一個ThreadLocalMap,它是ThreadLocal的靜態內部類,存放着當前線程內的所有ThreadLocal實例.我們再繼續往下看ThreadLocalMap內部的結構
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
private static final int INITIAL_CAPACITY = 16;
private Entry[] table;
private int size = 0;
private int threshold;
ThreadLocalMap本質上是一個用Entry[]數組實現的簡易哈希表,其中每一個Entry繼承了弱引用,Entry的key存放ThreadLocal,value存放值;
除此之外,我還節選了一部分參數,比如這個哈希表的初始容量是16,初始大小是0,用於控制哈希表擴容的閾值等等.提到哈希表的實現,就一定繞不開如何處理哈希碰撞等細節,但我認為目前為止對於學習ThreadLocal是如何實現線程隔離的已經足夠了,感興趣的話我會單獨出一篇文章介紹ThreadLocalMap內部是如何實現的.
結合這張圖,我們再以ThreadLocal.set() 和 get()來回看ThreadLocal中提供的方法內部是如何實現的:
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
map.set(this, value);
} else {
createMap(t, value);
}
}
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
//空實現,返回null
return setInitialValue();
}
可以看到,不管是set()還是get(),都會首先獲取當前線程,並拿到當前線程下的ThreadLocalMap,再調用map內部的set()和get()方法,我們再來看ThreadLocalMap內部的set()和get()是如何實現的
//set核心代碼
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
...
e.value = value;
return;
}
//get核心代碼
private Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
return e;
}
可以看到,在set()和get()的時候都會根據ThreadLocal實例內部的hashcode和哈希掩碼做與運算來計算哈希槽的下標,直接修改或返回ThreadLocalMap中的value即可.至此為止,ThreadLocal的面紗就已經被徹底揭開:ThreadLocal只是暴露給使用者的實例,自身並沒有存儲值,而是在ThreadLocalMap內部某一個Entry的value裏,找到這個value的方式就是通過ThreadLocal計算得到哈希槽下標.
補充一句,在set()方法中我們可以看到ThreadLocalMap實際上採用了延遲加載,ThreadLocal實例沒有賦值時指向的ThreadLocalMap是null,只有當有set()操作發生時才會初始化ThreadLocalMap,代碼如下
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
table = new Entry[INITIAL_CAPACITY];
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
}
我之所以在set()和get()的代碼塊中註釋了核心代碼,就是因為還隱藏了許多ThreadLocalMap內部的細節,譬如如果發生了哈希碰撞怎麼處理,以及和標記和清除過期哈希槽相關的邏輯
二、使用時的注意事項
2.1 典中典之內存泄漏
這個問題在面試過程中可以説是必背的八股,但未必每個人都真正理解了背後的含義,這和JVM運行時的內存結構,引用級別和GC息息相關.通過上一章的內容你已經可以對ThreadLocal如何實現線程隔離侃侃而談,那麼這一小節旨在幫你真正理解為什麼會產生內存泄露的問題,以及如何在使用過程中避免內存泄漏.
廢話不多説,直接上圖:
如果你對這張圖中出現的JVM內存結構或引用級別還不夠了解,就無法真正理解老生常談的內存泄漏問題,這部分網上的資料非常多也很全面
2.1.1 為什麼Entry的key採用弱引用
前面我們説過ThreadLocal自身並不存儲值,它是暴露給使用者的,本質上還是在操作ThreadLocalMap.不妨想想如果某個ThreadLocal的實例在程序中被設為null,Entry的key作為強引用時會發生什麼,這顯然會導致內存泄漏,這是因為:
對使用者而言指向堆上ThreadLocal對象的引用就只有自己的ThreadLocal實例,但通過前面的學習我們知道實際上還有一份引用存在於Entry的key裏,所以key只能是弱引用,否則ThreadLocal在堆上的對象實例就永遠不會被回收.
因此,每次GC的時候都會回收堆上只有弱引用指向的ThreadLocal對象
2.1.2 怎麼還有內存泄漏?
Entry的key通過設置為弱引用解決了,但value顯然行不通,它只能是強引用.所以面試八股中老生常談的內存泄漏問題指的是當key被回收設置為null以後,value的強引用依然存在.
為了應對這種情況,ThreadLocalMap的get(),set()和remove()方法中均提供了對ThreadLocalMap中key為null時刪除對應value的操作.
不同之處在於:
- remove()一定會抹除threadLocal對應的value
- set()和get()方法只是在一些比如哈希未命中需要額外做處理的時候順帶回收遍歷過程中key為null的value,如果第一次直接命中就直接返回,無法清理
因此,最佳實踐就是不再使用ThreadLocal實例時顯示調用remove()
2.2 數據污染
線程池內部如果使用了ThreadLocal要格外注意,因為線程池內線程會被複用,一定要及時remove(),避免造成數據污染
2.3 線程隔離等於線程安全嗎?
直接説結論,不等於.當ThreadLocal中的value是引用類型時,比如MDC中的Map<String,String>.這種情況下要麼明確使用場景確保沒有通過本地引用修改value,要麼返回引用時採用深拷貝.
三、淺析InheritableThreadLocal
在引言中提到了線程級別的ThreadLocal已經不能滿足當下跨線程的需要,ITL就是JDK原生的支持父子線程間攜帶信息的擴展,代碼非常簡短:
public class InheritableThreadLocal<T> extends ThreadLocal<T> {
protected T childValue(T parentValue) {
return parentValue;
}
ThreadLocalMap getMap(Thread t) {
return t.inheritableThreadLocals;
}
void createMap(Thread t, T firstValue) {
t.inheritableThreadLocals = new ThreadLocalMap(this, firstValue);
}
}
直接繼承自ThreadLocal並重寫了三個方法,第一個方法決定了父子線程傳遞值的方式是直接傳遞,後兩個方法僅僅把創建和獲取ThreadLocalMap的實例設置為ITL獨立的map實例.如果你回看1.2中Thread的代碼,你會發現ITL已經聲明瞭自己的ThreadLocalMap.
那怎麼使用呢?實際上你只需要聲明一個ITL,然後像ThreadLocal一樣使用就可以了,父子線程的傳遞是自動完成的,怎麼做到的?
if (inheritThreadLocals && parent.inheritableThreadLocals != null)
this.inheritableThreadLocals =
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
這段代碼來自於Thread內private的構造方法,可以看到只要當前線程的inheritThreadLocals布爾值為真且父線程的ITL是有值的,子線程會接受createInheritedMap()的結果,這個方法的內部就是對父線程的ITL做了深拷貝並返回.那問題又來了,大多都是直接new Thread()再重寫裏面的run()方法的,我怎麼知道這個布爾值是多少?只要再往上追一層Thread對外共開的無參構造方法就水落石出了:
public Thread(Runnable target) {
this(null, target, "Thread-" + nextThreadNum(), 0);
}
public Thread(ThreadGroup group, Runnable target, String name,
long stackSize) {
this(group, target, name, stackSize, null, true);
}
可以看到,這個布爾值是默認為true的,這也是為什麼前面説父子線程的傳遞是自動完成的.
實際上ITL對ThreadLocal做的擴展依舊不夠,當下絕大部分都是採用線程池複用的方式執行異步任務,而不是簡單的父子線程.這就需要類似MDC和TTL這樣的技術了,值得一提的是,它們的實現方式依然是對ThreadLocal做封裝.其中MDC的源碼實際上非常簡短,而阿里的TTL擁有非常友好和活躍的社區氛圍,推薦直接在官方文檔處學習:alibaba/transmittable-thread-local: 📌 a missing Java std lib(simple & 0-dependency) for framework/middleware, provide an enhanced InheritableThreadLocal that transmits values between threads even using thread pooling components. (github.com)
四、總結
本文結合了部分ThreadLocal的源碼 以及 靜態代碼和JVM運行時的圖示,主要講解了以下幾點:
1.ThreadLocal如何做到線程隔離
每個Thread下持有一個ThreadLocalMap,ThreadLocal以弱引用key的方式存儲在ThreadLocalMap中,真正的值存儲在value.
2.ThreadLocal使用時的注意事項
- 顯式調用remove()防止內存泄漏
- 需要考慮到線程複用的情況,避免數據污染
- 線程隔離≠線程安全,對於引用類型要格外敏感
3.ITL如何做到父子線程傳遞
在new Thread()內部自動拷貝父線程的ITL
五、todo
對於ThreadLocal源碼的解析中隱藏的ThreadLocalMap內部對簡易哈希表的實現,以及沒有展開講的MDC和TTL這樣的跨線程傳遞方案,後續會抽時間梳理並分享.
水平有限,文章難免存在謬誤和不完善的地方,隨時歡迎批評指正,補充或者分享自己對於ThreadLocal設計的看法.