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

背景

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

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

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

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

源碼探究

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

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

ThreadLocal的實現(xiàn)思路

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

ThreadLocalMap是這樣設(shè)計的:

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

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

幾個關(guān)鍵方法

哈希算法

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的冪取模后的沖突也比較少。一些資料中對這個值對取模哈希結(jié)果的分散表現(xiàn)有說明,雖然其中的“黃金分割點”理論我不是很贊同就是了。

ThreadLocalMap.expungeStaleEntry(int staleSlot)
對某個過期的entry進行清空操作,這是個private方法,無法直接調(diào)用。

由于使用線性探測法解決沖突,其后的一批entry都有可能是由于哈希沖突才插入到當前slot的。這個entry雖然過期了,但如果清空后不做處理,可能導(dǎo)致因哈希沖突而產(chǎn)生的一批slot連續(xù)且哈希結(jié)果相同的entry出現(xiàn)“斷裂”,之后再通過哈希查找這批entry時由于斷裂而在線性探測時找不到對應(yīng)的結(jié)果,副作用還有size對不上等。

因此,在清空該特定位置的數(shù)據(jù)后,還對其后連續(xù)的所有entry進行了rehash,直白地說可能就像在數(shù)組中刪除元素后把后邊連續(xù)的元素前移,保證邏輯上不出錯。

不過我個人認為這部分的處理不夠到位,沒有檢查需要rehash的entry是否過期,過期的entry本可以直接清理掉。極端情況下后邊的多個entry都過期了,就得進行多次rehash,就像冒泡排序的極端情況一樣。好在哈希算法足夠簡單(計算快),而entry個數(shù)和線程數(shù)大致對應(yīng)(數(shù)組不會特別大),還因為哈希算法的原因分布較均勻(難以出現(xiàn)很長的連續(xù)非空entry),這種極端情況應(yīng)該也可以忽略。

在get、set、remove方法中,遇到已經(jīng)過期被回收的entry key時都會直接或間接調(diào)用這個方法,這能夠確保在沒有進行remove操作的情況下即使key被回收也能夠定期清理很多已過期的entry和entry value。當然,有些特殊情況下也無法清理就是了,比如位于當前過期entry之前的過期entry,rehash過程可能檢查不到。

總結(jié)

可以說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ā)了一些其他的問題。

不調(diào)remove()方法除了內(nèi)存泄漏還會有什么樣的影響

由于ThreadLocalMap保存在Thread對象中,而現(xiàn)在很多主流框架里線程池的廣泛應(yīng)用,導(dǎo)致復(fù)用Thread對象同時也就復(fù)用了其綁定的ThreadLocalMap,那么以下的代碼就可能會出現(xiàn)問題:

    Object v = threadLocal.get();
    // 由于線程復(fù)用,可能該線程上個執(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)上的資料到這也就結(jié)束了,但我想再繼續(xù)深入探究一下:什么情況下key的強引用會斷?

強引用是對應(yīng)的子線程或主線程中某個對象持有的,對象生命周期結(jié)束或?qū)ο筇鎿Q指向這個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對應(yīng)的entry和value有Thread.threadLocalMap強引用指向,都沒被回收
  4. 可能在某些情況下,通過expungeStaleEntry方法,這個entry和value都被清空回收

在這種情況下,如果使用弱引用,還可能通過expungeStaleEntry機制清理ThreadLocalMap;

而通過強引用,根本無法清理,因為僅ThreadLocalMap不可能知曉key持有者a是否還存活,而key本身是被entry強引用的。

ThreadLocal的最佳實踐應(yīng)該是怎樣的

上文提到,當使用某個中間類A持有非靜態(tài)ThreadLocal對象即key時,會通過弱引用機制及自身策略自動清理部分無效的entry。

但是在ThreadLocal類的注釋文檔中提到,通常應(yīng)該將ThreadLocal聲明為private static變量。

我個人認為ThreadLocal的弱引用回收機制只是作者Josh Bloch和Doug Lea為避免錯誤使用而進行的防范措施,因為如果將ThreadLocal聲明為private static,那么基本就不存在需要弱引用回收的情況不是嗎?

但是聲明為靜態(tài)變量又會引入新的問題。

首先我們看一下在static情況下ThreadLocal的結(jié)構(gòu)示意:

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),但由于對應(yīng)的threadLocal即key還有一些線程在用,不會被回收,就導(dǎo)致這部分過期value也無法回收,即便使用了弱引用也無法解決這類問題。

拿上圖舉個例子:

  1. 線程1和線程2都用了threadLocal1和threadLocal2,且設(shè)置了value
  2. 線程1使用完畢歸還線程池,但沒有調(diào)用threadLocal1.remove()
  3. 之后線程1不再使用threadLocal1了,僅使用threadLocal2
  4. 線程1的threadLocalMap中仍然保存了obj1
  5. 由于靜態(tài)變量threadLocal1引用仍然可達,不會被回收,線程1無法觸發(fā)expungeStaleEntry機制,threadLocal1對應(yīng)的entry和value無法回收,造成了內(nèi)存泄漏

所以用private static修飾之后,好處就是僅使用有限的ThreadLocal對象以節(jié)約創(chuàng)建對象和后續(xù)自動回收的開銷,壞處是需要我們手動調(diào)用remove方法清理使用完的slot,否則會有內(nèi)存泄漏問題。

使用弱引用后,存放在ThreadLocal中的數(shù)據(jù)會在GC時回收導(dǎo)致后續(xù)使用過程中NPE嗎?

如果使用static修飾,那么只要static引用沒有變化就肯定不會被回收,可以放心使用。

如果不使用static修飾,那么得自行分析一下,正常使用(持有threadLocal強引用)是不會被回收的。

ps.使用private static final修飾也許是個更好的選擇。

總結(jié)

總的來說,ThreadLocal使用不當?shù)拇_會有內(nèi)存泄漏的風(fēng)險。常規(guī)使用應(yīng)當遵照以下幾點:

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

對于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算法(關(guān)于 0x61c88647)- 掘金

為什么使用0x61c88647 - 掘金

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

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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,505評論 6 533
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,556評論 3 418
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,463評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,009評論 1 312
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,778評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,218評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,281評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,436評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,969評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 40,795評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,993評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,537評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,229評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,659評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,917評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,687評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,990評論 2 374

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