Doug Lea寫的ThreadLocal怎么還是會產(chǎn)生內(nèi)存泄漏?

背景

  1. 某次在查看一個工具類時,發(fā)現(xiàn)這個工具類的實例被頻繁創(chuàng)建和回收
  2. 雖然這個類很輕囱修,但考慮到是個基礎工具類且這個功能需要頻繁調用,希望盡量減輕這個工具對系統(tǒng)的影響
  3. 優(yōu)化目標是在線程安全的基礎上池化類的對象以復用

于是王悍,初步方案是使用ThreadLocal為每個線程保存一個對象破镰。

然而重構這個工具類之后,發(fā)現(xiàn)阿里規(guī)約插件提示“應該至少調用一次remove()方法”压储,還提示可能造成內(nèi)存泄漏問題鲜漩。

奇怪了,記得之前看WeakReference時明確地看到ThreadLocal有用到弱引用集惋,按理說不是GC的時候會自動回收嗎孕似?這還是Doug Lea寫的呢。

源碼探究

帶著如下問題分析一下源碼:

  1. ThreadLocal是如何實現(xiàn)每個線程保存一份獨有變量的
  2. ThreadLocal使用了WeakReference刮刑,為什么阿里規(guī)約提示至少需要調用一次remove方法鳞青,真的會造成內(nèi)存泄漏嗎

ThreadLocal的實現(xiàn)思路

ThreadLocal的實現(xiàn)非常巧妙,在每個線程增加了一個獨有的“類似HashMap的結構”ThreadLocalMap为朋,所有的ThreadLocal變量保存在這個ThreadLocalMap中臂拓。

ThreadLocalMap是這樣設計的:

  1. ThreadLocalMap對象保存在對應的線程即Thread對象,根據(jù)Java內(nèi)存模型习寸,每個線程有自己對應的工作內(nèi)存胶惰,線程無法訪問其他線程的工作內(nèi)存
  2. ThreadLocalMap結構類似HashMap,有一個Entry數(shù)組霞溪,也會在threshold擴容孵滞,也有哈希碰撞和解決方案
  3. 與HashMap最大的不同是中捆,這個Map的Entry并非常規(guī)的包含key和value兩個屬性
    • Entry繼承WeakReference<ThreadLocal<?>>即弱引用,將弱引用的真正引用對象即ThreadLocal對象當作普通Entry中的key坊饶,也就是說使用時通過`entry.get()即獲取弱引用指向的對象泄伪,并計算equals的結果
    • Entry包含一個Object value屬性,保存對應的變量

ThreadLocal通過包裝這個ThreadLocalMap匿级,為線程開辟一塊變量存放區(qū)的功能蟋滴,實現(xiàn)了變量在線程間隔離,GC時回收掉“Entry的key”這樣的功能痘绎。此時津函,僅key被回收,entry和value都未被回收孤页。

幾個關鍵方法

哈希算法

ThreadLocalMap的哈希算法是取模哈希尔苦,即key(即ThreadLocal)的哈希值對容量取模,其中容量保證是2的冪行施;沖突解決方案是線型探測法允坚,查看下一相鄰位置的entry,在“可以寫入”的情況下將值賦入蛾号。

什么情況下是可用的位置呢稠项?

  1. entry為null,這個entry還沒被使用须教,顯然可以寫入
  2. 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定義為弱引用

image.png

ThreadLocal在內(nèi)存中的引用情況

Entry定義為弱引用:當GC回收后较沪,無法區(qū)分是原本就沒有寫入還是被回收了鳞绕,后續(xù)線性探測的修補也無法完成。

value定義為弱引用:似乎也是個不錯的方法尸曼,為啥沒這么做们何?因為這么做和將key定義為弱引用基本沒區(qū)別,仍然可以依賴弱引用機制清理控轿,但通常在我們的使用中不會持有value的強引用冤竹,只會持有key即ThreadLocal對象的強引用,而value沒有強引用的情況下會被GC回收茬射,與我們期望的功能不符鹦蠕。

讓我們換個問題:為什么key要用弱引用而不是直接用強引用?

  1. 一般我們是可以同時持有ThreadLocal對象強引用和Thread對象強引用的
  2. 某些情況下key的強引用斷了躲株,此時key就僅存在弱引用片部,在下次GC時key就會被回收
  3. 在key被回收后镣衡,set霜定、get等方法就有可能觸發(fā)expungeStaleEntry方法,將這個entry給清空

一般網(wǎng)上的資料到這也就結束了廊鸥,但我想再繼續(xù)深入探究一下:什么情況下key的強引用會斷望浩?

強引用是對應的子線程或主線程中某個對象持有的,對象生命周期結束或對象替換指向這個key的引用后惰说,key的強引用也就斷了磨德。

我們綜合看一下這個過期回收的過程:

  1. 子線程中使用A類的對象a,包含非靜態(tài)ThreadLocal變量即key
public class A {
    private ThreadLocal<Context> local = new ThreadLocal<>();

    public void doSth() {
        // Context ctx = ...
        local.set(ctx);
    }
}
  1. 子線程終止,或者下次子線程使用了A類的對象a'典挑,其中a'的ThreadLocal也使用了新的哈希值酥宴,成了key'
  2. 原對象a不可達,GC回收
  3. key被回收您觉,但是key對應的entry和value有Thread.threadLocalMap強引用指向拙寡,都沒被回收
  4. 可能在某些情況下,通過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的結構示意:

image.png

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. 線程1和線程2都用了threadLocal1和threadLocal2争舞,且設置了value
  2. 線程1使用完畢歸還線程池,但沒有調用threadLocal1.remove()
  3. 之后線程1不再使用threadLocal1了澈灼,僅使用threadLocal2
  4. 線程1的threadLocalMap中仍然保存了obj1
  5. 由于靜態(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ī)使用應當遵照以下幾點:

  1. 使用private static修飾ThreadLocal對象
  2. 調用ThreadLocal.withInitial時要謹慎慕的,不要傳入同一個對象造成假隔離
  3. 在流程開始前將上下文保存到threadLocal中
  4. 最好不要修改ThreadLocal的引用
  5. 在流程結束后調用remove去除threadLocal中的數(shù)據(jù),避免內(nèi)存泄漏及線程復用的問題

對于ThreadLocal內(nèi)存泄漏問題以及解決方案挤渔,網(wǎng)上的很多資料說得其實并不清楚肮街,大多數(shù)沒說到點上甚至還有誤。

盡管Josh Bloch和Doug Lea為ThreadLocal內(nèi)存泄漏問題增加了很多防范措施判导,但終究因為一些原因而無法完全避免嫉父,非常遺憾。

補充

什么情況下適合使用ThreadLocal

  1. 某些在整個流程中都需要用到的上下文信息眼刃,比如RpcContext绕辖,很多框架中都是保存在ThreadLocal中
  2. 一些線程不安全但每次創(chuàng)建代價又比較高的對象,比如SimpleDateFormat擂红、JDBC連接仪际,保存在ThreadLocal中可以有效節(jié)約開銷

參考資料

ThreadLocal的hash算法(關于 0x61c88647)- 掘金

為什么使用0x61c88647 - 掘金

將ThreadLocal變量設置為private static的好處是啥? - 知乎

ThreadLocal的最佳實踐 | 徐靖峰|個人博客

最后編輯于
?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末昵骤,一起剝皮案震驚了整個濱河市树碱,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌涉茧,老刑警劉巖赴恨,帶你破解...
    沈念sama閱讀 221,820評論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件疹娶,死亡現(xiàn)場離奇詭異伴栓,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,648評論 3 399
  • 文/潘曉璐 我一進店門钳垮,熙熙樓的掌柜王于貴愁眉苦臉地迎上來惑淳,“玉大人,你說我怎么就攤上這事饺窿∑缃梗” “怎么了?”我有些...
    開封第一講書人閱讀 168,324評論 0 360
  • 文/不壞的土叔 我叫張陵肚医,是天一觀的道長绢馍。 經(jīng)常有香客問我,道長肠套,這世上最難降的妖魔是什么舰涌? 我笑而不...
    開封第一講書人閱讀 59,714評論 1 297
  • 正文 為了忘掉前任,我火速辦了婚禮你稚,結果婚禮上瓷耙,老公的妹妹穿的比我還像新娘。我一直安慰自己刁赖,他們只是感情好搁痛,可當我...
    茶點故事閱讀 68,724評論 6 397
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著宇弛,像睡著了一般鸡典。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上枪芒,一...
    開封第一講書人閱讀 52,328評論 1 310
  • 那天轿钠,我揣著相機與錄音,去河邊找鬼病苗。 笑死疗垛,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的硫朦。 我是一名探鬼主播贷腕,決...
    沈念sama閱讀 40,897評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼咬展!你這毒婦竟也來了泽裳?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 39,804評論 0 276
  • 序言:老撾萬榮一對情侶失蹤破婆,失蹤者是張志新(化名)和其女友劉穎涮总,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體祷舀,經(jīng)...
    沈念sama閱讀 46,345評論 1 318
  • 正文 獨居荒郊野嶺守林人離奇死亡瀑梗,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,431評論 3 340
  • 正文 我和宋清朗相戀三年烹笔,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片抛丽。...
    茶點故事閱讀 40,561評論 1 352
  • 序言:一個原本活蹦亂跳的男人離奇死亡谤职,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出亿鲜,到底是詐尸還是另有隱情允蜈,我是刑警寧澤,帶...
    沈念sama閱讀 36,238評論 5 350
  • 正文 年R本政府宣布蒿柳,位于F島的核電站饶套,受9級特大地震影響,放射性物質發(fā)生泄漏垒探。R本人自食惡果不足惜凤跑,卻給世界環(huán)境...
    茶點故事閱讀 41,928評論 3 334
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望叛复。 院中可真熱鬧仔引,春花似錦、人聲如沸褐奥。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,417評論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽撬码。三九已至儿倒,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間呜笑,已是汗流浹背夫否。 一陣腳步聲響...
    開封第一講書人閱讀 33,528評論 1 272
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留叫胁,地道東北人凰慈。 一個月前我還...
    沈念sama閱讀 48,983評論 3 376
  • 正文 我出身青樓,卻偏偏與公主長得像驼鹅,于是被迫代替她去往敵國和親微谓。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 45,573評論 2 359

推薦閱讀更多精彩內(nèi)容