ThreadLocal源碼探究 (JDK 1.8)

ThreadLocal類以前有了解過,看過一些文章,自覺得對其理解得比較清楚了。偶然刷到了一道關於ThreadLocal內存泄漏的面試題,竟然徹底不知道是怎麼回事,痛定思痛,發現瞭解問題的本質仍是須要從源碼看起。
ThreadLocal能夠保存一些線程私有的數據,從而避免多線程環境下的數據共享問題。ThreadLocal存儲數據的功能是經過ThreadLocalMap實現的,這是ThreadLocal的一個靜態內部類。ThreadLocal源碼加註釋總共700多行,ThreadLocalMap就佔據了接近400行,基本上理解了ThreadLocalMap也就理解了ThreadLocal。本文先簡介ThreadLocalMap,而後從ThreadLocal的核心方法開始講起,須要用到ThreadLocalMap的地方順帶一塊兒介紹。面試

1.ThreadLocalMap簡介

ThreadLocalMap本質上仍然是一個Map,具備普通Map的特色,當遇到hash衝突的時候,採用線性探測的方式來解決衝突,底層使用數組做爲存儲結構,它的主要字段以下:數組

  • INITIAL_CAPACITY:初始容量,默認是16
  • Entry[] table:存儲鍵值對的數組,其大小是2的整數冪
  • size:數組內存儲的元素個數
  • threshold:擴容閾值
    ThreadLocalMap底層數組保存的是Entry類型鍵值對,EntryThreadLocalMap的一個內部類,它是用來存儲鍵值對的對象,值得關注的是Entry繼承了WeakReference這個弱引用類,這意味着Entry的key引用的對象,在沒有其餘強引用的狀況下,在下一次GC的時候就會被回收(注意:這裏忽略了軟引用,由於軟引用是在即將由於內存不足而拋出異常的時候纔會回收)。而且EntrykeyThreadLocal對象,經過其祖父類Reference的構造函數能夠看到,key其實是被保存在referent字段中,Entry對象的get方法也是從Reference繼承過來的,直接返回該referent字段。
static class Entry extends WeakReference<ThreadLocal<?>> {
        Object value;

        Entry(ThreadLocal<?> k, Object v) {
            super(k);
            value = v;
        }
    }
    
    //Entry的構造器調用了父類的構造,最終是經過Reference的構造器實現的
    Reference(T referent) {
        this(referent, null);
    }

    Reference(T referent, ReferenceQueue<? super T> queue) {
        this.referent = referent;
        this.queue = (queue == null) ? ReferenceQueue.NULL : queue;
    }
    
    //Reference的get方法
    public T get() {
        return this.referent;
    }

在對Entry有了初步瞭解後,如今來思考一下爲何key要設計成弱引用呢?假設如今採有強引用來設計key,考慮以下代碼:多線程

ThreadLocal<String> tl1 = new ThreadLocal<>();
    tl1.set("abc");
    tl1=null;

此時,相關的引用狀況以下圖:
函數

tl1雖然再也不引用堆上的ThreadLocal對象,可是線程的ThreadLocalMap裏還保留着對該對象的強引用,要獲取該對象就須要ThreadLocal對象做爲key,可是這個key如今已是null了。也就是說,此時已經沒有任何辦法可以訪問到堆上的TheradLocal對象,可是因爲還有強引用的存在,致使這個對象沒法被GC回收。這種狀況顯然不是咱們但願看到的,所以Entrykey不能被設計爲強引用。設計成弱引用是合理的,一旦外界的強引用被取消,就應當容許key所引用的對象被回收。this

2.ThreadLocal核心方法

  • get
    get方法用來獲取存儲在ThreadLocal中的元素,其源碼以下:
public T get() {
        Thread t = Thread.currentThread();
        //獲取當前線程內部的ThreadLocalMap對象
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        //執行到這裏的兩種狀況:1)map沒初始化;2)map.getEntry返回null
        return setInitialValue();
    }
    
    //從這裏能夠看到,每一個Thread示例內部都有一個ThreadLocalMap類型的字段,線程局部變量就存在這個Map中
    ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }

    //ThreadLocalMap的getEntry方法
    private Entry getEntry(ThreadLocal<?> key) {
        //計算key位於哪一個桶
        int i = key.threadLocalHashCode & (table.length - 1);
        Entry e = table[i];
        if (e != null && e.get() == key)
            return e;
        //執行到這裏的兩種狀況:1)e=null,即桶內沒有存數據;2)桶內有數據,
        //但不是當前這個ThreadLocal對象的,說明產生了hash衝突,致使鍵值對被放到了其餘位置
        else
            return getEntryAfterMiss(key, i, e);
    }

線程可以保存私有變量的緣由就在於其成員變量threadLocals,每一個線程都有這樣的結構,互相不干擾。get方法的代碼很簡單,根據從線程內取到的ThreadLocalMap對象,若是ThreadLocalMap還沒初始化,則先初始化;若是已完成初始化,調用其getEntry方法取元素,取不到的話,就會執行getEntryAfterMiss方法(ThreadLocal內部只在getEntry方法裏調用了getEntryAfterMiss),先看看setInitialValue方法的邏輯:線程

private T setInitialValue() {
        T value = initialValue();
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        //map已經初始化,就將鍵值對存入底層數組
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
        return value;
    }
    
    //默認的initialValue返回null,並且該方法是protected,目的顯然是讓子類進行重寫
    protected T initialValue() {
        return null;
    }

setInitialValue的邏輯很簡單,假如map沒有初始化,執行createMap方法進行初始化,不然將當前ThreadLocal對象和null構形成一個新的Entry放入數組內。接下來看一下createMap的初始化邏輯:設計

//能夠看到,初始化的過程就是對Thread內部變量threadLocals賦值的過程,用到了ThreadLocalMap的構造器
    void createMap(Thread t, T firstValue) {
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }
    
    //
    ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
        //使用默認容量
        table = new Entry[INITIAL_CAPACITY];
        //計算位置,並初始化對應的桶
        int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
        table[i] = new Entry(firstKey, firstValue);
        size = 1;
        setThreshold(INITIAL_CAPACITY);
    }

    //ThreadLocalMap的擴容使用的是2/3做爲加載因子,這點與HashMap等容器不一樣
    private void setThreshold(int len) {
        threshold = len * 2 / 3;
    }

該方法經過ThreadLocalMap的構造器對內部數組進行初始化,並將對應的值添加到數組中。能夠看到,ThreadLocalMap有容量的概念,但卻沒有辦法指定其初始容量,在構造的時候使用固定值16做爲初始容量,並且擴容閾值設置的是容量的2/3,這一點與HashMap等容器的作法不一樣。
接下來看看getEntryAfterMiss方法的源碼:指針

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

        while (e != null) {
            ThreadLocal<?> k = e.get();
            //找到key就直接返回
            if (k == key)
                return e;
            //注意這裏:當發現key=null是時,說明其對應的ThreadLocal對象已被GC回收,
            //此時會經過expungeStaleEntry將一部分key爲null的桶清空
            if (k == null)
                expungeStaleEntry(i);
            //走到這裏說明存在hash衝突,當前桶被其餘元素佔了,使用nextIndex向後找一個位置
            else
                i = nextIndex(i, len);
            e = tab[i];
        }
        //若是e=null,在這裏返回null
        return null;
    }
    
    /nextIndex的主要做用是:查找下一個桶,若是到達末尾,則從頭開始
    private static int nextIndex(int i, int len) {
        return ((i + 1 < len) ? i + 1 : 0);
    }

在桶內的key=null時,會調用expungeStaleEntry方法,從命名能夠看出,這個方法主要功能是將ThreadLocalMapkey=null的元素清理掉。下面是對這個方法的講解:code

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

            // expunge entry at staleSlot
            //分別將Entry的鍵和值清空
            tab[staleSlot].value = null;
            tab[staleSlot] = null;
            //元素數量減1
            size--;

            // Rehash until we encounter null
            Entry e;
            int i;
            //從當前下標的下一個位置開始遍歷,清空key=null的桶,並更新hash衝突的元素的位置
            //循環終止條件:順序向後遍歷時,找到一個非空的桶則循環終止,所以這裏只是做了局部清理
            for (i = nextIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = nextIndex(i, len)) {
                ThreadLocal<?> k = e.get();
                //若是key = null,則清空該桶
                if (k == null) {
                    e.value = null;
                    tab[i] = null;
                    size--;
                } else {
                    int h = k.threadLocalHashCode & (len - 1);
                    //h!=i說明當前元素是由於hash衝突,以前的桶被佔了才放在了i這個桶內,
                    //那麼就從其原來的位置h開始向後查找,找到第一個空桶,就把元素挪過去,
                    //目的是爲了保證元素距離其正確位置最近,減小後續的查找成本
                    if (h != i) {
                        tab[i] = null;

                        // Unlike Knuth 6.4 Algorithm R, we must scan until
                        // null because multiple entries could have been stale.
                        while (tab[h] != null)
                            h = nextIndex(h, len);
                        tab[h] = e;
                    }
                }
            }
            //返回值是第一個不爲空的桶的下標
            return i;
        }

expungeStaleEntry的循環邏輯說明,在對失效元素進行清空時,不是清空全部失效的桶,而是從當前位置向後遍歷,只要找到一個非空的桶,清理的過程就結束了。也就是說,這種清理會致使有一些過時失效的桶沒法獲得清理。對象

  • set
    介紹完get方法後,如今再來看看set方法的實現邏輯:
public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        //map已建立則直接設值
        if (map != null)
            map.set(this, value);
        //map未建立,則建立map
        else
            createMap(t, value);
    }

來看看mapset方法是如何設值的:

private void set(ThreadLocal<?> key, Object value) {

        // We don't use a fast path as with get() because it is at
        // least as common to use set() to create new entries as
        // it is to replace existing ones, in which case, a fast
        // path would fail more often than not.

        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();
            
            //若是當前ThreadLocal對應的有值,則更新
            if (k == key) {
                e.value = value;
                return;
            }
            
            //若是k=null,說明對應的ThreadLocal對象已被GC回收,執行replaceStaleEntry的邏輯
            if (k == null) {
                //這裏是replaceStaleEntry方法的惟一調用點
                replaceStaleEntry(key, value, i);
                return;
            }
        }
        //找到一個空位置,將值存進去
        tab[i] = new Entry(key, value);
        int sz = ++size;
        //若是調用cleanSomeSlots沒有清理任何桶,而且達到了擴容閾值,就執行擴容邏輯
        if (!cleanSomeSlots(i, sz) && sz >= threshold)
            rehash();
    }

set的時候須要從對應的下標開始向後遍歷,找到一個合適的位置將元素放進去,這裏合適的位置是指:a)空桶;b)桶非空,可是key對應的ThreadLocal對象已被清理。在key已經被清理的狀況下,會執行replaceStaleEntry方法的邏輯,接下來看看這個方法的代碼:

private void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) {
        Entry[] tab = table;
        int len = tab.length;
        Entry e;

        // Back up to check for prior stale entry in current run.
        // We clean out whole runs at a time to avoid continual
        // incremental rehashing due to garbage collector freeing
        // up refs in bunches (i.e., whenever the collector runs).
        int slotToExpunge = staleSlot;
        //從staleSlot這個桶向前查找,遇到第一個空桶就中止
        for (int i = prevIndex(staleSlot, len);
             (e = tab[i]) != null;
             i = prevIndex(i, len))
             //若是桶內的key=null,說明該桶能夠被回收,將slotToExpunge變量指向這個桶
            if (e.get() == null)
                slotToExpunge = i;

        // Find either the key or trailing null slot of run, whichever
        // occurs first
        //從當前桶向後遍歷
        for (int i = nextIndex(staleSlot, len);
             (e = tab[i]) != null;
             i = nextIndex(i, len)) {
            ThreadLocal<?> k = e.get();

            // If we find key, then we need to swap it
            // with the stale entry to maintain hash table order.
            // The newly stale slot, or any other stale slot
            // encountered above it, can then be sent to expungeStaleEntry
            // to remove or rehash all of the other entries in run.
            // k== key,說明這個ThreadLocal已經存在,可是距離正確的位置太遠,須要對其位置進行更正
            if (k == key) {
                e.value = value;

                //交換兩個桶內的元素,把i位置的元素放在距離其正確位置最近的桶內。
                //注意,replaceStaleEntry的惟一調用點出如今set方法內,此時staleSlot對應的桶的key=null,
                //我的推測這裏不直接賦值tab[i]=null的緣由是讓下一次expungeStaleEntry可以多清理一些空桶,
                //若是這裏設置爲null的話,下一次清理到這個位置就終止了
                tab[i] = tab[staleSlot];
                tab[staleSlot] = e;

                // Start expunge at preceding stale entry if it exists
                //下面的等式成立有兩種狀況:
                //1)staleSlot前一個桶就爲空,此時上文中前向遍歷的循環體會直接結束;
                //2)staleSlot前面的若干桶都不爲空,且桶內的key!=null,即對應的ThreadLocal對象都沒有被回收;
                //出現這兩種狀況的時候,都只能從i這個位置開始進行清理
                if (slotToExpunge == staleSlot)
                    slotToExpunge = i;
                cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
                return;
            }

            // If we didn't find stale entry on backward scan, the
            // first stale entry seen while scanning for key is the
            // first still present in the run.
            //k!=null的時候,不能清理i這個桶;slotToExpunge != staleSlot時,
            //說明在i這個位置以前就已經有須要清理的桶了,不能更新slotToExpunge這個指針的值
            if (k == null && slotToExpunge == staleSlot)
                slotToExpunge = i;
        }

        // If key not found, put new entry in stale slot
        //執行到這裏說明,直到遇到空桶,都沒有在數組中找到key,就把新的鍵值對放在staleSlot的位置。
        tab[staleSlot].value = null;
        tab[staleSlot] = new Entry(key, value);

        // If there are any other stale entries in run, expunge them
        //slotToExpunge != staleSlot說明在其餘位置找到須要清理的鍵值對,那麼就從對應的位置清理
        if (slotToExpunge != staleSlot)
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
    }

針對代碼註釋中的內容,有幾點須要強調一下。首先replaceStaleEntry這個方法惟一的調用點就是ThreadLocalMapset方法內部,並且在調用replaceStaleEntry的時候,參數staleSlot對應的桶內的Entry對象的key=null。其次,replaceStaleEntry的邏輯是從staleSlot這個桶開始,先前向遍歷,找到第一個空桶就中止遍歷,期間若是發現某個桶內的key=null,就將slotToExpunge指針指向這個桶,表示下文要從這個桶開始進行過時鍵清理。前向遍歷結束以後開始後向遍歷,找到當前的ThreadLocal對象所在的桶,將其位置更新,調用清理方法以後代碼返回,不然就一直向後找直到遇到空桶。
下面對replaceStaleEntry方法的執行流程進行梳理。
假設在方法執行時,ThreadLocalMap的存儲結構以下所示:

首先前向遍歷,遍歷到LL位置結束,因爲在L位置Entry.key=null,因此設置slotToExpunge=L

接下來開始向後遍歷,遍歷到R1位置時,雖然Entry.key=null,可是因爲slotToExpunge的值已經被修改,再也不對其進行賦值。代碼接着遍歷R2位置,在這裏找到了key,所以將該位置的值與staleSlot位置進行交換,以下圖:

以後執行expungeStaleEntry方法將LL位置清空,而後從L位置開始執行cleanSomeSlots的邏輯。
replaceStaleEntry方法內有兩處用到了cleanSomeSlots方法,接下來對其進行介紹:

private boolean cleanSomeSlots(int i, int n) {
        boolean removed = false;
        Entry[] tab = table;
        int len = tab.length;
        //循環的邏輯是:從i的下一個位置開始找那些桶不空,可是桶內Entry對應的key=null的元素,
        //而後從這些元素開始向後進行清空。循環的過程當中會跳過空桶或者桶內元素的key!=null的桶,
        //循環的次數由n的大小決定,每次將n減半,直到減爲0循環結束。
        do {
            i = nextIndex(i, len);
            Entry e = tab[i];
            if (e != null && e.get() == null) {
                n = len;
                removed = true;
                //注意:expungeStaleEntry方法的返回值是第一個不爲空的桶的下標,循環的下一次會從這個下標開始遍歷
                i = expungeStaleEntry(i);
            }
        } while ( (n >>>= 1) != 0);
        //若是清理了元素,則返回true,不然返回false
        return removed;
    }
  • rehash
    set方法內部最後幾行,若是調用cleanSomeSlots沒有清理任何桶,而且達到了擴容閾值,就執行擴容邏輯,這段邏輯在rehash方法中,來看看方法的實現邏輯:
private void rehash() {
        //清理全部的過時桶
        expungeStaleEntries();

        // Use lower threshold for doubling to avoid hysteresis
        //清理事後剩餘元素達到threshold的0.75才進行擴容,回憶一下ThreadLocalMap的初始化過程,
        //初始化時threshold=2/3*初始容量,這裏在判斷是否要擴容時,是已threshold*0.75爲標準
        if (size >= threshold - threshold / 4)
            resize();
    }
    //這個方法會從頭開始遍歷整個數組,每遇到一個ThreadLocal對象被回收的桶,就調用expungeStaleEntry方法向後清理一部分桶
    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;
        //注意這裏並無對newLen做限制,也就是說有超限的可能,可是通常確定不會在線程內放這麼多本地變量
        int newLen = oldLen * 2;
        Entry[] newTab = new Entry[newLen];
        int count = 0;
        
        //將原來的數據遷移到新的數組
        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;
    }
  • remove
    最後來看一下ThreadLocalremove方法,方法很簡單,底層邏輯仍然是經過ThreadLocalMapremove實現的:
public void remove() {
         ThreadLocalMap m = getMap(Thread.currentThread());
         if (m != null)
             m.remove(this);
     }

     private void remove(ThreadLocal<?> key) {
        Entry[] tab = table;
        int len = tab.length;
        //計算key的位置
        int i = key.threadLocalHashCode & (len-1);
        for (Entry e = tab[i];
             e != null;
             e = tab[i = nextIndex(i, len)]) {
            //若是找到key,則調用Reference類的clear方法,將referent置爲null,而後從該位置開始向後清理一部分過時鍵值對
            if (e.get() == key) {
                e.clear();
                expungeStaleEntry(i);
                return;
            }
        }
    }
    
    //Reference類的clear方法
    public void clear() {
        this.referent = null;
    }

3.內存泄漏問題

ThreadLocal的底層Entry數組的key是弱引用,意味着當ThreadLocal對象的全部強引用都被去除後,對應的ThreadLocal對象會被回收,此時Entrykey=null,可是value還維持着堆上數據的強引用,只要當前線程不退出,這個強引用會一直存在。爲了儘量緩解這個問題,ThreadLocalgetsetremove方法都會清除一批過時數據,可是從本文的分析能夠看出,這種清理只是部分清理,仍然可能遺漏掉部分數據。所以這三個方法只能在必定程度上緩解內存泄漏的問題,並不能避免。另外,若是線程在較長時間內都沒有執行上述方法,那過時的數據只會更多。那些在一段時間內都沒被清理的過時value對象仍會繼續佔用內存空間,這些未被清理的對象就是內存泄漏的源頭。固然,過時的數據會在線程退出後所有銷燬,可是當使用了線程池以後,線程用完會重複利用,並不會被銷燬,這種內存泄漏問題就不得不考慮了。所以,好習慣是在ThreadLocal對象用完以後及時使用remove方法進行刪除,從而避免內存泄漏問題。

相關文章
相關標籤/搜索