ThreadLocal分析其弱引用和可能引起的內(nèi)存泄漏

ThreadLocal大家都不陌生匪燕,字面意思是線程本地副本摸吠,可在多線程環(huán)境下,為每個(gè)線程創(chuàng)建獨(dú)立的副本保證線程安全,在需要線程隔離的場(chǎng)合應(yīng)用很廣泛畜疾,但是關(guān)于ThreadLocal赴邻,總是有兩個(gè)疑惑:

  1. 聽(tīng)說(shuō)ThreadLocal中有有使用弱引用,為什么要用弱引用啡捶?用弱引用姥敛,發(fā)生一次gc后,set進(jìn)去的值再get就是null了嗎瞎暑?
  2. 聽(tīng)說(shuō)ThreadLocal可能引起內(nèi)存泄露彤敛?啥場(chǎng)景會(huì)內(nèi)存泄露?為何使用了弱引用依然可能發(fā)生內(nèi)存泄露了赌?怎么避免墨榄?

首先先來(lái)一段代碼,看下最基本的使用:我們聲明兩個(gè)線程勿她,將線程的名字通過(guò)ThreadLocal保存袄秩,然后再通過(guò)ThreadLocal取出,看一下每個(gè)線程獲取到的線程名字

public class TestThreadLocal {

    final static ThreadLocal<String> LOCAL = new ThreadLocal();

    public static void main(String[] args) throws InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(3);
        // 線程1
        executorService.execute(() -> {
            // 存值
            LOCAL.set(Thread.currentThread().getName());
            // 獲取值
            System.out.println(Thread.currentThread().getName() + "-->" +LOCAL.get());
        });
        // 線程2
        executorService.execute(() -> {
            LOCAL.set(Thread.currentThread().getName());
            System.out.println(Thread.currentThread().getName() + "-->" +LOCAL.get());
        });
        executorService.shutdown();
    }
}

運(yùn)行結(jié)果

pool-1-thread-1-->pool-1-thread-1
pool-1-thread-2-->pool-1-thread-2

結(jié)果沒(méi)有什么懸念逢并,每一個(gè)線程都獲取到了與自己相對(duì)于的名字之剧。
現(xiàn)在我們就點(diǎn)源碼,看下它內(nèi)部是怎么存儲(chǔ)和獲取數(shù)據(jù)的(源碼基于jdk1.8砍聊,不同版本的jdk實(shí)現(xiàn)方式可能稍有不同)

首先看下ThreadLocal的set()

public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }

源碼短短幾行背稼,首先獲取當(dāng)前線程,然后調(diào)用getMap()玻蝌,返回一個(gè)ThreadLocalMap蟹肘,暫且不管這個(gè)ThreadLocalMap是什么,通過(guò)名字我們簡(jiǎn)單猜測(cè)灶伊,就是一個(gè)map疆前,我們繼續(xù)往下看,如果map不為空直接保存數(shù)據(jù)聘萨,map為空則創(chuàng)建然后再保存數(shù)據(jù)竹椒,而保存數(shù)據(jù)的方法,key傳入的this米辐,也就是當(dāng)前的ThreadLocal對(duì)象胸完,value是我們要保存的值(所以注意了,我們不能說(shuō)ThreadLocal能保存線程獨(dú)享的變量翘贮,而是保存數(shù)據(jù)的鑰匙赊窥,通過(guò)它操作ThreadLocalMap)。

我們一直在說(shuō)ThreadLocalMap狸页,現(xiàn)在回過(guò)頭來(lái)锨能,看看ThreadLocalMap是什么扯再,怎么來(lái)的吧。首先看看它的由來(lái):ThreadLocalMap map = getMap(t)址遇,點(diǎn)進(jìn)去熄阻,很簡(jiǎn)單,獲取了當(dāng)前線程的成員變量:ThreadLocal.ThreadLocalMap threadLocals倔约,我們可以理解為秃殉,每個(gè)線程在實(shí)例化的時(shí)候,都會(huì)創(chuàng)建一個(gè)ThreadLocalMap實(shí)例浸剩,保存線程獨(dú)享的數(shù)據(jù)钾军。
然后我們?cè)诳纯碩hreadLocalMap吧,該類的源碼在ThreadLocal類中绢要,是一個(gè)靜態(tài)的class吏恭,簡(jiǎn)單看一下ThreadLocalMap的實(shí)現(xiàn)

static class ThreadLocalMap {
        static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;

            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }
    ...
}

大致看下實(shí)現(xiàn),不要戀戰(zhàn)袖扛,我們不難看出2點(diǎn):

  1. 雖然它的名字叫Map砸泛,但并沒(méi)有實(shí)現(xiàn)java.util.Map接口十籍,而是自己?jiǎn)为?dú)實(shí)現(xiàn)的蛆封。
  2. 同大多數(shù)的Map的實(shí)現(xiàn)類似,其內(nèi)部也是維護(hù)了一個(gè)Entry存儲(chǔ)數(shù)據(jù)勾栗,Entry里有key和value惨篱,其中的value在Entry里聲明,但是key卻并沒(méi)有直接在Entry里聲明围俘,而是繼承WeakReference砸讳,是一個(gè)弱引用,在WeakReference的父類Reference里界牡,聲明了
    T referent簿寂,即為該map的key

好的,還記得剛剛我們看的ThreadLocal的set()嗎宿亡,先獲取ThreadLocalMap實(shí)例常遂,然后調(diào)用ThreadLocalMap的set(),我們來(lái)看一下ThreadLocal的set()吧挽荠,我們依舊不要戀戰(zhàn)克胳,沒(méi)必要一行一行的讀,我們大致看一下就好了

 /**
         * Set the value associated with key.
         *
         * @param key the thread local object
         * @param value the value to be set
         */
        private void set(ThreadLocal<?> key, Object value) {

            Entry[] tab = table;
            int len = tab.length;
            int i = key.threadLocalHashCode & (len-1);

            for (Entry e = tab[i];
                 e != null;
                 e = tab[i = nextIndex(i, len)]) {
                ThreadLocal<?> k = e.get();

                if (k == key) {
                    e.value = value;
                    return;
                }

                if (k == null) {
                    replaceStaleEntry(key, value, i);
                    return;
                }
            }

            tab[i] = new Entry(key, value);
            int sz = ++size;
            if (!cleanSomeSlots(i, sz) && sz >= threshold)
                rehash();
        }

這就是個(gè)簡(jiǎn)易版的Map的put存放數(shù)據(jù)的方法圈匆,相信大家都知道HashMap的實(shí)現(xiàn)漠另,對(duì)此應(yīng)該很清楚,大體上就是根據(jù)當(dāng)前哈希桶容量和key的哈希值跃赚,計(jì)算一個(gè)存放角標(biāo)笆搓,將存值的時(shí)候,沒(méi)有當(dāng)前key,直接新增一個(gè)Entry(上文說(shuō)過(guò)满败,Entry的key是弱引用哦)窘奏,有當(dāng)前key,替換掉其value葫录。
但說(shuō)明一點(diǎn)着裹,與HashMap這種哈希鏈表存儲(chǔ)不同的是,在尋址沖突時(shí)米同,ThreadLocalMap并沒(méi)有使用鏈表或紅黑樹(shù)等方式鏈地址來(lái)解決骇扇,而是當(dāng)前地址不可用,就在當(dāng)前map的數(shù)據(jù)數(shù)組中繼續(xù)查找下一個(gè)可用的地址面粮,有興趣的可以仔細(xì)看下少孝。

兜了一圈,一句話總結(jié)這個(gè)ThreadLocal的set(T value)熬苍,就是在當(dāng)前線程的ThreadLocalMap里存放了數(shù)據(jù)稍走,key是使用弱引用的ThreadLocal,value就是我們set進(jìn)去的value

ThreadLocal的獲取值等其他方法就不做過(guò)多分析了柴底,下面重點(diǎn)分析下開(kāi)始時(shí)拋出的問(wèn)題一:關(guān)于弱引用的問(wèn)題婿脸。
弱引用,在經(jīng)歷一次gc后柄驻,不管當(dāng)前內(nèi)存是否足夠狐树,都會(huì)被清除,我們把開(kāi)始的代碼修改一下鸿脓,在通過(guò)ThreadLocal保存數(shù)據(jù)后抑钟,停頓一秒,然后在main線程中觸發(fā)一次gc野哭,然后在在線程中通過(guò)ThreadLocal獲取數(shù)據(jù)在塔,看會(huì)不會(huì)被清除。為了確認(rèn)到底有沒(méi)有發(fā)生gc拨黔,在啟動(dòng)時(shí)我們加入?yún)?shù)
-XX:+PrintGCDetails

public class TestThreadLocal {

   static ThreadLocal<String> LOCAL = new ThreadLocal();

    public static void main(String[] args) throws InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(3);
        executorService.execute(() -> {
            // 存值
            LOCAL.set(Thread.currentThread().getName());
            try {
                // 停頓一秒蛔溃,以便先在gc,再get
                Thread.sleep(1000l);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 獲取值
            System.out.println(Thread.currentThread().getName() + "-->" +LOCAL.get());
        });
        // 線程二
        executorService.execute(() -> {
            LOCAL.set(Thread.currentThread().getName());
            try {
                Thread.sleep(1000l);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "-->" +LOCAL.get());
        });
        // 主線程中觸發(fā)gc
        System.gc();
        executorService.shutdown();
    }
}

結(jié)果如下蓉驹,如舊成功獲取了數(shù)據(jù)

[GC (System.gc()) [PSYoungGen: 5243K->784K(76288K)] 5243K->792K(251392K), 0.0028957 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] 
[Full GC (System.gc()) [PSYoungGen: 784K->0K(76288K)] [ParOldGen: 8K->597K(175104K)] 792K->597K(251392K), [Metaspace: 3724K->3724K(1056768K)], 0.0119867 secs] [Times: user=0.06 sys=0.00, real=0.01 secs] 
pool-1-thread-1-->pool-1-thread-1
pool-1-thread-2-->pool-1-thread-2

可見(jiàn)ThreadLocal的使用沒(méi)有受到gc的影響城榛,原因何在?
我們先分析一下里面的引用鏈态兴,其中實(shí)線為強(qiáng)引用狠持,虛線為弱引用


image.png

可見(jiàn),現(xiàn)在的ThreadLocal瞻润,是有兩條引用鏈的喘垂,一條是當(dāng)前線程中的甜刻,由線程指向ThreadLocalMap,通過(guò)Map指向Entry正勒,而Entry指向key得院;另一條引用鏈則是當(dāng)前執(zhí)行的測(cè)試類的成員變量:TestThreadLocal#LOCAL,且為強(qiáng)引用章贞,所以目前來(lái)說(shuō)并不會(huì)受到gc影響祥绞。

我們?cè)賮?lái)看下問(wèn)題二,內(nèi)存泄露的問(wèn)題鸭限,還是來(lái)段代碼跑跑再說(shuō)蜕径,這段代碼,主要做的就是败京,分別通過(guò)new Thread()和線程池的方式開(kāi)100個(gè)線程兜喻,每個(gè)線程都向ThreadLocal存入1M大小的對(duì)象,為了盡快實(shí)驗(yàn)出效果赡麦,我們把最大堆內(nèi)存調(diào)小點(diǎn)
-Xmx50m -XX:+PrintGCDetails

public class TestThreadLocalLeak {
    final static ThreadLocal<byte[]> LOCAL = new ThreadLocal();
    final static int _1M = 1024 * 1024;

    public static void main(String[] args) {
        //testUseThread();
        testUseThreadPool();
    }

    /**
     * 使用線程
     */
    private static void testUseThread() {
        for (int i = 0; i < 100; i++) {
            new Thread(() ->
                    LOCAL.set(new byte[_1M])
            ).start();
        }
    }

    /**
     * 使用線程池
     */
    private static void testUseThreadPool() {
        ExecutorService executorService = Executors.newFixedThreadPool(100);
        for (int i = 0; i < 100; i++) {
            executorService.execute(() ->
                    LOCAL.set(new byte[_1M])
            );
        }
        executorService.shutdown();
    }
}

使用線程打印結(jié)果(部分日志)

[GC (Allocation Failure) [PSYoungGen: 13819K->1712K(13824K)] 24099K->11992K(48128K), 0.0007287 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 12586K->1280K(14336K)] 22866K->12181K(48640K), 0.0008377 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 12257K->1120K(14336K)] 23158K->12021K(48640K), 0.0006637 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 12191K->1216K(14336K)] 23093K->12117K(48640K), 0.0010607 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 

使用線程池打印結(jié)果(部分日志)

[Full GC (Ergonomics) java.lang.OutOfMemoryError: Java heap space
[PSYoungGen: 12800K->2080K(14848K)] [ParOldGen: 33327K->33322K(34304K)] 46127K->35402K(49152K), [Metaspace: 3770K->3770K(1056768K)], 0.0129146 secs] [Times: user=0.06 sys=0.00, real=0.01 secs] 

當(dāng)調(diào)用testUseThread()時(shí)朴皆,系統(tǒng)在運(yùn)行時(shí)執(zhí)行了大量YGC,但始終穩(wěn)定回收泛粹,最后正常執(zhí)行遂铡,但是執(zhí)行testUseThreadPool()時(shí),經(jīng)歷的頻繁的Full GC戚扳,內(nèi)存卻沒(méi)有降下去忧便,最終發(fā)生了OOM族吻。
我們分析一下帽借,在使用new Thread()的時(shí)候,當(dāng)線程執(zhí)行完畢時(shí)超歌,隨著線程的終止砍艾,那個(gè)這個(gè)Thread對(duì)象的生命周期也就結(jié)束了,此時(shí)該線程下的成員變量巍举,ThreadLocalMap是GC Root不可達(dá)的脆荷,同理,下面的Entry懊悯、里面的key蜓谋、value都會(huì)在下一次gc時(shí)被回收;而使用線程池后炭分,由于線程執(zhí)行完一個(gè)任務(wù)后桃焕,不會(huì)被回收,而是被放回線程池以便執(zhí)行后續(xù)任務(wù)捧毛,自然其成員變量ThreadLocalMap不會(huì)被回收观堂,最終引起內(nèi)存泄露直至OOM让网。至于怎么避免出現(xiàn)內(nèi)存泄露,就是在使用線程完成任務(wù)后师痕,如果保存在ThreadLocalMap中的數(shù)據(jù)不必留給之后的任務(wù)重復(fù)使用溃睹,就要及時(shí)調(diào)用ThreadLocal的remove(),這個(gè)方法會(huì)把ThreadLocalMap中的相關(guān)key和value分別置為null胰坟,就能在下次GC時(shí)回收了因篇。

最后,我們回過(guò)頭來(lái)笔横,再看下問(wèn)題一中的一個(gè)疑問(wèn):ThreadLocalMap的Entry的key惜犀,為什么使用弱引用?還記得我們說(shuō)狠裹,ThreadLocal是有兩條引用鏈嗎虽界?那么我們斷掉強(qiáng)引用,看看弱引用的表現(xiàn)吧涛菠。
這次來(lái)段代碼莉御,我們自己debug一下

public class TestThreadLocalLeak {
    static ThreadLocal LOCAL = new ThreadLocal();

    public static void main(String[] args) {
        LOCAL.set("測(cè)試ThreadLocalMap弱引用自動(dòng)回收");
        Thread thread = Thread.currentThread();
        LOCAL = null;
        System.gc();
        System.out.println("");
    }
}

在gc前和gc后打斷點(diǎn),之前我們分析了俗冻,之所以ThreadLocal的數(shù)據(jù)不會(huì)被回收礁叔,是因?yàn)橛袃蓚€(gè)引用鏈指向ThreadLocal,一個(gè)是當(dāng)前線程的ThreadLocalMap迄薄,另一條就是當(dāng)前類中的成員變量LOCAL琅关,所以我們手動(dòng)把LOCAL置為null,再次調(diào)用System.gc()讥蔽,看一下弱引用是不是被回收了
System.gc()前


image.png

System.gc()后


image.png

可見(jiàn)涣易,執(zhí)行完gc后,確實(shí)回收了弱引用key冶伞,但是value并沒(méi)有被回收新症,原因當(dāng)然是他是強(qiáng)引用。

上面例子都是基于自己的理解自己寫的demo响禽,如果理解的不到位或錯(cuò)誤之處徒爹,歡迎大家不吝賜教,謝謝芋类!


2020-12-22更新
關(guān)于ThreadLocal使用的討論
看到有些編碼規(guī)范上隆嗅,對(duì)使用ThreadLocal有如下要求和建議:

(強(qiáng)制)在代碼邏輯中使用完ThreadLocal,都要調(diào)用remove方法侯繁,及時(shí)清理胖喳。
(推薦)盡量不要使用全局的ThreadLocal。

關(guān)于強(qiáng)制的要求的解讀為:目前我們的項(xiàng)目中使用的線程巫击,通常是對(duì)線程池化管理的(不管是我們自定義的線程池或是tomcat的線程池等)禀晓,核心線程數(shù)之內(nèi)的線程都是長(zhǎng)期駐留池內(nèi)的精续。如果不能及時(shí)調(diào)用remove,一方面可能造成數(shù)據(jù)泄露粹懒,另一方面有可能讓使用了上次未清除的值重付,導(dǎo)致嚴(yán)重的業(yè)務(wù)邏輯問(wèn)題。所以推薦在ThreadLocal使用前后都調(diào)用remove清理凫乖,同時(shí)針對(duì)異常情況也要在finally中清理确垫。

關(guān)于推薦不使用全局ThreadLocal,假設(shè)我們?nèi)质褂昧薚hreadLocal帽芽,那么這個(gè)引用可能保留給了多個(gè)業(yè)務(wù)使用删掀,當(dāng)有某業(yè)務(wù)線程修改了該ThreadLocal引用的實(shí)例后,會(huì)造成其他業(yè)務(wù)線程獲不到解決等不符合預(yù)期的問(wèn)題导街。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末披泪,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子搬瑰,更是在濱河造成了極大的恐慌款票,老刑警劉巖,帶你破解...
    沈念sama閱讀 221,635評(píng)論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件泽论,死亡現(xiàn)場(chǎng)離奇詭異艾少,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī)翼悴,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,543評(píng)論 3 399
  • 文/潘曉璐 我一進(jìn)店門缚够,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人鹦赎,你說(shuō)我怎么就攤上這事谍椅。” “怎么了钙姊?”我有些...
    開(kāi)封第一講書人閱讀 168,083評(píng)論 0 360
  • 文/不壞的土叔 我叫張陵毯辅,是天一觀的道長(zhǎng)。 經(jīng)常有香客問(wèn)我煞额,道長(zhǎng),這世上最難降的妖魔是什么沾谜? 我笑而不...
    開(kāi)封第一講書人閱讀 59,640評(píng)論 1 296
  • 正文 為了忘掉前任膊毁,我火速辦了婚禮,結(jié)果婚禮上基跑,老公的妹妹穿的比我還像新娘婚温。我一直安慰自己,他們只是感情好媳否,可當(dāng)我...
    茶點(diǎn)故事閱讀 68,640評(píng)論 6 397
  • 文/花漫 我一把揭開(kāi)白布栅螟。 她就那樣靜靜地躺著荆秦,像睡著了一般。 火紅的嫁衣襯著肌膚如雪力图。 梳的紋絲不亂的頭發(fā)上步绸,一...
    開(kāi)封第一講書人閱讀 52,262評(píng)論 1 308
  • 那天,我揣著相機(jī)與錄音吃媒,去河邊找鬼瓤介。 笑死,一個(gè)胖子當(dāng)著我的面吹牛赘那,可吹牛的內(nèi)容都是我干的刑桑。 我是一名探鬼主播,決...
    沈念sama閱讀 40,833評(píng)論 3 421
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼募舟,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼祠斧!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起拱礁,我...
    開(kāi)封第一講書人閱讀 39,736評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤梁肿,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后觅彰,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體吩蔑,經(jīng)...
    沈念sama閱讀 46,280評(píng)論 1 319
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,369評(píng)論 3 340
  • 正文 我和宋清朗相戀三年填抬,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了烛芬。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,503評(píng)論 1 352
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡飒责,死狀恐怖赘娄,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情宏蛉,我是刑警寧澤遣臼,帶...
    沈念sama閱讀 36,185評(píng)論 5 350
  • 正文 年R本政府宣布,位于F島的核電站拾并,受9級(jí)特大地震影響揍堰,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜嗅义,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,870評(píng)論 3 333
  • 文/蒙蒙 一屏歹、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧之碗,春花似錦蝙眶、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書人閱讀 32,340評(píng)論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)式塌。三九已至,卻和暖如春友浸,著一層夾襖步出監(jiān)牢的瞬間峰尝,已是汗流浹背。 一陣腳步聲響...
    開(kāi)封第一講書人閱讀 33,460評(píng)論 1 272
  • 我被黑心中介騙來(lái)泰國(guó)打工尾菇, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留境析,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 48,909評(píng)論 3 376
  • 正文 我出身青樓派诬,卻偏偏與公主長(zhǎng)得像劳淆,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子默赂,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,512評(píng)論 2 359