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

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

  1. 聽說(shuō)ThreadLocal中有有使用弱引用,為什么要用弱引用?用弱引用,發(fā)生一次gc后,set進(jìn)去的值再get就是null了嗎?
  2. 聽說(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)有使用鏈表或紅黑樹等方式鏈地址來(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)分析下開始時(shí)拋出的問(wèn)題一:關(guān)于弱引用的問(wèn)題。
弱引用,在經(jīng)歷一次gc后,不管當(dāng)前內(nèi)存是否足夠,都會(huì)被清除,我們把開始的代碼修改一下,在通過(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

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


image.png

可見,現(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()和線程池的方式開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

可見,執(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)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

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