執行緒封閉之ThreadLocal原始碼詳解

江溢Jonny發表於2018-03-18

掘金江溢Jonny,轉載請註明原創出處,謝謝!

本文內容將基於JDK1.7的原始碼進行討論,並且在文章的結尾,筆者將會給出一些經驗之談,希望能給學習者帶來些幫助。

執行緒封閉之ThreadLocal原始碼詳解

一、執行緒封閉

在《Java併發程式設計實戰》一書中提到,“當訪問共享的可變資料時,通常需要使用同步。一種避免使用同步的方式就是不共享資料”。因此提出了“執行緒封閉”的概念,一種經常使用執行緒封閉的應用場景就是JDBC的Connection,通過執行緒封閉技術,可以把連結物件封閉在某個執行緒內部,從而避免出現多個執行緒共享同一個連結的情況。而執行緒封閉總共有三種型別的呈現形式:

1)Ad-hoc執行緒封閉。維護執行緒封閉性的職責由程式實現來承擔,然而這種實現方式是脆弱的;

2)棧封閉。實際上通過儘量使用區域性變數的方式,避免其他執行緒獲取資料;

3)ThreadLocal類。通過JDK提供的ThreadLocal類,可以保證某個物件僅線上程內部被訪問,而該類正是本篇文章將要討論的內容。

二、誤區

網上很多人會想當然的認為,ThreadLocal的實現就是一個類似Map<Thread, T>的物件,其中物件中儲存了特定某個執行緒的值,然而實際上的實現並非如此,筆者在這裡將就著JDK 1.7的原始碼對ThreadLocal的實現進行解讀,如果有不對的或者不理解的地方,歡迎留言斧正。

三、舉個栗子

SimpleDateFormat是JDK提供的,一類用於處理時間格式的工具,但是因為早期的實現,導致這個類並非是一個執行緒安全的實現,因此,在使用的時候我們會需要使用執行緒封閉技術來保證使用該類過程中的執行緒安全,在這裡,我們使用了ThreadLocal,下面的實現是使用SimpleDateFormat格式化當前時間並輸出:

private static ThreadLocal<SimpleDateFormat> localFormatter =
                     new ThreadLocal<SimpleDateFormat>();
static {
    localFormatter.set(new SimpleDateFormat("yyyyMMdd"));
}
 
public static void main(String[] args) {
    Date now = new Date();
    System.out.println(localFormatter.get().format(now));
}
複製程式碼

四、系統設計

在JDK 1.7中,ThreadLocal是一個如下圖所示的設計:

ThreadLocal設計
可以在圖裡看到,每個執行緒內部都持有一個ThreadLocal.ThreadLocalMap型別的物件,但是該物件只能被ThreadLocal類處理。那麼讀者暫時可以理解成,每個執行緒的內部都持有了一個類似Map<ThreadLocal, T>結構的表(實際上,Map的維護的鍵值對,是一個WeakReference的弱引用結構,這個比SoftReference還要弱一點)。

為什麼這樣設計?

看到這裡,有的讀者會產生這樣的提問,為什麼是這樣的設計?好問題,按照很多的人的想法裡,應該有兩種設計方式:

1)全域性ConcurrentMap<Thread,T>結構。該設計在對應的ThreadLocal物件內維持一個本地變數表,以當前執行緒(使用Thread.currentThread()方法)作為key,查詢對應的的本地變數(value值),那麼這麼設計存在什麼問題呢?

第一,全域性的ConcurrentMap<Thread, T>表,這類資料結構雖然是一類分段式且執行緒安全的容器,但是這類容器仍然會有執行緒同步的的額外開銷;

第二,隨著執行緒的銷燬,原有的ConcurrentMap<Thread, T>沒有被回收,因此導致了記憶體洩露;

2)區域性HashMap<ThreadLocal, T>的結構。在該設計下,每個執行緒物件維護一個Map<ThreadLocal, T>,可以這樣仍然會存在一些問題:

比如某個執行緒執行時間非常長,然而在此過程中,某個物件已經不可達(理論上可以被GC),但是由於HashMap<ThreadLocal, T>資料結構的存在,仍然有物件被當前執行緒強引用,從而導致了該物件不能被GC,因此同樣也會導致記憶體洩露。

五、原始碼實現

在闡述完ThreadLocal設計以後,我們一起來看看JDK1.7 是怎麼實現ThreadLocal的。

ThreadLocal類的本身實現比較簡單,其程式碼的核心和精髓實際都在它的內部靜態類ThreadLocalMap中,因此這裡我們不再贅述ThreadLocal類的各種介面方法,直接進入主題,一起來研究ThreadLocalMap類相關的原始碼。

首先我們翻閱Thread類的原始碼,可以看到這麼一句:

public
class Thread implements Runnable {
...
ThreadLocal.ThreadLocalMap threadLocals = null; // 注意這裡
...
}
複製程式碼

可以看到在每個Thread類的內部,都耦合了一個ThreadLocalMap型別的引用,由於ThreadLocalMap類是ThreadLocal類的私有內嵌類,因此ThreadLocalMap型別的物件只能由ThreadLocal類打理:

public class ThreadLocal<T> {
    ...
    // 內部私有靜態類
    static class ThreadLocalMap {
        ...
    }
    ...
}
複製程式碼

關於ThreadLocalMap類實現,我們也可以把它理解成是一類雜湊表,那麼作為雜湊表,就要包含:資料結構定址方式雜湊表擴容(Rehash),除了雜湊表的部分外,ThreadLocalMap還包含了“垃圾回收”的過程。因此,我們將按以上模組分別介紹ThreadLocalMap類的實現。

1. 資料結構

那麼接下來我們看看ThreadLocalMap中資料結構的定義:

static class ThreadLocalMap {
    static class Entry extends WeakReference<ThreadLocal> {
        Object value; // 實際儲存的值
 
        Entry(ThreadLocal k, Object v) {
            super(k);
            value = v;
        }
    }
 
    /**
     * 雜湊表初始大小,但是這個值無論怎麼變化都必須是2的N次方
     */
    private static final int INITIAL_CAPACITY = 16;
 
    /**
     * 雜湊表中實際存放物件的容器,該容器的大小也必須是2的冪數倍
     */
    private Entry[] table;
 
    /**
     * 表中Entry元素的數量
     */
    private int size = 0;
 
    /**
     * 雜湊表的擴容閾值
     */
    private int threshold; // 預設值為0
 
    private void setThreshold(int len) {
        threshold = len * 2 / 3;
    }
  
    ...
    /**
    * 並不是Thread被建立後就一定會建立一個新的ThreadLocalMap,
    * 除非當前Thread真的用了ThreadLocal
    * 並且賦值到ThreadLocal後才會建立一個ThreadLocalMap
    */
    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);
    }
複製程式碼

可以從上面看到這些資訊:

1)存放物件資訊的表是一個陣列。這類方式和HashMap有點像;

2)陣列元素是一個**WeakReference(弱引用)**的實現。弱引用是一類比軟引用更加脆弱的型別(按照強弱程度分別為 強引用>軟引用 > 弱引用 > 虛引用),至於為什麼使用弱引用,這是因為執行緒的執行時間可能很長,但是對應的ThreadLocal物件生成時間未必有執行緒的執行壽命那般長,在對應ThreadLocal物件由該執行緒作為根節點出發,邏輯上不可達時,就應該可以被GC,如果使用了強引用,該物件無法被成功GC,因此會帶來記憶體洩露的問題;

3)雜湊表的大小必須是2的N次方。至於這部分,在後面會提到,實際上這個長度的設計和位運算有關;

4)閾值threshold。這個概念同樣和HashMap內部實現的閾值類似,當陣列長度到了某個閾值時,為了減少雜湊函式的碰撞,不得不擴充套件容量大小;

結構如圖所示,虛線部分表示的是一個弱引用

Entry引用

2、定址方式

首先我們根據getEntry()方法一起來觀察一下根據雜湊演算法定址某個元素的過程,可以看到,這是一類“直接定址法”的實現:

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);
}
複製程式碼

在這裡我們注意到一個“key.threadLocalHashCode”物件,該物件的生成方式如下:

public class ThreadLocal<T> {
    private final int threadLocalHashCode = 
                                    nextHashCode();
 
    /**
    * 計算雜湊值相關的魔數
    */
    private static final int HASH_INCREMENT = 0x61c88647;
 
    /**
    * 返回遞增後的雜湊值
    */
    private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT);
    }
複製程式碼

根據一個固定的值0x61c88647(為什麼是這個數字,我們稍後再提),在每次生成新的ThreadLocal物件時遞增這個雜湊值

之前已經提到了,table的length必須滿足2的N次方,因此按照位運算"key.threadLocalHashCode & (table.length - 1)"獲得是雜湊值的的末N位,根據這一雜湊演算法計算的結果取到雜湊表中對應的元素。可是這個時候,又會遇到雜湊演算法的經典問題——雜湊碰撞

針對雜湊碰撞,我們通常有三種手段:

1)拉鍊法。這類雜湊碰撞的解決方法將所有關鍵字為同義詞的記錄儲存在同一線性連結串列中。JDK1.7已經在HashMap類中實現了,感興趣的可以去看看;

2)再雜湊法。當發生衝突時,使用第二個、第三個、雜湊函式計算地址,直到無衝突時。缺點:計算時間增加。比如第一次按照姓首字母進行雜湊,如果產生衝突可以按照姓字母首字母第二位進行雜湊,再衝突,第三位,直到不衝突為止;

3)開放地址法(ThreadLocalMap使用的正是這類方法)。所謂的開放定址法就是一旦發生了衝突,就去尋找下一個空的雜湊地址,只要雜湊表足夠大,空的雜湊地址總能找到,並將記錄存入。

那麼我們一起來看看ThreadLocalMap的實現,我們通過getEntry()方法按照雜湊函式取得雜湊表中的值,在該方法內部,我們將用到一個getEntryAfterMiss()方法:

/**
 * 如果在getEntry方法中不能馬上找到對應的Entry,將呼叫該方法
 *
 * @param  e table[i]對應的entry值
 */
private Entry getEntryAfterMiss(
                ThreadLocal key, int i, Entry e) {
    Entry[] tab = table;
    int len = tab.length;
 
    while (e != null) {
        ThreadLocal k = e.get();
        if (k == key)
            return e;
        if (k == null)
            // 對從該位置開始的的物件進行清理(開發者主動GC)
            expungeStaleEntry(i); 
        else
            // 查詢下一個物件
            i = nextIndex(i, len); 
        e = tab[i];
    }
    return null;
}
複製程式碼

在該方法中可以看到,當根據雜湊函式直接查詢對應的位置失敗後,就會從當前的位置往後開始尋找,直到找到對應的key值,另外,如果發現有key值已經被GC了,那麼相應的,也應該啟動expungeStaleEntry()方法,清理掉無效的Entry。

類似的,ThreadLocalMap類的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(); // 擴容
}
複製程式碼

該函式在“定址方式”上和getEntry()方法類似,因此就不展開闡述了。

為什麼是0x61c88647

這個魔數的選取與斐波那契雜湊有關,0x61c88647對應的十進位制為1640531527。斐波那契雜湊的乘數可以用(long) ((1L << 31) * (Math.sqrt(5) - 1))可以得到2654435769,如果把這個值給轉為帶符號的int,則會得到-1640531527(也就是0x61c88647)。通過理論與實踐,當我們用0x61c88647作為魔數累加為每個ThreadLocal分配各自的ID也就是threadLocalHashCode再與2的冪取模,得到的結果分佈很均勻。ThreadLocalMap使用的是線性探測法,均勻分佈的好處在於很快就能探測到下一個臨近的可用slot,從而保證效率。

3、雜湊表擴容(Rehash)

我們一起來回憶一下,table物件的起始容量是可以容納16個物件,在set()方法的尾部可以看到以下內容:

// 清理無效資料後判斷是否仍需擴容
if (!cleanSomeSlots(i, sz) && sz >= threshold) 
        rehash(); // 擴容
複製程式碼

如果當前容量大小大於閾值(threshold)後,將會發起一次擴容(rehash)操作。

private void rehash() {
    expungeStaleEntries();
 
    if (size >= threshold - threshold / 4)
        resize();
}
複製程式碼

在該方法中,首先嚐試徹底清理表中的無效元素(失效的弱引用),然後判斷當前是否仍然大於threshold值的3/4。

而threshold值,在文章開始的時候就已經提起過,是當前容量大小的2/3:

/**
* 在當前容量大小超過table大小的2/3時可能會觸發一次rehash操作
*/
private void setThreshold(int len) {
    threshold = len * 2 / 3;
}
複製程式碼

那麼我們一起看看resize()方法:

/**
 * 成倍擴容table
 */
private void resize() {
    Entry[] oldTab = table;
    int oldLen = oldTab.length;
    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; // 釋放無效的物件
            } 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;
}
複製程式碼

在該方法內部,首先建立一個新的表,表的大小是原來表大小的兩倍,然後再逐個複製原表內容到新表中,如果發現有無效物件,則把Entry物件中對應的value引用置為NULL,方便後面垃圾收集器對該物件的回收。

4、垃圾回收

此時筆者再次貼出引用的圖示:

Entry引用
可以看到Entry物件到ThreadLocal物件是一個弱引用的關係,而指向Object物件仍然是一個強引用的關係,因此,雖然由於弱引用的ThreadLocal物件隨著ROOT路徑不可達而被垃圾收集器清理後,但是仍然殘留有Object物件,不及時清理會存在“記憶體洩露”的問題。

那麼我們看看和垃圾收集有關的方法:

/**
 * 該方法將在set方法中被呼叫,在set某個值時,通過雜湊函式指向某個位置,然而
 * 此時該位置上存在一個垃圾Entry,將會嘗試使用此方法用新值覆蓋舊值,不過該方
 * 法還承擔了“主動垃圾回收”的功能。
 *
 * @param  key 以ThreadLocal類物件作為key
 * @param  value 通過ThreadLocal類物件找到對應的值
 */
private void replaceStaleEntry(
      ThreadLocal key, Object value,int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;
    Entry e;
 
    // 向前掃描,查詢最前的一個無效slot
    int slotToExpunge = staleSlot;
    for (int i = prevIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = prevIndex(i, len))
        if (e.get() == null)
            slotToExpunge = i;
 
 
    // 向後遍歷table,直到當前表中所指的位置是一個空值或
    // 者已經找到了和ThreadLocal物件匹配的值
    for (int i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        ThreadLocal k = e.get();
 
        // 之前設定新值時,如果當前雜湊位存在衝突,
        // 那麼就要順延到後面空的slot中存放。
        // 既然當前雜湊位原來對應的ThreadLocal物件已經
        // 被回收了,那麼被順延放置的ThreadLocal物件
        // 自然就要被向前調整到當前位置中去
        if (k == key) {
            e.value = value;
 
            tab[i] = tab[staleSlot];
            tab[staleSlot] = e; // swap操作
 
            if (slotToExpunge == staleSlot)
                slotToExpunge = i;
            // 清理一波無效slot
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
            return; // 找到了就直接返回
        }
 
        // 如果當前的slot已經無效,並且向前掃描過程中沒有無效slot,
        // 則更新slotToExpunge為當前位置
        if (k == null && slotToExpunge == staleSlot)
            slotToExpunge = i;
    }
 
    // key沒找到就原地建立一個新的
    tab[staleSlot].value = null;
    tab[staleSlot] = new Entry(key, value);
 
    // 在探測過程中如果發現任何無效slot,
    // 則做一次清理(連續段清理+啟發式清理)
    if (slotToExpunge != staleSlot)
        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}
 
/**
 * 這個函式做了兩件事情:
 * 1)清理當前無效slot(由staleSlot指定位置)
 * 2)從staleSlot開始,一直到null位,清理掉中間所有的無效slot
 */
private int expungeStaleEntry(int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;
 
    // 清理當前無效slot(由staleSlot指定位置)
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;
 
    // 從staleSlot開始,一直到null位,清理掉中間所有的無效slot
    Entry e;
    int i;
    for (i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        ThreadLocal k = e.get();
        if (k == null) {
            // 清理掉無效slot
            e.value = null;
            tab[i] = null;
            size--;
        } else {
            int h = k.threadLocalHashCode & (len - 1);
            // 當前ThreadLocal不在它計算出來的雜湊位上,
            // 說明之前在插入的時候被順延到雜湊位後面放置了,
            // 因此此時需要向前調整位置
            if (h != i) {
                tab[i] = null;
 
                // 從計算出來的雜湊位開始往後查詢,找到一個適合它的空位
                while (tab[h] != null)
                    h = nextIndex(h, len);
                tab[h] = e;
            }
        }
    }
    return i;
}
 
/**
 * 啟發式地清理slot,
 * n是用於控制控制掃描次數的
 * 正常情況下如果log2(n)次掃描沒有發現無效slot,函式就結束了
 * 但是如果發現了無效的slot,將n置為table的長度len,做一次連續段的清理
 * 再從下一個空的slot開始繼續掃描
 */
private boolean cleanSomeSlots(int i, int n) {
    boolean removed = false;
    Entry[] tab = table;
    int len = tab.length;
    do {
        i = nextIndex(i, len);
        Entry e = tab[i];
        if (e != null && e.get() == null) {
            n = len;
            removed = true;
            i = expungeStaleEntry(i);
        }
    } while ( (n >>>= 1) != 0);
    return removed;
}
複製程式碼

下面我來圖解一下expungeStaleEntry方法的流程:

expungeStaleEntry方法

以上是ThreadLocal原始碼介紹的全部內容。下面筆者將補充一些在實際開發過程中遇到的問題,作為補充資訊一併分享。

六、經驗之談

1、謹慎在ThreadExecutorPool中使用ThreadLocal

在ThreadExecutorPool中,Thread是複用的,因此每個Thread對應的ThreadLocal空間也是被複用的,如果開發者不希望ThreadExecutorPool中的下一個Task能讀取到上一個Task在ThreadLocal中存入的資訊,那就不應該使用ThreadLocal。

舉個例子:

final ThreadLocal<String> threadLocal = 
       new ThreadLocal<String>();
// 執行緒池大小為1
ThreadPoolExecutor threadPoolExecutor = 
      new ThreadPoolExecutor(1, 1, 60, TimeUnit.SECONDS, 
                    new LinkedBlockingDeque<Runnable>());
// 任務1
threadPoolExecutor.execute(new Runnable() {
    public void run() {
        threadLocal.set("first insert"); 
    }
});
// 任務2
threadPoolExecutor.execute(new Runnable() {
    public void run() {
        System.out.println(threadLocal.get());
    }
});
複製程式碼

像這樣,第二個任務能讀取到第一個任務插入的資料。但是如果此時執行緒池中任務一丟擲一個異常出來:

final ThreadLocal<String> threadLocal = 
                      new ThreadLocal<String>();
// 執行緒池大小為1
ThreadPoolExecutor threadPoolExecutor = 
  new ThreadPoolExecutor(1, 1, 60, TimeUnit.SECONDS, 
             new LinkedBlockingDeque<Runnable>());
// 任務1
threadPoolExecutor.execute(new Runnable() {
    public void run() {
        threadLocal.set("first insert");
        // 拋一個異常
        throw new RuntimeException("throw a exception"); 
 
    }
});
// 任務2
threadPoolExecutor.execute(new Runnable() {
    public void run() {
        System.out.println(threadLocal.get());
    }
});

複製程式碼

那麼此時,第二個任務無法讀取到第一個任務插入的資料(因為第一個執行緒因為拋異常已經死了,任務二用的是新執行緒執行)

2、不要濫用ThreadLocal

很多開發者為了能夠在類和類直接傳輸資料,而不想把方法裡的參數列寫得過於龐大,那麼可能會帶來類於類直接重度耦合的問題,這樣不利於後面的開發。

3、要先set才能get

繼續舉個例子:

public class TestMain {
    public ThreadLocal<Integer> intThreadLocal = 
                          new ThreadLocal<Integer>();
 
    public int getCount() {
        return intThreadLocal.get();
    }
 
    public static void main(String[] args) {
        System.out.println(new TestMain().getCount());
    }
}
複製程式碼

在這裡,沒有先set就直接get,將會丟擲一個NullPointerException,原因我們一起來回顧一下ThreadLocal的程式碼:

public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null)
            return (T)e.value;
    }
    return setInitialValue(); // 返回了NULL導致NPE
}
 
private T setInitialValue() {
    T value = initialValue(); // 這裡返回了NULL
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
    return value;
}
複製程式碼

以上就是“執行緒封閉之ThreadLocal原始碼詳解”的全部內容了,如果還想進一步的交流,歡迎關注我的微信公眾號“Jonny的日知錄”~:-D

執行緒封閉之ThreadLocal原始碼詳解

相關文章