ThreadLocal原始碼閱讀

孤酒發表於2018-12-03
  • 趣鏈的面試中被問到ThreadLocal的相關問題,被問的一臉懵*,所以有次總結.

ThreadLocal

  • 執行緒區域性變數是我一直對他的叫法,剛開始接觸是用來儲存jdbc的連線(這樣想想我接觸的還挺早的)
  • 作用是為每個執行緒儲存執行緒私有的變數.以空間換時間,也能保證資料的安全性.
  • ThreadLocal並不是底層的集合類,而是一個工具類,所有的執行緒私有資料都被儲存在各個Thread物件中一個叫做threadLocalsThreadLocalMap的成員變數裡,ThreadLocal也只是操作這些變數的工具類.
  • 也就是說每個Thread都會存有一個ThreadLocalMap的物件供多個ThreadLocal的類呼叫,所以你可以發現多個ThreadLocal操作的Map會是同一個,而當ThreadLocal作為key的發生雜湊碰撞時,會從當前位置開始向後環型遍歷,找到一個空位置,這方法我們可以稱之為線性探測法.

ThreadLocalMap

  • ThreadLocalMap出人意料的並沒有繼承任何一個類或介面,是完全獨立的類。
成員變數
 		// 預設的初始容量 一定要是二的次冪
        private static final int INITIAL_CAPACITY = 16;
        // 元素陣列/條目陣列
        private Entry[] table;
       	// 大小,用於記錄陣列中實際存在的Entry數目
        private int size = 0;
		// 閾值
        private int threshold; // Default to 0 構造方法
複製程式碼
構造方法
       	// 預設訪問許可權的初始化方法
        ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
            // 使用預設的`容量`初始化陣列
            table = new Entry[INITIAL_CAPACITY];
            // 以`ThreadLocal`的`HashCode`計算下標
            // 這裡和HashMap中的計算方式一樣,都用與運算
            int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
            // 賦值 修改大小並計算閾值
            table[i] = new Entry(firstKey, firstValue);
            size = 1;
            // `setThreshold`方法也特別簡單,就是2/3的容量。
            setThreshold(INITIAL_CAPACITY);
        }
複製程式碼
元素獲取相關方法
getEntry
  • ThreadLocalKey獲取對應的Entry

  • 因為ThreadLocalMap底層也是使用陣列作為資料結構,所以該方法也借鑑了HashMap中求元素下標的方式.

  • 在獲取的元素為空的時候還會呼叫getEntryAfterMiss做後續處理.

private Entry getEntry(ThreadLocal<?> key) {
         	// 和HashMap中一樣的下標計算方式
            int i = key.threadLocalHashCode & (table.length - 1);
            Entry e = table[i];
    		// 獲取到對應的Entry之後就分兩步
            if (e != null && e.get() == key)
                // 1. e不為空且threadLocal相等
                return e;		
            else														
                // 2. e為空或者threadLocal不相等				
                return getEntryAfterMiss(key, i, e);
        }
複製程式碼
getEntryAfterMiss
  • 該方法是在直接按照Hash計算下標後,沒獲取到對應的Entry物件的時候呼叫。
  • 通過遍歷整個陣列的方式獲取相同key表示的Entry物件。
   private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
            Entry[] tab = table;
            int len = tab.length;
			// 此時注意如果從上面情況`2.`進來時,
       		// e為空則直接返回null,不會進入while迴圈
       		// 只有e不為空且e.get() != key時才會進while迴圈
            while (e != null) {
                ThreadLocal<?> k = e.get();
                // 找到相同的k,返回得到的Entry,get操作結束
                if (k == key)
                    return e;
                // 若此時的k為空,那麼e則被標記為`Stale`需要被`expunge`
                if (k == null)
                    expungeStaleEntry(i);
                else	// 下面兩個都是遍歷的相關操作
                    i = nextIndex(i, len);
                e = tab[i];
            }
            return null;
        }
複製程式碼
expungeStaleEntry
  • 該方法用來清除staleSlot位置的Entry物件,並且會清理當前節點到下一個null節點中間的過期Enyru.
  • 是消除記憶體洩漏威脅的主力方法,在整個ThreadLocalMap中會多次呼叫.
   /** 
     * 清空舊的Entry物件
     
     * @param staleSlot: 清理的起始位置
     * @param return: 返回的是第一個為空的Entry下標
     */
    private int expungeStaleEntry(int staleSlot) {
            Entry[] tab = table;
            int len = tab.length;
        	// 清空`staleSlot`位置的Entry
        	// value引用置為空之後,物件被標記為不可達,下次GC就會被回收.
            tab[staleSlot].value = null;
            tab[staleSlot] = null;
            size--;
            Entry e;
            int i;
        	// 通過nextIndex從`staleSlot`的下一個開始向後遍歷Entry陣列,直到e不為空
         	// e賦值為當前的Entry物件
            for (i = nextIndex(staleSlot, len);
                 (e = tab[i]) != null;		
                 i = nextIndex(i, len)) {
                ThreadLocal<?> k = e.get();
                // 當k為空的時候清空節點資訊
                if (k == null) {							
                    e.value = null;
                    tab[i] = null;
                    size--;
                } else {	// 以下為k存在的情況
                    int h = k.threadLocalHashCode & (len - 1);
                    // 元素下標和key計算的不一樣,表明是出現`Hash碰撞`之後調整的位置
                    // 將當前的元素移動到下一個null位置
                    if (h != i) {					
                        tab[i] = null;
                        while (tab[h] != null)
                            h = nextIndex(h, len);
                        tab[h] = e;
                    }
                }
            }
            return i;
        } 
複製程式碼
Set相關方法
set
  • 因為ThreadLocalMap底層結構和HashMap一樣也是陣列,也是通過hash確定下標,也一樣會發生Hash碰撞,我們知道在HashMap中為了解決Hash碰撞的問題選擇了拉鍊法,但對於ThreadLocalMap並沒有那麼高的複雜度,所以此處選擇的是開放地址法.
  • 從下方原始碼也可以看出來,Entry再確定陣列位置之後直接就開始了遍歷,如果key不匹配就往後遍歷找到key匹配的元素覆蓋,或者key == null的替換.
private void set(ThreadLocal<?> key, Object value) {
            Entry[] tab = table;
            int len = tab.length;
            int i = key.threadLocalHashCode & (len-1);
    		// 整個迴圈的功能就是找到相同的key覆蓋value
    		// 或者找到key為null的節點覆蓋節點資訊
    		// 只有在e==null的時候跳出迴圈執行下面的程式碼
            for (Entry e = tab[i];
                 e != null;	
                 e = tab[i = nextIndex(i, len)]) {
                ThreadLocal<?> k = e.get();
				// 找到相等的k,則直接替換value,set操作結束
                if (k == key) {
                    e.value = value;
                    return;
                }
				// k為空表示該節點過期,直接替換該節點
                if (k == null) {					       // 1.
                    replaceStaleEntry(key, value, i);
                    return;
                }
            }
			// 走到這一步就是找到了e為空的位置,不然在上面兩個判斷裡都return了
            tab[i] = new Entry(key, value);
            int sz = ++size;
            if (!cleanSomeSlots(i, sz) && sz >= threshold)
                rehash();
        }
複製程式碼
replaceStaleEntry
  • 原始碼中只有從上面1.處進入該方法,用於替換key為空的Entry節點,順帶清除陣列中的過期節點.
/**
 *	從`set.1.`處進入,key是插入元素ThreadLocal的hash,staleSlot為key為空的陣列節點下標
 */
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
                                       int staleSlot) {
            Entry[] tab = table;
            int len = tab.length;
            Entry e;
            int slotToExpunge = staleSlot;
    		// 從傳入位置,即插入時發現k為null的位置開始,向前遍歷,直到陣列元素為空
    		// 找到最前面一個key為null的值.	
    		// 這裡要吐槽一下原始碼...大括號都不加 習慣真差
            for (int i = prevIndex(staleSlot, len);
                 (e = tab[i]) != null;		
                 i = prevIndex(i, len)){
                if (e.get() == null)
                    // 因為是環狀遍歷所以此時slotToExpunge是可能等於staleSlot的
                    slotToExpunge = i;
            }
   			// 該段迴圈的功能就是向後遍歷找到`key`相等的節點並替換
    		// 並對之後的元素進行清理
            for (int i = nextIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = nextIndex(i, len)) {
                ThreadLocal<?> k = e.get();
                if (k == key) {	
                    // e就是tab[i],所以下三行程式碼的功能就是替換Entry
                    // 新的Entry實際還是在staleSlot下標的位置
                    e.value = value;
                    tab[i] = tab[staleSlot];
                    tab[staleSlot] = e;
                    // 因為接下來要進行清理操作,所以此處需要重新確定清理的起點.
                    if (slotToExpunge == staleSlot)
                        slotToExpunge = i;
                    cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
                    return;
                }
                // 其實我對這個`slotToExpunge == staleSlot`的判斷一直挺疑惑的,為什麼需要這個判斷?
                if (k == null && slotToExpunge == staleSlot)
                    slotToExpunge = i;
            }
    		// e==null時跳到下面程式碼執行
    		// 清空並重新賦值
            tab[staleSlot].value = null;
            tab[staleSlot] = new Entry(key, value);
			// set後的清理
            if (slotToExpunge != staleSlot)
                cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
        }

複製程式碼
cleanSomeSlots
  • 該方法的功能是就是清除陣列中的過期Entry
  • 首次清除從i向後開始遍歷log2(n)次,如果之間發現過期Entry會直接將n擴充到len可以說全陣列範圍的遍歷.發現過期Entry就呼叫expungeStaleEntry清除直到未發現Entry為止.
/**
  * @param i 清除的起始節點位置
  * @param n 遍歷控制,每次掃描都是log2(n)次,一般取當前陣列的`size`或`len`
  */
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`時,n變為len
                    // 即擴大範圍,全陣列範圍在遍歷一次
                    n = len;
                    removed = true;
                    i = expungeStaleEntry(i);
                }	
                // 無符號右移一位相當於n = n /2
                // 所以在第一次會遍歷`log2(n)`次
            } while ( (n >>>= 1) != 0);
    		// 遍歷過程中沒出現過期`Entry`的情況下會返回是否有清理的標記.
            return removed;
        }
複製程式碼
擴容調整方法
rehash
  • 容量調整的先驅方法,先清理過期Entry,並做是否需要resize的判斷
  • 調整的條件是當前size大於閾值的3/4就進行擴容
 private void rehash() {
     		// 清理過期Entry
            expungeStaleEntries();
     		// 初始閾值threshold為10
            if (size >= threshold - threshold / 4)
                resize();
        }
複製程式碼
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();
                    // k為空即表示為過期節點,當即清理了.
                    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;
        }
複製程式碼

  • ThreadLocal的內部方法因為邏輯都不復雜,不需要單獨出來看,就直接全放一塊了.

Get方法

  • 整個獲取的過程其實並不難,所以我說ThreadLocal的精華只要還是在TheradLocalMap和這種空間換時間的結構.
    1. 首先通過getMap方法獲取當前執行緒繫結的threadLocals
    2. 不要為空時,以當前ThreadLocal物件為引數獲取對應的Entry物件.為空跳到第四步
    3. 獲取Entry物件中的value,並返回
    4. 呼叫setInitialValue方法,
   // 直接獲取執行緒私有的資料
   public T get() {
        // 獲取當前執行緒
        Thread t = Thread.currentThread();
        // getMap其實很簡單就是獲取`t`中的`threadLocals`,程式碼在`工具方法`中
        ThreadLocalMap map = getMap(t); 
        if (map != null) {										// 3.
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {							// 2.
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();  				// 1.
    }
	// 這個方法只有在上面`1.`處呼叫...不知道為什麼map,thread不直接傳參
	// 該方法的功能就是為`Thread`設定`threadLocals`的初始值
    private T setInitialValue() {
        T value = initialValue();
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        // map不為null表明是從上面的`2.`處進入該方法
        // 已經初始化`threadLocals`,但並未找到當前對應的`Entry`
        // 所以此時直接新增`Entry`就行
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
        return value;
    }
      // 初始值,`protected`方便子類繼承,並定義自己的初始值.
      protected T initialValue() {
        return null;
      }

	// 建立並賦值`threadLocals`的方法
     void createMap(Thread t, T firstValue) {
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }
複製程式碼

Set方法

  1. 獲取當前執行緒,並以此獲取執行緒繫結的ThreadLocalMap物件.
  2. map不為空時,直接set就好
  3. map為空時需要先建立並賦值.
 public void set(T value) {
        // 獲取當前執行緒
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);     // .1
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
 }
複製程式碼
工具方法
getMap(Thraed t)
  • 獲取t中保留的ThreadLocalMap型別的物件
 ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }
複製程式碼

ThreadLocal相關問題

ThreadLocal的記憶體洩漏問題
記憶體洩漏的原因
  • 首先對於對作為keyThreadLocal物件,因為是弱引用我們完全不用擔心,強引用斷開之後自然會被GC回收.

  • 再來看value,按照上面所說的作為成員變數儲存在每個Thread例項的threadLocals才是儲存資料的物件,那麼它的生命週期是和Thread相同的,即使將ThreadLocalGC回收, 但對應的value物件仍然存在thread -> threadLocals -> value引用 -> value物件的引用關係,所以GC會認為它可達,並不會做回收處理,但在我們現有的程式碼中並沒有能夠跳過key去獲取value的,也就是說實際上value已經不可達了.這樣就造成了記憶體洩漏.

記憶體洩漏的處理方法
  • 究其根本還是斷開value的引用關係,就是講value引用置null.
  • 可以看到ThreadLcoalMap的方法多處呼叫了expungeStaleEntry,cleanSomeSlots檢查陣列中的Entry物件是否過期,也就是key是否為空.
ThreadLocal的併發性問題
  • 首先併發問題在我理解中就是多執行緒情況下對共享資源的合理使用,像是ReentrantLock,Synchronized都是幫我們解決共享資源的使用問題.
  • ThreadLocal則幫我們提供了另外一種思路,就是在每一個執行緒中保留副本,就是上文有提到的以空間換時間的形式保證資源的合理有序使用,所以我覺得也是解決併發問題的一種思路.

  • 第一次在掘金髮文章。。也是第一次在網上發文章 有點小緊張
  • 這是我的個人部落格CheNbXxx,目前只有一些原始碼的閱讀筆記,和讀書筆記.
  • 有空會優化一下一些筆記格式再上傳,感謝瀏覽.

相關文章