大家好,我是王有志。關注王有志,一起聊技術,聊遊戲,聊在外漂泊的生活。
好久不見,不知道大家新年過得怎麼樣?有沒有痛痛快快得放鬆?是不是還能收到很多壓歲錢?好了,話不多說,我們開始今天的主題:ThreadLocal。
我收集了4個面試中出現頻率較高的關於ThreadLocal的問題:
- 什麼是ThreadLocal?什麼場景下使用ThreadLocal?
- ThreadLocal的底層是如何實現的?
- ThreadLocal在什麼情況下會出現記憶體洩漏?
- 使用ThreadLocal要注意哪些內容?
我們先從一個“謠言”開始,透過分析ThreadLocal的原始碼,嘗試糾正“謠言”帶來的誤解,並解答上面的問題。
流傳已久的“謠言”
很多文章都在說“ThreadLocal透過複製共享變數的方式解決併發安全問題”,例如:
這種說法並不準確,很容易讓人誤解為ThreadLocal會複製共享變數。來看個例子:
private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd");
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 1000; i++) {
new Thread(() -> {
try {
System.out.println(DATE_FORMAT.parse("2023-01-29"));
} catch (ParseException e) {
e.printStackTrace();
}
}).start();
}
}
我們知道,多執行緒併發訪問同一個DateFormat例項物件會產生嚴重的併發安全問題,那麼加入ThreadLocal是不是能解決併發安全問題呢?修改下程式碼:
/**
* 第一種寫法
*/
private static final ThreadLocal<DateFormat> DATE_FORMAT_THREAD_LOCAL = new ThreadLocal<>() {
@Override
protected DateFormat initialValue() {
return DATE_FORMAT;
}
};
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 1000; i++) {
new Thread(() -> {
try {
System.out.println(DATE_FORMAT_THREAD_LOCAL.get().parse("2023-01-29"));
} catch (ParseException e) {
e.printStackTrace();
}
}).start();
}
}
估計會有很多小夥伴會說:“你這麼寫不對!《阿里巴巴Java開發手冊》中不是這麼用的!”。把書中的用法搬過來:
/**
* 第二種寫法
*/
private static final ThreadLocal<DateFormat> DATE_FORMAT_THREAD_LOCAL = new ThreadLocal<>() {
@Override
protected DateFormat initialValue() {
return new SimpleDateFormat("yyyy-MM-dd");
}
};
Tips:程式碼小改了一下~~
我們來看兩種寫法的差別:
- 第一種寫法,
ThreadLocal#initialValue
時使用共享變數DATE_FORMAT
; - 第二種寫法,
ThreadLocal#initialValue
時建立SimpleDateFormat物件。
按照“謠言”的描述,第一種寫法會複製DATE_FORMAT
的副本提供給不同的執行緒使用,但從結果上來看ThreadLocal並沒有這麼做。
有的小夥伴可能會懷疑是因為DATE_FORMAT_THREAD_LOCAL
執行緒共享導致的,但別忘了第二種寫法也是執行緒共享的。
到這裡我們應該能夠猜到,第二種寫法中每個執行緒會訪問不同的SimpleDateFormat例項物件,接下來我們透過原始碼一探究竟。
ThreadLocal的實現
除了使用ThreadLocal#initialValue
外,還可以透過ThreadLocal#set
新增變數後再使用:
ThreadLocal<SimpleDateFormat> threadLocal = new ThreadLocal<>();
threadLocal.set(new SimpleDateFormat("yyyy-MM-dd"));
System.out.println(threadLocal.get().parse("2023-01-29"));
Tips:這麼寫僅僅是為了展示用法~~
使用ThreadLocal非常簡單,3步就可以完成:
- 建立物件
- 新增變數
- 取出變數
無參構造器沒什麼好說的(空實現),我們從ThreadLocal#set
開始。
ThreadLocal#set的實現
ThreadLocal#set
的原始碼:
public void set(T value) {,
Thread t = Thread.currentThread();
// 獲取當前執行緒的ThreadLocalMap
ThreadLocalMap map = getMap(t);
if (map != null) {
// 新增變數
map.set(this, value);
} else {
// 初始化ThreadLocalMap
createMap(t, value);
}
}
ThreadLocal#set
的原始碼非常簡單,但卻透露出了不少重要的資訊:
- 變數儲存在ThreadLocalMap中,且與當前執行緒有關;
- ThreadLocalMap應該類似於Map的實現。
接著來看原始碼:
public class ThreadLocal<T> {
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
}
public class Thread implements Runnable {
ThreadLocal.ThreadLocalMap threadLocals = null;
}
很清晰的展示出ThreadLocalMap與Thread的關係:ThreadLocalMap是Thread的成員變數,每個Thread例項物件都擁有自己的ThreadLocalMap。
另外,還記得在關於執行緒你必須知道的8個問題(上)提到Thread例項物件與執行執行緒的關係嗎?
如果從Java的層面來看,可以認為建立Thread類的例項物件就完成了執行緒的建立,而呼叫
Thread.start0
可以認為是作業系統層面的執行緒建立和啟動。
可以近似的看作是:\(Thread例項物件\approx執行執行緒\)。也就是說,屬於Thread例項物件的ThreadLocalMap也屬於每個執行執行緒。
基於以上內容,我們好像得到了一個特殊的變數作用域:屬於執行緒。
Tips:
- 實際上屬於執行緒也即是屬於Thread例項物件,因為Thread是執行緒在Java中的抽象;
- ThreadLocalMap屬於執行緒,但不代表儲存到ThreadLocalMap的變數屬於執行緒。
ThreadLocalMap的實現
ThreadLocalMap是ThreadLocal的內部類,程式碼也不復雜:
public class ThreadLocal<T> {
private final int threadLocalHashCode = nextHashCode();
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
private Entry[] table;
private int size = 0;
private int threshold;
private void setThreshold(int len) {
threshold = len * 2 / 3;
}
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);
}
}
}
僅從結構和構造方法中已經能夠窺探到ThreadLocalMap的特點:
- ThreadLocalMap底層儲存結構是Entry陣列;
- 透過ThreadLocal的雜湊值取模定位陣列下標;
- 構造方法新增變數時,儲存的是原始變數。
很明顯,ThreadLocalMap是雜湊表的一種實現,ThreadLocal作為Key,我們可以將ThreadLocalMap看做是“簡版”的HashMap。
Tips:
- 本文不討論雜湊表實現中處理雜湊衝突,陣列擴容等問題的方式;
- 也不需要關注
ThreadLocalMap#set
和ThreadLocalMap#getgetEntry
的實現; - 與構造方法一樣,
ThreadLocalMap#set
中儲存的是原始變數。
到目前為止,無論是ThreadLocalMap#set
還是ThreadLocalMap的構造方法,都是儲存原始變數,沒有任何複製副本的操作。也就是說,想要透過ThreadLocal實現變數線上程間的隔離,就需要手動為每個執行緒建立自己的變數。
ThreadLocal#get的實現
ThreadLocal#get
的原始碼也非常簡單:
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;
}
}
return setInitialValue();
}
前面的部分很容易理解,我們看map == null
時呼叫的ThreadLocal#setInitialValue
方法:
private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
map.set(this, value);
} else {
createMap(t, value);
}
if (this instanceof TerminatingThreadLocal) {
TerminatingThreadLocal.register((TerminatingThreadLocal<?>) this);
}
return value;
}
ThreadLocal#setInitialValue
方法幾乎和ThreadLocal#set
一樣,但變數是透過ThreadLocal#initialValue
獲得的。如果是透過ThreadLocal#initialValue
新增變數,在第一次呼叫ThreadLocal#get
時將變數儲存到ThreadLocalMap中。
ThreadLocal的原理
好了,到這裡我們已經可以構建出對ThreadLocal比較完整的認知了。我們先來看ThreadLocal,ThreadLocalMap和Thread三者之間的關係:
可以看到,ThreadLocal是作為ThreadLocalMap中的Key的,而ThreadLocalMap又是Thread中的成員變數,屬於每一個Thread例項物件。忘記ThreadLocalMap是ThreadLocal的內部類這層關係,整體結構就會非常清晰。
建立ThreadLocal物件並儲存資料時,會為每個Thread物件建立ThreadLocalMap物件並儲存資料,ThreadLocal物件作為Key。在每個Thread物件的生命週期內,都可以透過ThreadLocal物件訪問到儲存的資料。
到底是“謠言”嗎?
那麼“ThreadLocal透過複製共享變數的方式解決併發安全問題”是“謠言”嗎?
我認為是的。ThreadLoal不會複製共享變數,它能“解決”併發安全問題的原理很簡單,要求開發者為每個執行緒“發”一個變數,即變數本身就是執行緒隔離的。接近於以下寫法:
public static Date parseDate(String dateStr) throws ParseException {
return new SimpleDateFormat("yyyy-MM-dd").parse(dateStr);
}
那這還能算是ThreadLocal去解決併發安全問題嗎?
Tips:Stack Overflow上也有關於“謠言”的討論。
既然不是解決共享變數併發安全問題的,那麼ThreadLocal有什麼用?我認為最主要的功能就是跳過方法的引數列表線上程內傳遞引數。舉個例子:Dubbo借鑑Netty的FastThreadLocal,搞了InternalThreadLocal,用來隱式傳遞引數。
ThreadLocal的記憶體洩漏
在ThreadLocalMap的原始碼中可以看到,Entry繼承自WeakReference,並且會將ThreadLocal新增到弱引用佇列中:
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
我們知道,弱引用關聯的物件只能存活到下一次GC。如果ThreadLocal沒有關聯任何強引用,只有Entry上的弱引用的話,發生一次GC後ThreadLocal就會被回收,就會存在ThreadLocalMap上關聯Entry,但Entry上沒有Key的情況:
此時Value依舊關聯在ThreadLocalMap上,但無法透過常規手段訪問,造成記憶體洩漏。雖然執行緒銷燬後會釋放記憶體,但線上程執行期間,始終有一塊無法訪問的記憶體被佔用。
避免記憶體洩漏
為了避免記憶體洩漏,Java建議設定靜態ThreadLocal變數,保證一直存在與之關聯的強引用:
ThreadLocal instances are typically private static fields in classes.
另外,ThreadLocal自身也做了一些努力去清除這些沒有Key的Entry,如:
ThreadLocalMap#getEntry
呼叫ThreadLocalMap#getEntryAfterMiss
;ThreadLocalMap#set
呼叫ThreadLocalMap#replaceStaleEntry
。
這些方法中都會嘗試清除無用的Entry,只是觸發條件較為苛刻,實際作用較小。
除此之外,開發者主動呼叫ThreadLocal#remove
清除無用變數才是正確使用ThreadLocal的方式。
ThreadLocal的注意事項
除了需要關注ThreadLocal的記憶體洩漏外,我們需要關注另外一種場景:執行緒池中使用ThreadLocal。
通常執行緒池不會銷燬執行緒,因此線上程池中使用ThreadLcoal,且沒有正確執行ThreadLocal#remove
的話,執行緒中會一直存在ThreadLocal關聯的Value,那麼就需要考慮清楚,這次的ThreadLocal對下一是否還適用?
結語
ThreadLocal的內容到這裡就結束了,使用方法,實現原理,包括記憶體洩漏都還是比較簡單的。不過有一點比較難搞,因為有太多人去寫“ThreadLocal透過複製共享變數的方式解決併發安全問題”,導致很多人認為這是ThreadLocal的核心功能,所以無法確認坐在對面的面試官是如何理解ThreadLocal的。
我也思考了“謠言”是如何產生的,大概有兩點:
第一,《阿里巴巴Java開發手冊》中使用ThreadLocal解決了DateFormat的併發安全問題,表現上看是ThreadLocal的能力,實際上是開發者自身保證了每個執行緒使用不同的DateFormat例項物件。
第二,ThreadLocal的註釋中,提到了一句“independently initialized copy of the variable.”,搞得大家以為ThreadLocal會複製共享變數給執行緒使用。
如果真的遇到了這樣面試官,那隻能”見人說人話“了。
好了,今天就到這裡了,Bye~~