背景
- 某次在查看一個工具類時,發(fā)現(xiàn)這個工具類的實例被頻繁創(chuàng)建和回收
- 雖然這個類很輕囱修,但考慮到是個基礎工具類且這個功能需要頻繁調用,希望盡量減輕這個工具對系統(tǒng)的影響
- 優(yōu)化目標是在線程安全的基礎上池化類的對象以復用
于是王悍,初步方案是使用ThreadLocal為每個線程保存一個對象破镰。
然而重構這個工具類之后,發(fā)現(xiàn)阿里規(guī)約插件提示“應該至少調用一次remove()方法”压储,還提示可能造成內(nèi)存泄漏問題鲜漩。
奇怪了,記得之前看WeakReference時明確地看到ThreadLocal有用到弱引用集惋,按理說不是GC的時候會自動回收嗎孕似?這還是Doug Lea寫的呢。
源碼探究
帶著如下問題分析一下源碼:
- ThreadLocal是如何實現(xiàn)每個線程保存一份獨有變量的
- ThreadLocal使用了WeakReference刮刑,為什么阿里規(guī)約提示至少需要調用一次remove方法鳞青,真的會造成內(nèi)存泄漏嗎
ThreadLocal的實現(xiàn)思路
ThreadLocal的實現(xiàn)非常巧妙,在每個線程增加了一個獨有的“類似HashMap的結構”ThreadLocalMap
为朋,所有的ThreadLocal變量保存在這個ThreadLocalMap中臂拓。
ThreadLocalMap是這樣設計的:
- ThreadLocalMap對象保存在對應的線程即Thread對象,根據(jù)Java內(nèi)存模型习寸,每個線程有自己對應的工作內(nèi)存胶惰,線程無法訪問其他線程的工作內(nèi)存
- ThreadLocalMap結構類似HashMap,有一個Entry數(shù)組霞溪,也會在threshold擴容孵滞,也有哈希碰撞和解決方案
- 與HashMap最大的不同是中捆,這個Map的Entry并非常規(guī)的包含key和value兩個屬性
- Entry繼承
WeakReference<ThreadLocal<?>>
即弱引用,將弱引用的真正引用對象即ThreadLocal對象當作普通Entry中的key坊饶,也就是說使用時通過`entry.get()即獲取弱引用指向的對象泄伪,并計算equals的結果 - Entry包含一個
Object value
屬性,保存對應的變量
- Entry繼承
ThreadLocal通過包裝這個ThreadLocalMap匿级,為線程開辟一塊變量存放區(qū)的功能蟋滴,實現(xiàn)了變量在線程間隔離,GC時回收掉“Entry的key”這樣的功能痘绎。此時津函,僅key被回收,entry和value都未被回收孤页。
幾個關鍵方法
哈希算法
ThreadLocalMap的哈希算法是取模哈希尔苦,即key(即ThreadLocal)的哈希值對容量取模,其中容量保證是2的冪行施;沖突解決方案是線型探測法允坚,查看下一相鄰位置的entry,在“可以寫入”的情況下將值賦入蛾号。
什么情況下是可用的位置呢稠项?
- entry為null,這個entry還沒被使用须教,顯然可以寫入
- entry的key為null皿渗,說明這個entry已過期斩芭,key已經(jīng)被GC回收轻腺,可以將其key和value都替換掉
要注意的是,ThreadLocalMap沒有使用拉鏈法/紅黑樹等解決沖突的方式划乖。
ThreadLocal.nextHashCode()
由于ThreadLocal要作為key使用贬养,而且使用了特殊的哈希算法,因此重寫了哈希值的生成方法琴庵。
每個ThreadLocal的哈希值是通過步長0x61c88647累加生成的误算,為什么是這個數(shù)?我個人的看法是迷殿,這是一個素數(shù)(1640531527)儿礼,即使通過累加計算,對2的冪取模后的沖突也比較少庆寺。一些資料中對這個值對取模哈希結果的分散表現(xiàn)有說明蚊夫,雖然其中的“黃金分割點”理論我不是很贊同就是了。
ThreadLocalMap.expungeStaleEntry(int staleSlot)
對某個過期的entry進行清空操作懦尝,這是個private方法知纷,無法直接調用壤圃。
由于使用線性探測法解決沖突,其后的一批entry都有可能是由于哈希沖突才插入到當前slot的琅轧。這個entry雖然過期了伍绳,但如果清空后不做處理,可能導致因哈希沖突而產(chǎn)生的一批slot連續(xù)且哈希結果相同的entry出現(xiàn)“斷裂”乍桂,之后再通過哈希查找這批entry時由于斷裂而在線性探測時找不到對應的結果冲杀,副作用還有size對不上等。
因此模蜡,在清空該特定位置的數(shù)據(jù)后漠趁,還對其后連續(xù)的所有entry進行了rehash,直白地說可能就像在數(shù)組中刪除元素后把后邊連續(xù)的元素前移忍疾,保證邏輯上不出錯闯传。
不過我個人認為這部分的處理不夠到位,沒有檢查需要rehash的entry是否過期卤妒,過期的entry本可以直接清理掉甥绿。極端情況下后邊的多個entry都過期了,就得進行多次rehash则披,就像冒泡排序的極端情況一樣共缕。好在哈希算法足夠簡單(計算快),而entry個數(shù)和線程數(shù)大致對應(數(shù)組不會特別大)士复,還因為哈希算法的原因分布較均勻(難以出現(xiàn)很長的連續(xù)非空entry)图谷,這種極端情況應該也可以忽略。
在get阱洪、set便贵、remove方法中,遇到已經(jīng)過期被回收的entry key時都會直接或間接調用這個方法冗荸,這能夠確保在沒有進行remove操作的情況下即使key被回收也能夠定期清理很多已過期的entry和entry value承璃。當然,有些特殊情況下也無法清理就是了蚌本,比如位于當前過期entry之前的過期entry盔粹,rehash過程可能檢查不到。
總結
可以說ThreadLocal僅僅是包含一個int型的Map key程癌,并封裝了通過key從各自線程查value的工具舷嗡。
回頭看問題
最初的疑問
如何實現(xiàn)每個線程變量隔離
因為get方法的第一步就是從Thread.currentThread()
中獲取該線程的ThreadLocalMap,再從ThreadLocalMap中獲取value的嵌莉,隔離性顯然是可以保證的(有特例)进萄。
使用了WeakReference還會造成內(nèi)存泄漏嗎
只有entry中的key是弱引用,entry本身和其中的value仍然是強引用,如果引用沒有釋放垮斯,還是可能出現(xiàn)內(nèi)存泄漏的問題郎仆。
內(nèi)存泄漏的具體原因下文會分析。
新的問題
在查找資料時發(fā)現(xiàn)兜蠕,最初的問題引發(fā)了一些其他的問題扰肌。
不調remove()方法除了內(nèi)存泄漏還會有什么樣的影響
由于ThreadLocalMap保存在Thread對象中,而現(xiàn)在很多主流框架里線程池的廣泛應用熊杨,導致復用Thread對象同時也就復用了其綁定的ThreadLocalMap曙旭,那么以下的代碼就可能會出現(xiàn)問題:
Object v = threadLocal.get();
// 由于線程復用,可能該線程上個執(zhí)行過程中的數(shù)據(jù)沒清理晶府,本次拿到了上次的數(shù)據(jù)
if (v == null) {
v = genFromSomePlace();
threadLocal.set(v);
}
另外桂躏,要謹慎使用ThreadLocal.withInitial(Supplier<? extends S> supplier)
這個工廠方法創(chuàng)建ThreadLocal對象,一旦不同線程的ThreadLocal使用了同一個Supplier對象川陆,那么隔離也就無從談起了剂习,比如這樣:
// ...
// 反例,這實際上是不同線程共享同一個變量
private static ThreadLocal<Obj> threadLocal = ThreadLocal.withIntitial(() -> obj);
// ...
要使用這種方式:
// ...
private static ThreadLocal<Obj> threadLocal = ThreadLocal.withIntitial(Obj::new);
// ...
為什么不把Entry或value定義為弱引用
ThreadLocal在內(nèi)存中的引用情況
Entry定義為弱引用:當GC回收后较沪,無法區(qū)分是原本就沒有寫入還是被回收了鳞绕,后續(xù)線性探測的修補也無法完成。
value定義為弱引用:似乎也是個不錯的方法尸曼,為啥沒這么做们何?因為這么做和將key定義為弱引用基本沒區(qū)別,仍然可以依賴弱引用機制清理控轿,但通常在我們的使用中不會持有value的強引用冤竹,只會持有key即ThreadLocal對象的強引用,而value沒有強引用的情況下會被GC回收茬射,與我們期望的功能不符鹦蠕。
讓我們換個問題:為什么key要用弱引用而不是直接用強引用?
- 一般我們是可以同時持有ThreadLocal對象強引用和Thread對象強引用的
- 某些情況下key的強引用斷了躲株,此時key就僅存在弱引用片部,在下次GC時key就會被回收
- 在key被回收后镣衡,set霜定、get等方法就有可能觸發(fā)expungeStaleEntry方法,將這個entry給清空
一般網(wǎng)上的資料到這也就結束了廊鸥,但我想再繼續(xù)深入探究一下:什么情況下key的強引用會斷望浩?
強引用是對應的子線程或主線程中某個對象持有的,對象生命周期結束或對象替換指向這個key的引用后惰说,key的強引用也就斷了磨德。
我們綜合看一下這個過期回收的過程:
- 子線程中使用A類的對象a,包含非靜態(tài)ThreadLocal變量即key
public class A {
private ThreadLocal<Context> local = new ThreadLocal<>();
public void doSth() {
// Context ctx = ...
local.set(ctx);
}
}
- 子線程終止,或者下次子線程使用了A類的對象a'典挑,其中a'的ThreadLocal也使用了新的哈希值酥宴,成了key'
- 原對象a不可達,GC回收
- key被回收您觉,但是key對應的entry和value有Thread.threadLocalMap強引用指向拙寡,都沒被回收
- 可能在某些情況下,通過expungeStaleEntry方法琳水,這個entry和value都被清空回收
在這種情況下肆糕,如果使用弱引用,還可能通過expungeStaleEntry機制清理ThreadLocalMap在孝;
而通過強引用诚啃,根本無法清理,因為僅ThreadLocalMap不可能知曉key持有者a是否還存活私沮,而key本身是被entry強引用的始赎。
ThreadLocal的最佳實踐應該是怎樣的
上文提到,當使用某個中間類A持有非靜態(tài)ThreadLocal對象即key時仔燕,會通過弱引用機制及自身策略自動清理部分無效的entry极阅。
但是在ThreadLocal類的注釋文檔中提到,通常應該將ThreadLocal聲明為private static變量涨享。
我個人認為ThreadLocal的弱引用回收機制只是作者Josh Bloch和Doug Lea為避免錯誤使用而進行的防范措施筋搏,因為如果將ThreadLocal聲明為private static,那么基本就不存在需要弱引用回收的情況不是嗎厕隧?
但是聲明為靜態(tài)變量又會引入新的問題奔脐。
首先我們看一下在static情況下ThreadLocal的結構示意:
threadLocal實際上就是個key,在不同線程中通過這個key取value
一旦ThreadLocal聲明為靜態(tài)吁讨,那么多個線程都會將同一個ThreadLocal對象作為key髓迎,那么可能在多個線程中都會出現(xiàn)這批key的value。
想象一下建丧,當某些線程不再需要更新/使用一些threadLocal時排龄,就出現(xiàn)了內(nèi)存泄漏:其threadLocalMap中的很多value已經(jīng)處于不需要且可清理的狀態(tài),但由于對應的threadLocal即key還有一些線程在用翎朱,不會被回收橄维,就導致這部分過期value也無法回收,即便使用了弱引用也無法解決這類問題拴曲。
拿上圖舉個例子:
- 線程1和線程2都用了threadLocal1和threadLocal2争舞,且設置了value
- 線程1使用完畢歸還線程池,但沒有調用threadLocal1.remove()
- 之后線程1不再使用threadLocal1了澈灼,僅使用threadLocal2
- 線程1的threadLocalMap中仍然保存了obj1
- 由于靜態(tài)變量threadLocal1引用仍然可達竞川,不會被回收店溢,線程1無法觸發(fā)expungeStaleEntry機制,threadLocal1對應的entry和value無法回收委乌,造成了內(nèi)存泄漏
所以用private static修飾之后床牧,好處就是僅使用有限的ThreadLocal對象以節(jié)約創(chuàng)建對象和后續(xù)自動回收的開銷,壞處是需要我們手動調用remove方法清理使用完的slot遭贸,否則會有內(nèi)存泄漏問題叠赦。
使用弱引用后,存放在ThreadLocal中的數(shù)據(jù)會在GC時回收導致后續(xù)使用過程中NPE嗎革砸?
如果使用static修飾除秀,那么只要static引用沒有變化就肯定不會被回收,可以放心使用算利。
如果不使用static修飾册踩,那么得自行分析一下,正常使用(持有threadLocal強引用)是不會被回收的效拭。
ps.使用private static final修飾也許是個更好的選擇暂吉。
總結
總的來說,ThreadLocal使用不當?shù)拇_會有內(nèi)存泄漏的風險缎患。常規(guī)使用應當遵照以下幾點:
- 使用private static修飾ThreadLocal對象
- 調用ThreadLocal.withInitial時要謹慎慕的,不要傳入同一個對象造成假隔離
- 在流程開始前將上下文保存到threadLocal中
- 最好不要修改ThreadLocal的引用
- 在流程結束后調用remove去除threadLocal中的數(shù)據(jù),避免內(nèi)存泄漏及線程復用的問題
對于ThreadLocal內(nèi)存泄漏問題以及解決方案挤渔,網(wǎng)上的很多資料說得其實并不清楚肮街,大多數(shù)沒說到點上甚至還有誤。
盡管Josh Bloch和Doug Lea為ThreadLocal內(nèi)存泄漏問題增加了很多防范措施判导,但終究因為一些原因而無法完全避免嫉父,非常遺憾。
補充
什么情況下適合使用ThreadLocal
- 某些在整個流程中都需要用到的上下文信息眼刃,比如RpcContext绕辖,很多框架中都是保存在ThreadLocal中
- 一些線程不安全但每次創(chuàng)建代價又比較高的對象,比如SimpleDateFormat擂红、JDBC連接仪际,保存在ThreadLocal中可以有效節(jié)約開銷
參考資料
ThreadLocal的hash算法(關于 0x61c88647)- 掘金