12.ThreadLocal的那點小秘密

王有志發表於2023-01-30

大家好,我是王有志。關注王有志,一起聊技術,聊遊戲,聊在外漂泊的生活。

好久不見,不知道大家新年過得怎麼樣?有沒有痛痛快快得放鬆?是不是還能收到很多壓歲錢?好了,話不多說,我們開始今天的主題: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#setThreadLocalMap#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~~

相關文章