Java源碼探究:ThreadLocal工作原理完全解析

#前言
ThreadLocal是一個平時Android開發中並不常見的類,正因爲少接觸,所以對它的瞭解並不多。但實際上,它對我們常用的Handler通信機制起着重要的支撐作用。ThreadLocal,顧名思義,線程封閉的變量,也即該變量的作用範圍是以當前線程爲單位,別的線程不能訪問該變量。ThreadLocal對外提供了get和set方法,用於提供線程獨佔的變量的訪問途徑。下面我們先從使用方法來了解一下它怎麼使用。

#簡單的例子

public class ThreadLocalPractice {

    public static void main(String args[]){

        ThreadLocal<Integer> integerThreadLocal = new ThreadLocal<>();
        integerThreadLocal.set(1);

        new Thread(() -> {
            integerThreadLocal.set(2);
            System.out.println("Thread ID:" + Thread.currentThread().getId() + ",integerThreadLocal = " + integerThreadLocal.get());
        }).start();

        new Thread(()->{
            //do nothing
            System.out.println("Thread ID:" + Thread.currentThread().getId() + ",integerThreadLocal = " + integerThreadLocal.get());
        }).start();

        System.out.println("Thread ID:" + Thread.currentThread().getId() + ",integerThreadLocal = " + integerThreadLocal.get());
    }
}

運行程序,觀察結果如下:
在這裏插入圖片描述

在線程1中,我們設置了threadlocal的值爲1,然後在子線程中設置爲2以及不做任何修改,得到的結果分別是1、2和null,這說明了ThreadLocal的作用域限制在了某一線程中,是線程封閉了,一個線程的ThreadLocal的值改變了,並不影響另一條線程的ThreadLocal的值。下面,我們就從源碼的角度來分析它的工作原理。

#源碼分析
注意:下面源碼來自JDK-10。
1、幾個關鍵的類或對象
在真正閱讀源碼之前,筆者先列舉出幾個關鍵點,以便分析的時候能更容易理解源碼。
①ThreadLocal.ThreadLocalMap 這是ThreadLocal的一個靜態內部類,它本質上是一個Hash Map,是專門爲維護線程私有的變量而定製的。

public class ThreadLocal {

static class ThreadLocalMap {

    private static final int INITIAL_CAPACITY = 16;

    private Entry[] table;

    private int size = 0;

    private int threshold; // Default to 0

    private void setThreshold(int len) {
        threshold = len * 2 / 3;
    }

    private void set(ThreadLocal<?> key, Object value) {
        //...
    }
  
    private Entry getEntry(ThreadLocal<?> key) {
        //...
    }
    }
}

從上面的源碼可以看出,ThreadLocalMap與HashMap結構上是相似的,都有初始容量、都用數組來裝載value值、都有閾值和負載因子等,它們都是利用了散列算法而做的散列表。不同之處在於ThreadLocalMap是基於線性探測的散列表,而HashMap是基於拉鍊法的散列表
我們關注上面的Entry[] table這一成員變量,這是一個Entry數組,它保存了一系列的通過調用ThreadLocal#set(T value)方法而傳遞進來的value值。

②ThreadLocalMap.Entry 這是ThreadLocalMap的一個靜態內部類,可以看一下它的源碼:

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

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

它繼承自WeakReference,泛型參數是ThreadLocal<?>,同時有一個Object的變量,這說明了該Entry持有一個對ThreadLocal的弱引用,同時把ThreadLocal保存的值放到了這裏的Object對象內保存起來。

③Thread類的ThreadLocalMap成員變量:

public class Thread implements Runnable {
     /* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */
    ThreadLocal.ThreadLocalMap threadLocals = null;
    
    //other code...
}

從源碼註釋可以看出,ThreadLocalMap是屬於當前Thread對象的(因爲不同線程所對應的Thread對象不同),所以ThreadLocalMap僅在當前Thread內起作用,不同的線程都維護着各自的ThreadLocalMap,相互之間沒有聯繫。

2、解析ThreadLocal.set(T value)方法

public void set(T value) {
    Thread t = Thread.currentThread(); //獲取當前的線程  
    ThreadLocalMap map = getMap(t);    //根據當前線程獲取對應的Map
    if (map != null)
        map.set(this, value);  //2、把value放進map中
    else
        createMap(t, value);   //1、創建一個Map
}

上面的流程很簡單,就是根據線程對象來獲取到線程內部維護的ThreadLocalMap對象,然後再把值放到map內部。

2.1、我們先看createMap(t,value)方法:

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

這裏把Thread對象的map變量進行了實例化,指向一個ThreadLocalMap對象。這裏可以知道線程內部維護的threadLocalMap變量只有在進行第一次保存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); //Entry內部的object保存了value值
    size = 1;
    setThreshold(INITIAL_CAPACITY);
}

從上面的源碼可以看出,數組的下標通過散列函數計算來得到,即firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1),這裏相當於把哈希值對數組長度取模運算,那麼ThreadLocal是怎麼確定自身的哈希值的呢?我們循着源碼的蹤跡繼續向前探索,我們來看看threadLocalHashCode到底是何方神聖:

public class ThreadLocal<T> {
    //成員變量,聲明爲final域,一旦賦值便不能修改
    private final int threadLocalHashCode = nextHashCode();

    private static AtomicInteger nextHashCode =
        new AtomicInteger();

    private static final int HASH_INCREMENT = 0x61c88647;

    private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT); //不斷自增HASH_INCREMENT大小
    }

    //other code...
}

觀察上面的源碼,threadLocalHashCode是ThreadLocal的一個成員變量,被聲明爲final域,表示它一旦賦值之後便不能修改了,同時它指向nextHashCode()方法的返回值。當ThreadLocal被實例化的同時,會觸發成員變量的初始化,也即是調用nextHashCode()方法來獲取一個當前ThreadLocal對象的哈希值。

我們把關注點放在nextHashCode()方法,它是一個靜態方法,與它相關的一個變量是nextHashCode,這是一個AtomicInteger,即原子變量,它也是一個靜態變量。我們知道,靜態變量是屬於類所有的,與類的某一對象實例無關,所以通過不斷的實例化ThreadLocal類,它的靜態變量nextHashCode靜態變量就會不斷地自增,並且每次都自增0x61c88647。通過這種方法,不同的ThreadLocal實例便獲得了一個獨特的哈希值(注:由於採用了原子變量,在多線程環境下也能獲得正確的取值)。

2.1-小結:上面分析了createMap(t,value)方法,通過該方法,實例化了一個與當前線程有關的ThreadLocalMap實例,並且通過ThreadLocal實例化時獲得的一個哈希值與Entry[]數組的長度進行與運算來算出下標i,並把value保存到Entry[]數組的該下標位置處。

2.2、我們繼續來分析map.set(this, value)
現在,讓我們把目光放回剛纔的set()方法上,當map不是空值時,會調用map.set()函數進行插入操作。下面,我們來看看map.set()方法做了什麼工作:

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);  //根據threadLocal的hashcode來獲取散列後的下標i

    //在i的基礎上,不斷向前探測,即線性探測法。探查是否已經存在相應的key,如果存在舊替換。
    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {  //nextIndex()的作用在於自增i值,如果超過數組長度就變成0,相當於把數組看出環形數組
        ThreadLocal<?> k = e.get();         //獲取Entry持有的ThreadLocal弱引用

        if (k == key) {         //如果兩個ThreadLocal相等,表示需要更新該key對應的value值
            e.value = value;
            return;
        }

        if (k == null) {       //如果k爲null,表示Entry持有的弱引用已經過期,即ThreadLocal對象被GC回收了
            replaceStaleEntry(key, value, i);   //此時更新舊的Entry值
            return;
        }
    }

    tab[i] = new Entry(key, value);    //如果走到了這裏,表示插入的key-value是新值
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold)  //當ThreadLocalMap達到了一定的容量時,需要進行擴容
        rehash();
}

上面的註釋已經說得很清楚了,主要流程就是先通過散列找到應該存放的數組下標index,然後利用線性探測的方法逐步增大index,觀察對應Entry[]位置是否存在Entry對象,然後選擇替換或者實例化一個新的Entry對象。

3、解析ThreadLocal.get()方法
上面講述了set()方法,那麼當我們調用get()方法來獲取一個值的時候,背後所作的工作又是怎樣的呢?

public T get() {
    Thread t = Thread.currentThread();      //獲取當前線程對象
    ThreadLocalMap map = getMap(t);         //根據線程對象獲取其內部的Map
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();               //如果Map尚未初始化,則初始化
}

邏輯很簡單,就是獲取到當前線程所維護的一個ThreadLocalMap,然後以當前ThreadLocal對象作爲key來獲取一個Entry,具體邏輯我們看map.getEntry(this)

/**
 * Get the entry associated with key.  This method
 * itself handles only the fast path: a direct hit of existing
 * key. It otherwise relays to getEntryAfterMiss.  This is
 * designed to maximize performance for direct hits, in part
 * by making this method readily inlinable.
 *
 * @param  key the thread local object
 * @return the entry associated with key, or null if no such
 */
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;

    while (e != null) {                //利用線性探測法來尋找key所在的位置  
        ThreadLocal<?> k = e.get();
        if (k == key)
            return e;
        if (k == null)                 //如果當前遍歷到的key已經被回收了,那麼進行清理
            expungeStaleEntry(i);
        else
            i = nextIndex(i, len);     //利用環形數組的原理來變化i值
        e = tab[i];
    }
    return null;
}

邏輯還是很清晰的,如果通過散列函數得到的數組下標直接命中key值,那麼可以直接返回,否則進一步調用getEntryAfterMiss(key,e)方法來進行線性探測查找key。
值得注意的是,這裏把查找過程分成了兩個方法來處理,爲什麼要這樣做?從源碼的註釋可以看出,這樣設計的目的是最大限度提高getEntry(key)方法的性能,也即是提高直接命中時的返回結果的效率。這是因爲JVM在運行的過程中,如果一些短函數被頻繁的調用,那麼JVM會把它優化成內聯函數,即直接把函數的代碼融合進調用方的代碼裏面,這樣省掉了函數的調用過程,效率也會得到提高。

4、ThreadLocalMap處理已失效的Key的過程
ThreadLocalMap是ThreadLocal的核心部分,其中大量邏輯都是在ThreadLocalMap中完成的,所以其重要性不言而喻。因此值得我們來繼續學習它的優秀思路。
我們通過上面的學習,知道了Entry持有對ThreadLocal的弱引用,但同時它也持有一個對Object的強引用,前者是key,後者是value。那麼隨着系統的運行,ThreadLocal可能會被GC回收了,那麼此時Entry持有的key值就變成了失效的值。因此,在get()和set()的過程中,ThreadLocalMap可能會觸發對已失效key的處理,以回收空間。
4.1、我們來看看ThreadLocalMap.expungeStaleEntry(int)的源碼:

private int expungeStaleEntry(int staleSlot) {  //這裏傳入的staleSlot表示這個下標位置的Entry是失效的
    Entry[] tab = table;
    int len = tab.length;

    //把當前位置Entry的value值置空,同時也把Entry[staleSlot]置空,便於GC回收
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;

    //線性探測法進行環形探測,回收失效的key值及Entry,對於沒失效的Entry進行ReHash得到h,
    //再把該Entry放到對h線性探測的下一個爲空的位置
    Entry e;
    int i;
    for (i = nextIndex(staleSlot, len);
        (e = tab[i]) != null;
        i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();
        if (k == null) {
            e.value = null;
            tab[i] = null;
            size--;
        } else {
            int h = k.threadLocalHashCode & (len - 1);
            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;
}

經過調用一次i = expungeStaleEntry(staleSlot)後,staleSlot到i之間的無效值都被清理了,並且在這其中的有效Entry也被再散列到了相應的位置。

4.2、在理解了expungeStaleEntry(staleSlot)的作用之後,我們接下來看看與之關係密切的另一個方法ThreadLocalMap.cleanSomeSlots(int i, int n)方法:

/**
 * 啓發式地對Entry[]進行掃描,並清理無效的slot.
 * 從下面的while循環表達式可以知道,第一次掃描的單元是i ~ i+log2(n),
 * 如果在這期間發現了無效slot,那麼把n變大到數組的長度,此時掃描單元數爲log2(length)。
 * 即,在掃描的期間,如果發現了無效slot,就不斷增大掃描範圍。因此稱之爲啓發式掃描。
 *
 * @param i 無效slot所在的位置 
 * @param n 控制掃描的數組單元數的參數
 */
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) { //如果找到一個無效Entry(Key被回收)
            n = len;            //設置n爲Entry[]的長度,以增加掃描單元數
            removed = true;
            i = expungeStaleEntry(i);   //調用清理函數,i就是下一次向前探測的初始位置,
                                        //因爲在[舊i,新i]之間的無效slot都被清理了
        }
    } while ( (n >>>= 1) != 0); // n >>>= 1 表示 n = n >>> 1,>>>表示無符號右移
    return removed;
}

代碼給出了詳細的註釋,cleanSomeSlots(int,int)就是一個啓發式的過程,在給定範圍內如果沒有找到失效的Entry,那麼就停止搜索,否則會不斷增大搜索範圍。該方法避免了對Entry[]的全部掃描,是時間效率和存在無效slot之間的一個折衷方案。

4.3、讓我們回到2.2的代碼處,在set(key,value)方法內當掃描到的key是null時,會調用replaceStaleEntry(key, value, i)方法進行替換,這時候未免產生了一個疑問:在當前位置進行替換,如果後面已經有相同的key但還沒掃描到怎麼辦?其實,replaceStaleEntry(key, value, i)方法已經幫我們解決了這個問題,我們來查看該方法的源碼:

/**
 * 在已知存在失效slot的情況下,插入一個key-value值。
 * 該方法會觸發啓發式掃描,清理失效slot。
 *
 * @param  key ThreadLocal實例
 * @param  value ThreadLocal實例需要保存的值
 * @param  staleSlot 一個失效Entry.key所在的下標
 */
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
                               int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;
    Entry e;

    //向前掃描,尋找一個失效的slot,直到數組元素爲null
    int slotToExpunge = staleSlot;
    for (int i = prevIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = prevIndex(i, len))
        if (e.get() == null)
            slotToExpunge = i;

    //向後掃描,直到找到一個key與參數的key相等的位置,
    //或者遇到數組元素爲null停止掃描
    for (int i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();

        //如果找到相同的key,把i位置的Entry與staleSlot位置的Entry交換位置
        //經過這一步驟,失效的slot被移到了i位置
        if (k == key) {
            e.value = value;

            tab[i] = tab[staleSlot];
            tab[staleSlot] = e;

            //從slotToExpunge位置開始啓發式清理過程,該位置根據在前向掃描過程中
            //是否找到另一個失效slot來決定,如果找到,則從該位置開始清理;
            //否則,從i位置開始清理,即上面被交換了位置的slot。
            if (slotToExpunge == staleSlot)
                slotToExpunge = i;
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
            return;
        }

        //如果前向掃描沒有失效slot,並且在後向掃描的過程中遇到了第一個失效slot,記錄下該位置
        if (k == null && slotToExpunge == staleSlot)
            slotToExpunge = i;
    }

    //在staleSlot位置插入新值
    tab[staleSlot].value = null;
    tab[staleSlot] = new Entry(key, value);

    //從失效slot位置進行啓發式清理
    if (slotToExpunge != staleSlot)
        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}

該方法的核心就在於尋找一個合適的位置來放入給定的value值,並尋找合適位置來執行cleanSomeSlots(int,int)方法。而掃描位置取決於前向掃描是否找到一個失效slot和後向掃描是否找到一個相等的key或者一個失效的slot。

4-4小結:經過上面的流程梳理以及源碼分析,我們可以得知,觸發cleanSomeSlots(int i, int n)啓發式清理有兩個場景:①新的Entry被添加到數組中。②把失效key所在的slot替換成新的Entry。啓發式清理的過程是在發現了失效slot的情況下會逐漸增大掃描單元,以獲得較好的時間複雜度。expungeStaleEntry(staleSlotIndex)則是關鍵的清理函數,它向前環形遍歷,不斷地清理失效key的Entry,置爲null同時斷開強引用,把有效的Entry再散列到別的位置,直到遇到null值。replaceStaleEntry(key,value,i)則是在要替換Entry[]的某一元素時被調用,它會在i位置前後掃描查看是否有失效key的Entry,以觸發一次啓發式清理的過程。

#總結
經過上面的學習,我們可以總結出下面的ThreadLocal UML類圖如下:
類圖
本文主要探究了ThreadLocal和ThreadLocalMap的原理,以及ThreadLocalMap的清理失效Entry的算法,其中ThreadLocalMap是使用線性探測解決碰撞的哈希表的一個優秀實現例子,我們可以借鑑它的實現方法,讓我們對哈希表的理解更加深入。

好了,本文到這裏結束,謝謝你們的閱讀!如果可以的話,點個贊再走吧~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章