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

背景

  1. 某次在查看一個工具類時,發(fā)現(xiàn)這個工具類的實例被頻繁創(chuàng)建和回收
  2. 雖然這個類很輕,但考慮到是個基礎(chǔ)工具類且這個功能需要頻繁調(diào)用,希望盡量減輕這個工具對系統(tǒng)的影響
  3. 優(yōu)化目標(biāo)是在線程安全的基礎(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對象當(dāng)作普通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都有可能是由于哈希沖突才插入到當(dāng)前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。當(dāng)然,有些特殊情況下也無法清理就是了,比如位于當(dāng)前過期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定義為弱引用:當(dāng)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)該是怎樣的

上文提到,當(dā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。

想象一下,當(dāng)某些線程不再需要更新/使用一些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使用不當(dāng)?shù)拇_會有內(nèi)存泄漏的風(fēng)險。常規(guī)使用應(yīng)當(dā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)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容