Java內(nèi)存泄露學(xué)習(xí) ThreadLocal真的會內(nèi)存泄露嗎

概述

ThreadLocal提供了線程內(nèi)存儲變量的能力,這些變量不同之處在于每一個線程讀取的變量是對應(yīng)的互相獨(dú)立的。比如我們熟知的Spring事務(wù)管理中就使用了ThreadLocal來保證多線程環(huán)境下connection的線程安全問題。再比如我們?nèi)粘5膉ava web項(xiàng)目開發(fā)中,經(jīng)常使用ThreadLocal來存儲一些用戶id等信息,在一次request請求中,首先拿到登錄的uid,然后放到ThreadLocal上下文中,這樣在service、dao層就不需要一直傳遞uid直接從上下文中獲取就可以了。

但是說起ThreadLocal,除了好用我們還經(jīng)常聽到的就是它有內(nèi)存泄露的風(fēng)險,那么到底是怎么產(chǎn)生內(nèi)存泄露的呢?難道jdk設(shè)計(jì)的類還會內(nèi)存泄露嗎?我們應(yīng)該怎么樣避免內(nèi)存泄露呢?

ThreadLocal內(nèi)部設(shè)計(jì)

關(guān)于ThreadLocal的使用說明就不提了,直接引用網(wǎng)上常見的一個圖來描述一下對象的引用關(guān)系


image.png

簡單解釋一下這個圖,假設(shè)有這樣幾行代碼

Thread t = new Thread(()->{
    ThreadLocal tl = new ThreadLocal();
    tl.set(object);
});
t.start();

Thread類定義有一個property叫做ThreadLocal.ThreadLocalMap threadLocals
threadLocals內(nèi)部是一個hashmap類似的結(jié)構(gòu),存儲著很多Entry
上面的代碼操作后的結(jié)果就是
Entry的key是 tl, value是object

那么棧上面的引用t代表的就是CurrentThreadRef,指向new Thread這個對象。tl代表的就是ThreadLocalRef,指向的就是new ThreadLocal這個對象。

內(nèi)存泄露探究

關(guān)于ThreadLocal的內(nèi)存泄露討論可能是由于ThreadLocal在我們平時代碼使用中越來越頻繁,又或許是高頻面試題的原因被討論的越來越多?,F(xiàn)在網(wǎng)上關(guān)于ThreadLocal內(nèi)存泄露的分析文章非常之多,但是我覺得并不全面,或者僅僅是提了一下弱引用這個問題就完了。
內(nèi)存泄露通常說的是key被回收后,value無法被訪問到但是仍然占用了內(nèi)存,key的弱引用當(dāng)然是最核心的點(diǎn),但是是否內(nèi)存泄露還跟我們的使用場景有關(guān)。通過上面的圖解分析我們可以發(fā)現(xiàn):
1、如果Thread生命周期比較長,是線程池場景,比如tomcat worker線程。那么除非ThreadLocal ref強(qiáng)引用被釋放掉,gc就會回收ThreadLocal對象,導(dǎo)致ThreadLocalMap中之前該ThreadLocal對應(yīng)的value無法回收,內(nèi)存泄露。
2、上一種情況下,ThreadLocal ref強(qiáng)引用什么情況下會釋放呢,如果我們平時使用的時候都是將ThreadLocal定義為static的變量,那么強(qiáng)引用是不會被釋放的,所以這時候key的弱引用就沒有那么重要了。
3、如果Thread本身生命周期結(jié)束了,CurrentThread ref強(qiáng)引用釋放了,gc以后ThreadLocalMap就完全被回收了,不會產(chǎn)生內(nèi)存泄露。

場景一
@Controller
@RequestMapping("/myThreadLocal")
public class MyThreadLocalTestController {

    private static ThreadLocal staticThreadLocal = new MyThreadLocal();

    /**
     * ThreadLocal變量為非靜態(tài)變量,使用完以后釋放掉強(qiáng)引用,
     * 只剩下threadLocalMap中entry的key這個弱引用,gc可以回收掉ThreadLocal對象
     * 但是value My50MB對象不會被回收,除非thread的生命周期結(jié)束
     * @return
     */
    @RequestMapping(value = "/nonStaticWithTomcatThread", method = RequestMethod.GET)
    @ResponseBody
    public String nonStaticWithTomcatThread() {
        System.out.println("staticThreadLocal.hashCode=" + staticThreadLocal.hashCode());
        ThreadLocal t = new MyThreadLocal();
        System.out.println("t.hashCode=" + t.hashCode());
        t.set(new My50MB());// 注意禁用jvm逃逸分析的優(yōu)化 -XX:-DoEscapeAnalysis

        t = null; // 釋放掉強(qiáng)引用
        try {
            System.gc();// 提示系統(tǒng)gc
            TimeUnit.SECONDS.sleep(5L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return "ok";
    }
}

這種情況下Thread是池化的,但是ThreadLocal ref強(qiáng)引用會被釋放。
啟動tomcat運(yùn)行這段代碼,我們分3次獲取內(nèi)存dump信息
第一次在tomcat剛啟動成功,得到0.hprof
第二次在首次訪問/nonStaticWithTomcatThread這個請求后,得到1.hprof
第三次在二次訪問/nonStaticWithTomcatThread這個請求后,得到2.hprof

用MAT工具打開3個dump文件,查看Histogram信息,發(fā)現(xiàn)byte[]這個內(nèi)存的占用一次比一次多,每次多出50MB。打開with incoming reference 分別看到如下信息

0.png

0.hprof中看到最大的一個內(nèi)存占用是靜態(tài)變量staticThreadLocal中有一個1MB的byte[],這個跟我們的示例沒有關(guān)系

1.png

1.hprof中看到最大的一個內(nèi)存占用是My50MB對象內(nèi)部的一個byte[],My50MB被ThreadLocalMap$Entry引用,所以很明顯這個50MB就是那個內(nèi)存泄露的value。

2.png

2.hprof中看到有2個My50MB對象都引用了一個50MB的byte[],跟1.hprof一模一樣只是多了一個,因?yàn)槲覀冊L問了2次controller。如果我們訪問的次數(shù)越多,這個內(nèi)存泄露就越來越明顯。

場景二
    @RequestMapping(value = "/staticWithTomcatThread", method = RequestMethod.GET)
    @ResponseBody
    public String staticWithTomcatThread() {
        staticThreadLocal.set(new My50MB());
        /**try {
            // invoke service to do business
        } finally {
            staticThreadLocal.remove();
        }**/
        return "ok";
    }

這種情況下Thread仍然是池化的,ThreadLocal ref強(qiáng)引用是不會被釋放的,如果還是調(diào)用2次controller方法,打印出來的dump文件是始終只會有一個My50MB存在,前提是2次是同一個線程對象(如果tomcat線程有n個,n個請求同時訪問,每一個線程都會存在一個My50MB的對象,不考慮內(nèi)存溢出的情況下),這里就不拿heap dump分析了。

當(dāng)然針對這種池化的線程,ThreadLocal就相當(dāng)于給這個線程增加狀態(tài)信息,線程復(fù)用的情況下容易出現(xiàn)業(yè)務(wù)邏輯錯誤。所以我們一般在使用線程處理完業(yè)務(wù)邏輯后要清理掉線程中的狀態(tài)信息,也就是加上代碼中被注釋掉的那段代碼。 這樣不但能避免邏輯錯誤,也可以使線程在非活躍狀態(tài)下系統(tǒng)內(nèi)存占用的更少,如果不調(diào)用remove方法清理其實(shí)也是一定程度的內(nèi)存泄露。

場景三
    @RequestMapping(value = "/staticWithNewThread", method = RequestMethod.GET)
    @ResponseBody
    public String staticWithNewThread() {
        new Thread(()->{
            staticThreadLocal.set(new My50MB());
        }).start();
        return "ok";
    }

這種情況下Thread生命周期在代碼執(zhí)行完畢后就會結(jié)束,Thread內(nèi)部的threadLocalMap就會被內(nèi)存回收了,所以不存在任何泄露的問題??雌饋韘et了一個對象到staticThreadLocal中,但是其實(shí)ThreadLocal只是一個工具,真實(shí)存儲是在Thread中,不能被表象所迷惑。

ThreadLocal對內(nèi)存泄露的預(yù)防

其實(shí)jdk將ThreadLocalMap$Entry的key設(shè)計(jì)為WeakReference的時候就已經(jīng)考慮了value的內(nèi)存泄露問題,我們看看ThreadLocalMap的注釋

/**
     * ThreadLocalMap is a customized hash map suitable only for
     * maintaining thread local values. No operations are exported
     * outside of the ThreadLocal class. The class is package private to
     * allow declaration of fields in class Thread.  To help deal with
     * very large and long-lived usages, the hash table entries use
     * WeakReferences for keys. However, since reference queues are not
     * used, stale entries are guaranteed to be removed only when
     * the table starts running out of space.
     */
    static class ThreadLocalMap {

最后一句話的意思大概就是當(dāng)map的空間占用過大后,那么弱引用的key被回收后,無用的entries就會被清理掉。在get() set()操作的時候都會有一些時機(jī)觸發(fā),具體可以自行看源碼


image.png

總結(jié)

1、ThreadLocal只是一個工具,具體的變量存儲是放在Thread中的,所以內(nèi)存泄露很大程度上要看Thread的生命周期
2、ThreadLocalMap$Entry中的key是弱引用,要防止key對象被回收造成value對象的內(nèi)存泄露
3、ThreadLocal一般都應(yīng)該定義成static變量
4、如果在線程池場景下使用ThreadLocal一定要記得調(diào)用remove

相關(guān)閱讀:

當(dāng)ThreadLocal碰上線程池 http://www.itdecent.cn/p/85d96fe9358b

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

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

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