原始碼篇:ThreadLocal的奇思妙想(萬字圖文)

xdd666發表於2021-05-06

前言

ThreadLocal的文章在網上也有不少,但是看了一些後,理解起來總感覺有繞,而且看了ThreadLocal的原始碼,無論是執行緒隔離、類環形陣列、弱引用結構等等,實在是太有意思了!我必須也要讓大家全面感受下其中所蘊含的那些奇思妙想! 所以這裡我想寫一篇超幾兒通俗易懂解析ThreadLocal的文章,相關流程會使用大量圖示解析,以證明:我是乾貨,不是水比!

ThreadLocal這個類加上龐大的註釋,總共也才七百多行,而且你把這個類的程式碼拷貝出來,你會發現,它幾乎沒有報錯!耦合度極低!(唯一的報錯是因為ThreadLocal類引用了Thread類裡的一個包內可見變數,所以把程式碼複製出來,這個變數訪問就報錯了,僅僅只有此處報錯!)

ThreadLocal的執行緒資料隔離,替換演算法,擦除演算法,都是有必要去了解了解,僅僅少量的程式碼,卻能實現如此精妙的功能,讓我們來體會下 Josh Bloch 和 Doug Lea 倆位大神,巧奪天工之作吧!

一些說明

這篇文章畫了不少圖,大概畫了十八張圖,關於替換演算法和擦除演算法,這倆個方法所做的事情,如果不畫圖,光用文字描述的話,十分的抽象且很難描述清楚;希望這些流程圖,能讓大家更能體會這些精煉程式碼的魅力!

image-20210506091320057

使用

嗶嗶原理之前,必須要先來看下使用

  • 使用起來出奇的簡單,僅僅使用set()get()方法即可
public class Main {

    public static void main(String[] args) {
        ThreadLocal<String> threadLocalOne = new ThreadLocal<>();
        ThreadLocal<String> threadLocalTwo = new ThreadLocal<>();

        new Thread(new Runnable() {
            @Override
            public void run() {
                threadLocalOne.set("執行緒一的資料 --- threadLocalOne");
                threadLocalTwo.set("執行緒一的資料 --- threadLocalTwo");
                System.out.println(threadLocalOne.get());
                System.out.println(threadLocalTwo.get());
            }
        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println(threadLocalOne.get());
                System.out.println(threadLocalTwo.get());
                threadLocalOne.set("執行緒二的資料 --- threadLocalOne");
                threadLocalTwo.set("執行緒二的資料 --- threadLocalTwo");
                System.out.println(threadLocalOne.get());
                System.out.println(threadLocalTwo.get());
            }
        }).start();
    }
}
  • 列印結果
    • 一般來說,我們在主存(或稱工作執行緒)建立一個變數;在子執行緒中修改了該變數資料,子執行緒結束的時候,會將修改的資料同步到主存的該變數上
    • 但是,在此處,可以發現,倆個執行緒都使用同一個變數,但是線上程一里面設定的資料,完全沒影響到執行緒二
    • cool!簡單易用,還實現了資料隔離與不同的執行緒
執行緒一的資料 --- threadLocalOne
執行緒一的資料 --- threadLocalTwo
null
null
執行緒二的資料 --- threadLocalOne
執行緒二的資料 --- threadLocalTwo

前置知識

在解釋ThreadLocal整體邏輯前,需要先了解幾個前置知識

下面這些前置知識,是在說set和get前,必須要先了解的知識點,瞭解下面這些知識點,才能更好去了解整個存取流程

執行緒隔離

在上面的ThreadLocal的使用中,我們發現一個很有趣的事情,ThreadLocal在不同的執行緒,好像能夠儲存不同的資料:就好像ThreadLocal本身具有儲存功能,到了不同執行緒,能夠生成不同的'副本'儲存資料一樣

實際上,ThreadLocal到底是怎麼做到的呢?

  • 來看下set()方法,看看到底怎麼存資料的:此處涉及到ThreadLocalMap型別,暫且把他當成Map,詳細的後面欄目分析
    • 其實這地方做了一個很有意思的操作:執行緒資料隔離的操作,是Thread類和ThreadLocal類相互配合做到的
    • 在下面的程式碼中可以看出來,在塞資料的時候,會獲取執行該操作的當前執行緒
    • 拿到當前執行緒,取到threadLocals變數,然後彷彿以當前例項為key,資料value的形式往這個map裡面塞值(有區別,set欄目再詳細說)
    • 所以使用ThreadLocal在不同的執行緒中進行寫操作,實際上資料都是繫結在當前執行緒的例項上,ThreadLocal只負責讀寫操作,並不負責儲存資料,這就解釋了,為什麼ThreadLocal的set資料,只在操作的執行緒中有用
    • 大家有沒有感覺這種思路有些巧妙!
//存資料
public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocal.ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}

//獲取當前Thread的threadLocals變數
ThreadLocal.ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

//Thread類
public class Thread implements Runnable {
    ...
        
    /* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */
    ThreadLocal.ThreadLocalMap threadLocals = null;
    
    ...
}
  • 來看下圖示
    • 圖上只花了了一個ThreadLocal,想多花幾個,然後線交叉了,暈
    • threadLocals是可以儲存多個ThreadLocal,多個存取流程同理如下

執行緒隔離

  • 總結下:通過上面的很簡單的程式碼,就實現了執行緒的資料隔離,也能得到幾點結論
    • ThreadLocal物件本身是不儲存資料的,它本身的職能是執行相關的set、get之類的操作
    • 當前執行緒的例項,負責儲存ThreadLocal的set操作傳入的資料,其資料和當前執行緒的例項繫結
    • 一個ThreadLocal例項,在一個執行緒中只能儲存一類資料,後期的set操作,會覆蓋之前set的資料
    • 執行緒中threadLocals是陣列結構,能儲存多個不同ThreadLocal例項set的資料

Entry

  • 說到Entry,需要先知道下四大引用的基礎知識

強引用:不管記憶體多麼緊張,gc永不回收強引用的物件

軟引用:當記憶體不足,gc對軟引用物件進行回收

弱引用:gc發現弱引用,就會立刻回收弱引用物件

軟引用:在任何時候都可能被垃圾回收器回收

Entry就是一個實體類,這個實體類有倆個屬性:key、value,key是就是我們們常說的的弱引用

當我們執行ThreadLocal的set操作,第一次則新建一個Entry或後續set則覆蓋改Entry的value,塞到當前Thread的ThreadLocals變數中

  • 來看下Entry程式碼
    • 此處key取得是ThreadLocal自身的例項,可以看出來Entry持有的key屬性,屬於弱引用屬性
    • value就是我們傳入的資料:型別取決於我們定義的泛型
static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;

    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}
  • Entry有個比較巧妙的結構,繼承弱引用類,然後自身內部又定義了一個強引用屬性,使得該類有一強一弱的屬性
  • 結構圖

Entry結構

你可能會想,what?我用ThreadLocal來set一個資料,然後gc一下,我Entry裡面key變數引用鏈就斷開了?

img

  • 來試一下
public class Main {

    public static void main(String[] args) {
        ThreadLocal<String> threadLocalOne = new ThreadLocal<>();

        new Thread(new Runnable() {
            @Override
            public void run() {
                threadLocalOne.set("執行緒一的資料 --- threadLocalOne");
                System.gc();
                System.out.println(threadLocalOne.get());
            }
        }).start();
    }
}
  • 結果
執行緒一的資料 --- threadLocalOne

看來這裡gc了個寂寞。。。

在這裡,必須明確一個道理:gc回收弱引用物件,是先回收弱引用的物件,弱引用鏈自然斷開;而不是先斷開引用鏈,再回收物件。Entry裡面key對ThreadLocal的引用是弱引用,但是threadLocalOne對ThreadLocal的引用是強引用啊,所以ThreadLocal這個物件是沒法被回收的

  • 來看下上面程式碼真正的引用關係

Entry的key值引用鏈

  • 此處可以演示下,threadLocalOne對ThreadLocal的引用鏈斷開,Entry裡面key引用被gc回收的情況
public class Main {
    static ThreadLocal<String> threadLocalOne = new ThreadLocal<>();

    public static void main(String[] args) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                threadLocalOne.set("執行緒一的資料 --- threadLocalOne");
                try {
                    threadLocalOne = null;
                    System.gc();

                    //下面程式碼來自:https://blog.csdn.net/thewindkee/article/details/103726942
                    Thread t = Thread.currentThread();
                    Class<? extends Thread> clz = t.getClass();
                    Field field = clz.getDeclaredField("threadLocals");
                    field.setAccessible(true);
                    Object threadLocalMap = field.get(t);
                    Class<?> tlmClass = threadLocalMap.getClass();
                    Field tableField = tlmClass.getDeclaredField("table");
                    tableField.setAccessible(true);
                    Object[] arr = (Object[]) tableField.get(threadLocalMap);
                    for (Object o : arr) {
                        if (o == null) continue;
                        Class<?> entryClass = o.getClass();
                        Field valueField = entryClass.getDeclaredField("value");
                        Field referenceField = entryClass.getSuperclass().getSuperclass().getDeclaredField("referent");
                        valueField.setAccessible(true);
                        referenceField.setAccessible(true);
                        System.out.println(String.format("弱引用key:%s    值:%s", referenceField.get(o), valueField.get(o)));
                    }
                } catch (Exception e) { }
            }
        }).start();
    }
}
  • 結果
    • key為null了!上面有行程式碼:threadLocalOne = null,這個就是斷開了對ThreadLocal物件的強引用
    • 大家如果有興趣的話,可以把threadLocalOne = null去掉,再執行的話,會發現,key不會為空
    • 反射程式碼的功能就是取到Thread中threadLocals變數,迴圈取其中的Entry,列印Entry的key、value值
弱引用key:null    值:執行緒一的資料 --- threadLocalOne
弱引用key:java.lang.ThreadLocal@387567b2    值:java.lang.ref.SoftReference@2021fb3f
  • 總結
    • 大家心裡可能會想,這變數一直持有強引用,key那個弱引用可有可無啊,而且子執行緒程式碼執行時間一般也不長
    • 其實不然,我們可以想想Android app裡面的主執行緒,就是一個死迴圈,以事件為驅動,裡面可以搞巨多巨難的邏輯,這個強引用的變數被賦其它值就很可能了
      • 如果key是強引用,那麼這個Entry裡面的ThreadLocal基本就很難被回收
      • key為弱引用,當ThreadLocal物件強引用鏈斷開後,其很容易被回收了,相關清除演算法,也能很容易清理key為null的Entry
    • 一個弱引用都能玩出這麼多花樣

img

ThreadLocalMap環形結構

  • 我們們來看下ThreadLocalMap程式碼
    • 先去掉一堆暫時沒必要關注的程式碼
    • table就是ThreadLocalMap的主要結構了,資料都存在這個陣列裡面
    • 所以說,ThreadLocalMap的主體結構就是一個Entry型別的陣列
public class ThreadLocal<T> {
    
    ...
    
    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;
            }
        }
        
        /**
         * The table, resized as necessary.
         * table.length MUST always be a power of two.
         */
        private Entry[] table;

        ...
    }
}

  • 在此處你可能又有疑問了,這東西不就是一個陣列嗎?怎麼和環形結構扯上關係了?

img

  • 陣列正常情況下確實是下面的這種結構

UML時序圖

  • 但是,ThreadLocalMap類裡面,有個方法做了一個騷操作,看下程式碼
public class ThreadLocal<T> {
    
    ...
    
    static class ThreadLocalMap {
        ...
            
        private static int nextIndex(int i, int len) {
            return ((i + 1 < len) ? i + 1 : 0);
        }
           
        ...
    }
}
  • 這個nextIndex方法,大家看懂了沒?
    • 它的主要作用,就是將傳入index值加一
    • 但是!當index值長度超過陣列長度後,會直接返回0,又回到了陣列頭部,這就完成了一個環形結構

Entry結構變形

  • 總結
    • 這樣做有個很大的好處,能夠大大的節省記憶體的開銷,能夠充分的利用陣列的空間
    • 取資料的時候會降低一些效率,時間置換空間

set

總流程

  • 塞資料的操作,來看下這個set操作的程式碼:下面的程式碼,邏輯還是很簡單的
    1. 獲取當前執行緒例項
    2. 獲取當前執行緒中的threadLocals例項
    3. threadLocals不為空執行塞值操作
    4. threadLocals為空,new一個ThreadLocalMap賦值給threadLocals,同時塞入一個Entry
public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}

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

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生成Entry陣列,設定了一個預設長度,預設為:16
 private static final int INITIAL_CAPACITY = 16;

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
    table = new Entry[INITIAL_CAPACITY];
    ...
}
  • 流程圖

set總流程

map.set

  • 上面說了一些細枝末節,現在來說說最重要的map.set(this, value) 方法
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();

        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();
}

取雜湊值

  • 上面程式碼有個計算雜湊值的操作
    • key.threadLocalHashCode這行程式碼上來看,就好像將自身的例項計算hash值
    • 其實看了完整的程式碼,發現傳入key,只不過是為了呼叫nextHashCode方法,用它來計算雜湊值,並不是將當前ThreadLocal物件轉化成hash值
public class ThreadLocal<T> {
    private final int threadLocalHashCode = nextHashCode();

    private static final int HASH_INCREMENT = 0x61c88647;
    
    private static AtomicInteger nextHashCode = new AtomicInteger();

    private void set(ThreadLocal<?> key, Object value) {
        ...
        int i = key.threadLocalHashCode & (len-1);
        ...
    }

    private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT);
    }
}
  • 這地方用了一個原子類的操作,來看下getAndAdd() 方法的作用
    • 這就是個相加的功能,相加後返回原來的舊值,保證相加的操作是個原子性不可分割的操作
public class Main {
    public static void main(String[] args) {
        AtomicInteger atomicInteger = new AtomicInteger();
        
        System.out.println(atomicInteger.getAndAdd(1));  //0
        System.out.println(atomicInteger.getAndAdd(1));  //1
        System.out.println(atomicInteger.getAndAdd(1));  //2
    }
}
  • HASH_INCREMENT = 0x61c88647,為什麼偏偏將將0x61c88647這個十六進位制相加呢,為什麼不能是1,2,3,4,5,6呢?

該值的相加,符合斐波那契雜湊法,可以使得的低位的二進位制數值分佈的更加均勻,這樣會減少在陣列中產生hash衝突的次數

具體分析可檢視:從 ThreadLocal 的實現看雜湊演算法

等等大家有沒有看到 threadLocalHashCode = nextHashCode(),nextHashCode()是獲取下一個節點的方法啊,這是什麼鬼?

難道每次使用key.threadLocalHashCode的時候,HashCode都會變?

  • 看下完整的賦值語句
    • 這是在初始化變數的時候,就直接定義賦值的
    • 說明例項化該類的時候,nextHashCode()獲取一次HashCode之後,就不會再次獲取了
    • 加上用的final修飾,僅能賦值一次
    • 所以threadLocalHashCode變數,在例項化ThreadLocal的時候,獲取HashCode一次,該數值就定下來了,在該例項中就不會再變動了
public class ThreadLocal<T> {
    private final int threadLocalHashCode = nextHashCode();
}

好像又發現一個問題!threadHashCode通過 nextHashCode() 獲取HashCode,然後nextHashCode是使用AtomicInteger型別的 nextHashCode變數相加,這玩意每次例項化的時候不都會歸零嗎?

難道我們每次新的ThreadLocal例項獲取HashCode的時候,都要從0開始相加?

  • 來看下完整程式碼
    • 大家看下AtomicInteger型別的nextHashCode變數,他的修飾關鍵字是static
    • 這說明該變數的數值,是和這個類繫結的,和這個類生成的例項無關,而且從始至終,只會例項化一次
    • 當不同的ThreadLocal例項呼叫nextHashCode,他的數值就會相加一次
    • 而且每個例項只能呼叫一次nextHashCode()方法,nextHashCode數值會很均勻的變化
public class ThreadLocal<T> {
    private final int threadLocalHashCode = nextHashCode();

    private static final int HASH_INCREMENT = 0x61c88647;
    
    private static AtomicInteger nextHashCode = new AtomicInteger();

    private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT);
    }
}

總結

  • 通過寥寥數行的初始化,幾個關鍵字,就能形成在不同例項中,都能穩步變化的HashCode數值
  • 這些基礎知識大家或許都知道,又有多少能這樣信手拈來呢?

img

取index值

上面程式碼中,用取得的hash值,與ThreadLocalMap例項中陣列長度減一的與操作,計算出了index值

這個很重要的,因為大於長度的高位hash值是不需要的

此處會將傳入的ThreadLocal例項計算出一個hash值,怎麼計算的後面再說,這地方有個位與的操作,這地方是和長度減一的與操作,這個很重要的,因為大於長度的高位hash值是不需要的

  • 假設hash值為:010110011101
  • 長度(此處選擇預設值:16-1):01111
  • 看下圖可知,這個與操作,可去掉高位無用的hash值,取到的index值可限制在陣列長度中

hash值低位與操作

塞值

  • 看下塞值進入ThreadLocalMap陣列的操作
    • 關於Key:因為Entry是繼承的WeakReference類,get()方法是獲取其內部弱引用物件,所以可以通過get()拿到當前ThreadLocal例項
    • 關於value:直接 .value 就OK了
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);
    ...
}

分析下塞值流程

  • 實際上面的迴圈還值得去思考,來思考下這迴圈處理的事情

  • 迴圈中獲取當前index值,從Entry陣列取到當前index位置的Entry物件

  1. 如果獲取的這Entry是null,則直接結束這個迴圈體
    • 在Entry陣列的index塞入一個新生成的節點
  2. 如果獲取的這Entry不為null
    1. key值相等,說明Entry物件存在,覆蓋其value值即可
    2. key為null,說明該節點可被替換(替換演算法後面講),new一個Entry物件,在此節點儲存資料
    3. 如果key不相等且不為null,迴圈獲取下一節點的Entry物件,並重覆上述邏輯

整體的邏輯比較清晰,如果key已存在,則覆蓋;不存在,index位置是否可用,可用則使用該節點,不可用,往後尋找可用節點:線性探測法

  • 替換舊節點的邏輯,實在有點繞,下面單獨提出來說明

map.set流程

替換演算法

在上述set方法中,當生成的index節點,已被佔用,會向後探測可用節點

  • 探測的節點為null,則會直接使用該節點
  • 探測的節點key值相同,則會覆蓋value值
  • 探測的節點key值不相同,繼續向後探測
  • 探測的節點key值為null,會執行一個替換舊節點的操作,邏輯有點繞,下面來分析下
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 == null) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }
    ...
}
  • 來看下replaceStaleEntry方法中的邏輯
private static int prevIndex(int i, int len) {
    return ((i - 1 >= 0) ? i - 1 : len - 1);
}

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;
    for (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len))
        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.
        if (k == key) {
            e.value = value;

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

            // Start expunge at preceding stale entry if it exists
            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.
        if (k == null && slotToExpunge == staleSlot)
            slotToExpunge = i;
    }

    // If key not found, put new entry in stale slot
    tab[staleSlot].value = null;
    tab[staleSlot] = new Entry(key, value);

    // If there are any other stale entries in run, expunge them
    if (slotToExpunge != staleSlot)
        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}
  • 上面的程式碼,很明顯倆個迴圈是重點邏輯,這裡面有倆個很重要的欄位:slotToExpunge和staleSlot

    • staleSlot:記錄傳進來節點key為null的位置
    • slotToExpunge:標定是否需要執行最後的清理方法
  • 第一個迴圈:很明顯是往前列表頭結點方向探測,是否還有key為null的節點,有的話將其下標賦值給slotToExpunge;這個探測是一個連續的不為null的節點鏈範圍,有空節點,立馬結束迴圈

替換演算法-前探測

  • 第二個迴圈:很明顯主要是向後探測,探測整個陣列,這裡有很重要邏輯

    • 這地方已經開始有點繞了,我giao,大家要好好想想
    • 當探測的key和傳入的需要設值的key相同時,會複寫探測到Entry的value,然後將探測到位置和傳入位置,倆者相互調換
  • 為什麼會出現探測到Entry和傳入key相同?

    • 相同是因為,存在到陣列的時候,產生了hash衝突,會自動向後探測合適的位置儲存
    • 當你第二次用ThreadLocal存值的時候,hash產生的index,比較倆者key,肯定是不可能相同,因為產生了hash衝突,真正儲存Entry,在往後的位置;所以需要向後探測
    • 假設探測的時候,一直沒有遇到key為null的Entry:正常迴圈的話,肯定是能探測到key相同的Entry,然後進行復寫value的操作
    • 但是在探測的時候,遇到key為null的Entry的呢?此時就進入了替換舊Entry演算法,所以替換演算法就也有了一個向後探測的邏輯
    • 探測到相同key值的Entry,就說明了找到了我們需要複寫value的Entry例項
  • 為什麼要調換倆者位置呢?

    • 這個問題,大家可以好好想想,我們時候往後探測,而這key為null的Entry例項,屬於較快的探測到Entry

    • 而這個Entry例項的key又為null,說明這個Entry可以被回收了,此時正處於佔著茅坑不拉屎的位置

    • 此時就可以把我需要複寫Entry例項和這個key為null的Entry調換位置

    • 可以使得我們需要被操作的Entry例項,在下次被操作時候,可以儘快被找到

  • 調換了位置之後,就會執行擦除舊節點演算法

替換演算法-後探測(需複寫)

  • 上面是探查連續的Entry節點,未碰到null節點的情況;如果碰到null節點,會直接結束探測
    • 請注意,如果陣列中,有需要複寫value的節點;在計算的hash值處,向後探測的過程,一定不會碰到null節點
    • 畢竟,第一次向後探測可用節點是,碰到第一個null節點,就停下來使用了

替換演算法-後探測(null節點)

  • 在第二個迴圈中,還有一段程式碼,比較有意思,這判斷邏輯的作用是
    • 以key為null的Entry,以它為界限
    • 向前探測的時候,未碰到key為null的Entry
    • 而向後探測的時候,碰到的key為null的Entry
    • 然後改變slotToExpunge的值,使其和staleSlot不相等
private void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) {
   	...
    for (int i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
        ...
        if (k == null && slotToExpunge == staleSlot)
            slotToExpunge = i;
    }
	...
}

替換演算法-後探測(尋找key為null)

  • 可以看出來這倆個迴圈的操作,是有關聯性,對此,我表示

img

為什麼這倆個迴圈都這麼執著的,想改變slotToExpunge的數值呢?

  • 來看下關於slotToExpunge的關鍵程式碼
private void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) {
    ...
    int slotToExpunge = staleSlot;
    ...
    if (slotToExpunge != staleSlot)
        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}

明白了吧!都是為了替換方法裡面的最後一段邏輯:為了判斷是否需要執行擦除演算法

總結

  • 雙向探測流程

    • 替換演算法會以傳入的key為null的Entry節點為界限,在一個連續的Entry範圍往倆邊探測
      • 什麼是連續的Entry範圍?這邊陣列的節點都不能為null,碰到為null節點會結束探測
    • 先向前探測:如果碰到key為null的Entry,會將其下標賦值給slotToExpunge
    • 向後探測使:如果向前探測沒有碰到key的節點,只要向後探測的時候碰到為null的節點,會將其下標賦值給slotToExpunge
    • 上面向倆邊探測的邏輯,是為了:遇到key為null的節點,能確保slotToExpunge不等於staleSlot
  • 在向後探測的時候,如果遇到key值對比相同的Entry,說明遇到我們需要複寫value的Entry

    • 此時複寫value的Entry,用我們傳入的value數值將其原來的value數值覆蓋
    • 然後將傳入key為null的Entry(通過傳入的下標得知Entry)和需要複寫value的Entry交換位置
    • 最後執行擦除演算法
  • 如果在向後探測的時候,沒有遇到遇到key值對比相同的Entry

    • 傳入key為null的Entry,將其value賦值為null,斷開引用
    • 建立一個新節點,放到此位置,key為傳入當前ThreadLocal例項,value為傳入的value資料
    • 然後根據lotToExpunge和staleSlot是否相等,來判斷是否要執行擦除演算法

總結

來總結下

  • 再來看下總流程

set總流程

  • 上面分析完了替換舊節點方法邏輯,終於可以把map.set的那塊替換演算法操作流程補起來了
    • 不管後續遇到null,還是遇到需要被複寫value的Entry,這個key為null的Entry都將被替換掉

map.set流程(完善)

這倆個圖示,大概描述了ThreadLocal進行set操作的整個流程;現在,進入下一個欄目吧,來看看ThreadLocal的get操作!

get

get流程,總體要比set流程簡單很多,可以輕鬆一下了

總流程

  • 來看下程式碼
    • 總體流程非常簡單,將自身作為key,傳入map.getEntry方法,獲取符合例項的Entry,然後拿到value,返回就行了
public T get() {
    Thread t = Thread.currentThread();
    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;
        }
    }
    return setInitialValue();
}
  • 如果通過map.getEntry獲取的Entry為null,會返回setInitialValue(),來看下這個方法是幹嘛的
    • 從這個方法可知,如果我們沒有進行set操作,直接進行get操作,他會給ThreadLocal的threadLocals方法賦初值
    • setInitialValue() 方法,返回的是initialValue() 方法的資料,可知預設為null
    • 所以通過key沒查到對應的Entry,get方法會返回null
private T setInitialValue() {
    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;
}

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

map.getEntry

  • 從上面的程式碼可以看出來,getEntry方法是獲取符合條件的節點
    • 這裡邏輯很簡單,通過當前ThreadLocal例項獲取HashCode,然後算出index值
    • 直接獲取當前index下標的Entry,將其key和當前ThreadLocal例項對比,看是否一樣
    • 相同:說明沒有發生Hash碰撞,可以直接使用
    • 不相同:說明發生了Hash碰撞,需要向後探測尋找,執行getEntryAfterMiss()方法
    • 此時,就需要來看看getEntryAfterMiss()方法邏輯了
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);
}

getEntryAfterMiss

  • 來看下程式碼
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)
            expungeStaleEntry(i);
        else
            i = nextIndex(i, len);
        e = tab[i];
    }
    return null;
}

整體邏輯還是很清晰了,通過while迴圈,不斷獲取Entry陣列中的下一個節點,迴圈中有三個邏輯走向

  1. 當前節點的key等於當前ThreadLocal例項:直接返回這個節點的Entry
  2. 當前節點的key為null:執行擦除舊節點演算法,繼續迴圈
  3. 當前節點的可以不等於當前ThreadLocal例項且不為null:獲取下一節點的下標,然後繼續上面的邏輯
  • 如果沒有獲取到符合條件的Entry節點,會直接返回null

get流程-getEntryAfterMiss

總結

ThreadLocal的流程,總體上比較簡單

  • 將當前ThreadLocal例項當為key,查詢Entry陣列當前節點(使用ThreadLocal中的魔術值算出的index)是否符合條件

  • 不符合條件將返回null

    • 從未進行過set操作
    • 未查到符合條件的key
  • 符合條件就直接返回當前節點

    • 如果遇到雜湊衝突,算出的index值的Entry陣列上存在Entry,但是key不相等,就向後查詢
    • 如果遇到key為null的Entry,就執行擦除演算法,然後繼續往後尋找
    • 如果遇到key相當的Entry,就直接結束尋找,返回這個Entry節點
  • 這裡大家一定要明確一個概念:在set的流程,發生了hash衝突,是在衝突節點向後的連續節點上,找到符合條件的節點儲存,所以查詢的時候,只需要在連續的節點上查詢,如果碰到為null的節點,就可以直接結束查詢

get流程

擦除演算法

在set流程和get流程都使用了這個擦除舊節點的邏輯,它可以及時清除掉Entry陣列中,那些key為null的Entry,如果key為null,說明這些這節點,已經沒地方使用了,所以就需要清除掉

  • 來看看這個方法程式碼
private int expungeStaleEntry(int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;

    // expunge entry at staleSlot
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;

    // Rehash until we encounter null
    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;
}

前置操作

從上面的程式碼,可以發現,再進行主要的迴圈體,有個前置操作

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

    // expunge entry at staleSlot
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;

    ...
}
  • 這地方做了很簡單的置空操作,如果Entry節點的key為空,說明這個節點可以被清除,value置空,和陣列的連結斷開
擦除演算法-前置操作

主體邏輯

  • 很明顯,迴圈體裡面的邏輯是最重要,而且迴圈體裡面做了一個相當有趣的操作!
private int expungeStaleEntry(int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;
	...
    // Rehash until we encounter null
    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;
}
  • 上面的迴圈體裡面,就是不斷的獲取下一節點的Entry例項,然後判斷key值進行相關處理
  • key為null:中規中矩的,將value置空,斷開與陣列的連結
  • key不為null:這時候就有意思了
    • 首先,會獲取當前ThreadLocal例項的hash值,然後取得index值
    • 判斷h(idnex值)和i是否相等,不相等進行下述操作,因為Entry陣列是環形結構,是完成存在相等的情況
      1. 會將當前迴圈到節點置空,該節點的Entry記為e
      2. 從通過hreadLocal例項的hash值獲取到index處,開始進行迴圈
      3. 迴圈到節點Entry為null,則結束迴圈
      4. 將e賦值給為null的節點
    • 這裡面的邏輯就是關鍵了
  • 大家可能對這個文字的描述,感覺比較抽象,來個圖,來體會下這短短几行程式碼的妙處

擦除演算法-主體邏輯

總結

程式碼很少,但是實現的功能卻並不少

  • 擦除舊節點的方法,在Entry上探測的時候
    • 遇到key為空的節點,會將該節點置空
    • 遇到key不為空的節點,會將該節點移到靠前位置(具體移動邏輯,請參考上述說明)
  • 互動到靠前節點位置,可以看出,主要的目的,是為了:
    • ThreadLocal例項計算出的index節點位置往後的位置,能讓節點保持連續性
    • 也能讓交換的節點,更快的被操作

擴容

在進行set操作的時候,會進行相關的擴容操作

  • 來看下擴容程式碼入口:resize方法便是擴容方法
public void set(T value) {
    ...
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}

private void set(ThreadLocal<?> key, Object value) {
	...
    tab[i] = new Entry(key, value);
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

private void rehash() {
    expungeStaleEntries();

    // Use lower threshold for doubling to avoid hysteresis
    if (size >= threshold - threshold / 4)
        resize();
}
  • 來看下擴容程式碼
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; // 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;
}

觸發條件

先來看下擴容的觸發條件吧

  • 整體程式碼
public void set(T value) {
    ...
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}

private void set(ThreadLocal<?> key, Object value) {
	...
    tab[i] = new Entry(key, value);
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

private void rehash() {
    expungeStaleEntries();

    // Use lower threshold for doubling to avoid hysteresis
    if (size >= threshold - threshold / 4)
        resize();
}

上面主要的程式碼就是:!cleanSomeSlots(i, sz) && sz >= threshold

  • 來看下threshold是什麼
    • 只要Entry陣列含有Entry例項大於等於陣列的長度的三分之二,便能滿足後一段判定
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);
}

private void setThreshold(int len) {
    threshold = len * 2 / 3;
}
  • 來看看前一段的判定,看下cleanSomeSlots,只要返回false,就能觸發擴容方法了
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;
}

n >>>= 1:表達是無符號右移一位,正數高位補0,負數高位補1

舉例:0011 ---> 0001

在上面的cleanSomeSlots方法中,只要在探測節點的時候,沒有遇到Entry的key為null的節點,該方法就會返回false

  • rehash方法就非常簡單了
    • 執行擦除方法
    • 只要size(含有Entry例項數)長度大於等於3/4 threshold,就執行擴容操作
private void rehash() {
    expungeStaleEntries();

    // Use lower threshold for doubling to avoid hysteresis
    if (size >= threshold - threshold / 4)
        resize();
}

總結

滿足下面倆個條件即可

  1. Entry陣列中不含key為null的Entry例項
  2. 陣列中含有是例項數大於等於threshold的四分之三(threshold為陣列長度的 三分之二)

擴容邏輯

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; // 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;
}
  • 從上面的邏輯,可以看出來,將舊陣列的資料賦值到擴容陣列,並不是全盤賦值到擴容陣列的對應位置

  • 遍歷舊陣列,取出其中的Entry例項

    • key為null:需要將該節點value置空,等待GC處理(Help the GC,hhhh)
      • 這裡你可能有個疑問,不是說陣列的節點key不為null,才會觸發擴容機制嗎?
      • 在多執行緒的環境裡,執行擴容的時候,key的強引用斷開,導致key被回收,從而key為null,這是完全存在的
    • key不為null:算出index值,向擴容陣列中儲存,如果該節點衝突,向後找到為null的節點,然後儲存
  • 這裡的擴容儲存和ArrayList之類是有區別

擴容機制

總結

可以發現

  • set,替換,擦除,擴容,基本無時無刻,都是為了使hash衝突節點,向衝突的節點靠近

  • 這是為了提高讀寫節點的效率

remove

remove方法是非常簡單的,ThreadLocal擁有三個api:set、get、remove;雖然非常簡單,但是還有一些必要,來稍微瞭解下

  • remove程式碼
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;
    int i = key.threadLocalHashCode & (len-1);
    for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
        if (e.get() == key) {
            e.clear();
            expungeStaleEntry(i);
            return;
        }
    }
}

邏輯非常的清晰,通過ThreadLocal例項,獲取當前的index,然後從此開始查詢符合條件Entry,找到後,會將其key值清掉,然後執行擦除演算法

e.clear就是,弱引用的清理弱引用的方法,很簡單,將弱引用referent變數置空就行了,這個變數就是持有弱引用物件的變數

remove流程

最後

文章寫到這裡,基本上到了尾聲了,寫了差不多萬餘字,希望大家看完後,對ThreadLocal能有個更加深入的認識

ThreadLocal的原始碼雖然並不多,但是其中有很多奇思妙想,有種蘿蔔雕花的感覺,這就是高手寫的程式碼嗎?

img

系列文章

相關文章