天天看點

ThreadLocal源碼、InheritableThreadLocal與記憶體洩露,這一篇給你捋順了

ThreadLocal,可以了解為線程局部變量。同一份變量在每一個線程中都儲存一份副本,線程對該副本的操作對其他線程完全是不可見的,是封閉的。

一、ThreadLocal簡單示例

public class Main {

    private static ThreadLocal<Integer> tl = new ThreadLocal<>();

    public static void main(String[] args) {

        tl.set(1);

        Thread t = new Thread(() -> {
            tl.set(2);
            System.out.println("子線程:" + tl.get());
        });
        t.start();

        System.out.println("主線程:" + tl.get());

    }
}      

最終的輸出如下:

ThreadLocal源碼、InheritableThreadLocal與記憶體洩露,這一篇給你捋順了

 可以看出,各個線程内的ThreadLocal互不幹擾,每個線程也隻能通路自己獨有的ThreadLocal變量。

那麼ThreadLocal的結構是怎麼樣的呢?

二、ThreadLocal的結構

Thread、ThreadLocal與ThreadLocalMap的關系圖如下:

ThreadLocal源碼、InheritableThreadLocal與記憶體洩露,這一篇給你捋順了

 從上面的結構圖我們可以看出:

(1)每個Thread内部都有一個ThreadLocalMap,可以了解為簡單版的HashMap。

(2)map的key是ThreadLocal類型的,而value的類型則是ThreadLocal的泛型類型。在本例中,value是Intege類型的。

在我剛學ThreadLocal的時候,我覺得他應該是這樣設計的:

ThreadLocal裡面有一個map容器,key是線程id或線程名稱,value是副本的值,簡單又好了解。那為什麼jdk不這樣設計呢(當然早期就是這樣設計的)?

在jdk8中,map被放入到了Thread中,ThreadLocal更像是一個工具類。

那麼,jdk8這樣設計的好處是什麼呢?

(1)如果map被放到ThreadLocal中,那麼map的大小取決于線程數量。當線程數特别多的時候,勢必會影響到map的查找、插入與擴容的效率。而在jdk8中,map的大小取決于ThreadLocal的數量,這個數量是可控的,一般不可能聲明出那麼多的ThreadLocal。

(2)在早期的設計中,當線程消亡時,需要在每一個關聯的ThreadLocal的map中做一些清理工作,比較麻煩。而在jdk8中,線程消亡時,内部的map容器也随之消亡。

三、ThreadLocal有哪些方法

先從比較簡單的set與get方法說起

有關ThreadLocalMap的方法,我們将會在下個章節進行梳理。

set方法

public void set(T value) {
        //擷取目前的操作線程
        Thread t = Thread.currentThread();
        //擷取線程内部的ThreadLocalMap
        ThreadLocalMap map = getMap(t);
        if (map != null)
            //如果map不為空,則直接将(k:目前ThreadLocal執行個體,v:副本值)放入進map中
            map.set(this, value);
        else
            //如果map為空,則建立該線程的ThreadLocalMap,并将(k,v)放入進map中
            createMap(t, value);
    }

    ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }      

可以看到,Thread内部的ThreadLocalMap是懶加載的,隻有在第一次使用的時候,才會建立map。

get方法

public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            //如果map不為空,則擷取鍵為該ThreadLocal對象的Entry執行個體
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }
    
    private T setInitialValue() {
        //擷取初始值,預設是null
        T value = initialValue();
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
        return value;
    }

    protected T initialValue() {
        return null;
    }      

看的出,get方法同樣會觸發map的初始化。

remove方法

public void remove() {
         ThreadLocalMap m = getMap(Thread.currentThread());
         if (m != null)
             //最終還是調用ThreadLocalMap的方法,移除key為目前ThreadLocal的Entry
             m.remove(this);
     }      

好家夥,ThreadLocal工具人的身份石錘了。

核心的代碼都在ThreadLocalMap中,他是ThreadLocal内的一個靜态内部類。

四、ThreadLocalMap探究

ThreadLocalMap的結構

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

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

ThreadLocalMap沒有直接使用HashMap,而是一個經過定制化的map。map中的每一項都是一個Entry,key是對ThreadLocal的一個弱引用(這個後面會再解釋)。

成員變量

//初始容量,必須是2的整數次方
        private static final int INITIAL_CAPACITY = 16;

        //Entry數組。其長度也必須是2的整數次方
        private Entry[] table;

        //數組中不為null的Entry個數
        private int size = 0;

        //擴容門檻值,當size≥threshold時,就會發生擴容。預設為0,會在構造方法中重新設定
        private int threshold;      

基本方法

//設定擴容門檻值為表長度的2/3
        private void setThreshold(int len) {
            threshold = len * 2 / 3;
        }
      
        //下一個索引,當索引為len-1時,下一個索引為0
        private static int nextIndex(int i, int len) {
            return ((i + 1 < len) ? i + 1 : 0);
        }

        //上一個索引,當索引為0時,上一個索引為len-1
        private static int prevIndex(int i, int len) {
            return ((i - 1 >= 0) ? i - 1 : len - 1);
        }      

ThreadLocalMap不同于HashMap,HashMap使用鍊位址法解決沖突,而ThreadLocalMap使用線性探測法。即目前下标存在沖突時,檢查下一個下标是否存在沖突,你可以把數組看成一個環。

構造方法

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
            //初始化一個容量為16的table
            table = new Entry[INITIAL_CAPACITY];
            //計算目前ThreadLocal的下标
            int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
            table[i] = new Entry(firstKey, firstValue);
            size = 1;
            //設定擴容門檻值為16的2/3,即10
            setThreshold(INITIAL_CAPACITY);
        }      

在計算ThreadLocal的下标的時候,用到了

int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1)

生成哈希值的時候,用到了以下代碼:

private final int threadLocalHashCode = nextHashCode();

    //使用AtomicInteger類型是保證加法的原子操作
    private static AtomicInteger nextHashCode =
        new AtomicInteger();

    //該魔數使得在該table上散列均勻,這裡不細究其原理
    private static final int HASH_INCREMENT = 0x61c88647;

    //傳回下一個哈希值,僅僅是在目前值的基礎上再加上魔數
    private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT);
    }      

在計算下标的時候,使用到了& (INITIAL_CAPACITY - 1),這裡和HashMap是一樣的算法(有關HashMap的連環問,可以參考我的這篇文章​​HashMap奪命連環問​​)。

前面說過,Entry數組的容量必須是2的整數次方,那麼在這樣的前提下,hashCode%len是和hashCode&(len-1)相等的,而位運算更加的快速。

例如len=16,len-1的二進制為01111,即将最高位變為0,小于16的部分全為1。那麼hashCode&(len-1)之後,hashCode中≥16的位全部被與為0,小于16的被保留了下來,進而達到對容量取餘相同的效果。

getEntry方法

這個就是ThreadLocal.get方法調用的底層邏輯

private Entry getEntry(ThreadLocal<?> key) {
            //計算下标
            int i = key.threadLocalHashCode & (table.length - 1);
            Entry e = table[i];
            //找到則傳回
            if (e != null && e.get() == key)
                return e;
            else
                //利用線性探測法繼續尋找
                return getEntryAfterMiss(key, i, e);
        }


        private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
            Entry[] tab = table;
            int len = tab.length;

            //隻要Entry不為null,就一直尋找。如果為null,說明真的找不到了
            while (e != null) {
                ThreadLocal<?> k = e.get();
                if (k == key)
                    return e;
                if (k == null)
                    //key為null,說明ThreadLocal已經被回收,那麼回收其value
                    expungeStaleEntry(i);
                else
                    //尋找i的下一個下标
                    i = nextIndex(i, len);
                //将e設定為下一個Entry
                e = tab[i];
            }
            return null;
        }      

expungeStaleEntry方法

即清理那些key為null的Entry

private int expungeStaleEntry(int staleSlot) {
            Entry[] tab = table;
            int len = tab.length;

            //斷開對value的強引用
            tab[staleSlot].value = null;
            //斷開對Entry的強引用
            tab[staleSlot] = null;
            size--;
 
            Entry e;
            int i;
            //從staleSlot的下一個位置開始,直到遇到為null的Entry結束
            for (i = nextIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = nextIndex(i, len)) {
                ThreadLocal<?> k = e.get();
                if (k == null) {
                    //斷開對value與Entry的強引用
                    e.value = null;
                    tab[i] = null;
                    size--;
                } else {
                    //如果目前的Entry不為null,則進行重新散列
                    int h = k.threadLocalHashCode & (len - 1);
                    if (h != i) {
                        //如果重新散列後,位置發生變動
                        tab[i] = null;

                        //一直找到一個空位置
                        while (tab[h] != null)
                            h = nextIndex(h, len);
                        tab[h] = e;
                    }
                }
            }
            return i;
        }      

expungeStaleEntry方法有兩個作用:

(1)從staleSlot位置開始,在遇到空Entry之前,清理目前位置的Entry

(2)如果目前Entry不為空,則進行重新散列。重新散列後的位置不為空Entry的話,則選擇下一個下标。

明明清理空Entry就行了,為什麼需要對非空Entry還要再做一次重新散列呢?

是為了下一次get的時候,避免遇到空Entry需要執行expungeStaleEntry方法。

expungeStaleEntry方法可以了解為清理某一段數組,遇到null就停下來了,并不是全量清理。

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) {
                    //替換目前失效的Entry
                    replaceStaleEntry(key, value, i);
                    return;
                }
            }

            tab[i] = new Entry(key, value);
            int sz = ++size;
            if (!cleanSomeSlots(i, sz) && sz >= threshold)
                //當沒清理到任何資料且size≥門檻值的時候,進行擴容
                rehash();
        }      

replaceStaleEntry與cleanSomeSlots方法,我們不繼續深入了,兩個方法的主要想法依然是去清理無效Entry,即key為null的Entry。

rehash方法

private void rehash() {
            //該方法對于table上每一個Entry,都執行了expungeStaleEntry方法
            //可以了解為整體清理
            expungeStaleEntries();

            //threshold - threshold / 4 =3/4*threshold 
            //預設的threshold =2/3*len,是以隻要size>=1/2*len,即占了一半之後,就考慮擴容
            //為什麼不按傳統的size>=threshold來考慮擴容呢?
            //因為執行一次全部清理後,依然還占有一半容量,那麼就說明沖突可能會趨于嚴重,不如早點執行擴容操作。
            if (size >= threshold - threshold / 4)
                resize();
        }

        //對每一個位置上都進行檢查并清理
        private void expungeStaleEntries() {
            Entry[] tab = table;
            int len = tab.length;
            for (int j = 0; j < len; j++) {
                Entry e = tab[j];
                if (e != null && e.get() == null)
                    expungeStaleEntry(j);
            }
        }


        private void resize() {
            Entry[] oldTab = table;
            int oldLen = oldTab.length;
            //擴容為原來的兩倍
            int newLen = oldLen * 2;
            Entry[] newTab = new Entry[newLen];
            int count = 0;

            //将舊位置的Entry重新計算下标放入新table中
            for (int j = 0; j < oldLen; ++j) {
                Entry e = oldTab[j];
                if (e != null) {
                    ThreadLocal<?> k = e.get();
                    if (k == null) {
                        e.value = null; // Help the GC
                    } else {
                        int h = k.threadLocalHashCode & (newLen - 1);
                        while (newTab[h] != null)
                            h = nextIndex(h, newLen);
                        newTab[h] = e;
                        count++;
                    }
                }
            }

            setThreshold(newLen);
            size = count;
            table = newTab;
        }      

可以看得出來,在ThreadLocal的get與set方法中,都會去檢查Entry的key是否為null,如果為null的話,會進行一些局部的清理工作。

當需要進行擴容時,會進行一次整體清理。

五、InheritableThreadLocal是什麼鬼

ThreadLocal是用于線程之間隔離的,但是InheritableThreadLocal可以使得子線程去自動拷貝來自父線程的副本資料。

簡單的例子:

public static void main(String[] args) {
        InheritableThreadLocal<Integer> threadLocal = new InheritableThreadLocal<>();

        threadLocal.set(1);
        System.out.println("父線程的副本值:" + threadLocal.get());

        new Thread(() -> System.out.println("子線程的副本值:" + threadLocal.get())).start();
    }      

兩個線程的副本值都是1,說明子線程确實自動拷貝了父線程的副本值。

原理很簡單:

InheritableThreadLocal繼承了ThreadLocal,

重寫了childValue方法,直接傳回了傳入參數值。因為InheritableThreadLocal預設不對原值進行轉換,如果我們需要對原值進行轉換的話,可以重寫該方法。

重寫了getMap方法,傳回目前線程的inheritableThreadLocals,也是ThreadLocalMap類型。createMap則是懶加載該inheritableThreadLocals。

protected T childValue(T parentValue) {
        return parentValue;
    }
   
    ThreadLocalMap getMap(Thread t) {
       return t.inheritableThreadLocals;
    }

    void createMap(Thread t, T firstValue) {
        t.inheritableThreadLocals = new ThreadLocalMap(this, firstValue);
    }      

說白了,目前的副本不在threadLocals存了,而是存在了inheritableThreadLocals中。

接着看Thread的構造方法:

public Thread(Runnable target) {
        init(null, target, "Thread-" + nextThreadNum(), 0);
    }

    private void init(ThreadGroup g, Runnable target, String name,
                      long stackSize) {
        init(g, target, name, stackSize, null, true);
    }

    private void init(ThreadGroup g, Runnable target, String name,
                      long stackSize, AccessControlContext acc,
                      boolean inheritThreadLocals) {
        if (name == null) {
            throw new NullPointerException("name cannot be null");
        }

        this.name = name;

        Thread parent = currentThread();
        ...省略無關代碼
        //inheritThreadLocals為true,父線程的inheritableThreadLocals也不為空
        if (inheritThreadLocals && parent.inheritableThreadLocals != null)
            //則利用父線程的inheritableThreadLocals去建立子線程的inheritableThreadLocals
            this.inheritableThreadLocals =
                ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
        /* Stash the specified stack size in case the VM cares */
        this.stackSize = stackSize;

        /* Set thread ID */
        tid = nextThreadID();
    }      

接着進入createInheritedMap方法:

static ThreadLocalMap createInheritedMap(ThreadLocalMap parentMap) {
        return new ThreadLocalMap(parentMap);
    }

    private ThreadLocalMap(ThreadLocalMap parentMap) {
        Entry[] parentTable = parentMap.table;
        int len = parentTable.length;
        setThreshold(len);
        table = new Entry[len];
    
        //将parentTable上key不為null的Entry複制到目前table上
        for (int j = 0; j < len; j++) {
            Entry e = parentTable[j];
            if (e != null) {
                @SuppressWarnings("unchecked")
                ThreadLocal<Object> key = (ThreadLocal<Object>) e.get();
                if (key != null) {
                    //key.childValue傳回e.value
                    Object value = key.childValue(e.value);
                    Entry c = new Entry(key, value);
                    int h = key.threadLocalHashCode & (len - 1);
                    while (table[h] != null)
                        h = nextIndex(h, len);
                    table[h] = c;
                    size++;
                }
            }
        }
    }      

不過需要注意的是,如果在拷貝之後,父線程再進行set的話,子線程肯定是感覺不到的。

六、ThreadLocal與記憶體洩漏

什麼是記憶體洩露?

大白話講,就是我自己建立的對象,在一系列操作後,我通路不到該對象了,我認為它已經被回收掉了,但該對象卻一直存在與記憶體中。

那什麼是記憶體溢出呢?

記憶體溢出是在有限的堆記憶體中(當然記憶體溢出的區域不止這一塊)申請了大量的對象,造成oom的情況。

那兩者的差別呢?

記憶體洩露比較嚴重的時候會導緻記憶體溢出,如果每次gc後,堆記憶體都不能下降到一個比較低的占用量,那麼可以使用jmap dump堆記憶體,再使用MAT找出導緻記憶體洩漏的對象。

我們以一開頭的例子來畫出運作時的堆棧圖:

ThreadLocal源碼、InheritableThreadLocal與記憶體洩露,這一篇給你捋順了

 為什麼這裡的key保持着對ThreadLocal的一個弱引用呢?保持強引用行不行?

假設這裡的key保持對ThreadLocal的強引用,則當我的程式用不到該ThreadLocal時,我手動執行了tl=null,此時1号線斷開,而這裡的5号線是實線,5号線沒有斷開,是以ThreadLocal對象無法被回收掉,一直存在于記憶體中,造成記憶體洩露。

看來,這裡的弱引用,能夠保證用不到的ThreadLocal被回收掉。

弱引用就能完全防止記憶體洩露了嗎?

由上面的分析,弱引用能夠防止釋放不掉ThreadLocal引起的記憶體洩露。但是,卻不能防止釋放不掉Integer引起的記憶體洩露。首先,執行tl=null,則1号線斷開,GC到來時,5号線斷開,此時ThreadLocal被回收掉了,這個key被置為了null,可是這個key對應的value強引用着Integer對象,該Integer無法在使用者代碼中通路到了,但卻依然存在于記憶體中,造成記憶體洩露。

既然依然存在着記憶體洩露,那麼JDK團隊是怎麼解決的呢?

從上文的源碼分析來看,ThreadLocal中的get()、set()方法,不是單純地去做擷取、設定的操作。在它們的方法内部,依然會周遊該Entry數組,删除所有key為null的Entry,并将相關的value置為null,進而夠解決因釋放不掉value而引起的記憶體洩露。

有這些get()、set()方法,就能完全地防止記憶體洩漏嗎?

但我們手動将tl置為null後,就已經沒法調用這些get()、set()方法了。是以,預防記憶體洩露的最佳實踐是,在使用完ThreadLocal後,先調用tl.remove(),再調用tl=null。tl.remove()能夠使得ThreadLocalMap删除該ThreadLocal所在的Entry,以及将value置為null,tl=null使得ThreadLocal對象真正地被回收掉。

其實記憶體洩露的問題,核心在于ThreadLocal與Thread的生命周期不一緻。

有兩種情況:

(1)ThreadLocal的生命周期長于Thread,此時的Thread銷毀後,内部的ThreadLocalMap也逐漸銷毀,這種情況是不會發生記憶體洩露的。

(2)線上程池相關的場景下,ThreadLocal的生命周期是明顯短于Thread的。當ThreadLocal被置為null,而又沒在其之前調用remove時,記憶體洩露就開始了,一直持續到Thread銷毀。

還有哪些記憶體洩露的場景呢?怎麼去解決呢?

【1】長生命周期的對象持有短生命周期對象的引用,就很有可能造成記憶體洩露。

長生命周期的對象往往和整個程式的生命周期相同,若是當它們持有短生命周期的對象的引用,盡管短對象不再被使用,也無法被垃圾回收器回收,因為垃圾回收器無法回收被強引用所關聯的對象。

解決方案:

(1)像一些靜态的集合類,它們的生命周期和整個程式相同,盡管放入集合中的元素不再需要,就算将元素強行置為null,但由于集合類持有它們的引用,這些元素占據的空間也得不到釋放,那麼在必要的時候,我們可以将集合類對象類型的變量置為null。

(2)單例模式中,單例與整個程式的生命周期一緻,如果單例對象持有其他短對象的引用,也很容易造成記憶體洩露,這還得靠我們謹慎編碼。

(3)又或是資料庫連接配接對象(長生命周期),ResultSet與Statement對象(短生命周期),連接配接不再被使用時,需要調用其close()方法,釋放長對象與短對象。同理,需要顯式調用close()方法的長生命周期的對象還有Socket、IO流、Session等。

【2】非靜态的外部類會隐式地持有外部類的一個強引用

在Android中,如果在Activity内聲明一個非靜态的内部類,那麼隻要該内部類沒有被回收的話,那麼外部類Activity就無法被回收,Activity所關聯的視圖和資源也不會被回收,這樣的記憶體洩露比較嚴重。

解決方案:

(1)将非靜态内部類改為靜态内部類,靜态内部類是屬于類的,是以不會依賴于外部類的執行個體,進而不持有外部類執行個體的引用。

(2)顯式地聲明非靜态内部類持有外部類的一個弱引用,被弱引用關聯的對象,在下一次垃圾回收器活動時,就會被回收。

七、ThreadLocal的使用場景

ThreadLocal一個典型的場景就是,我們需要在某個線程内儲存全局可流通的屬性,避免參數傳遞的麻煩。

大可以使用攔截器,将請求的token資訊解析成使用者屬性,放在ThreadLocal中,之後在該線程執行的任何地方都可以擷取到使用者屬性。