Java併發程式設計——ThreadLocal

it_was發表於2020-10-12

1.1 ThreadLocal簡介

ThreadLocal 通過為每個使用可變狀態的變數的執行緒維護一份該變數的獨立的副本,即每個執行緒都會維護一份屬於自己的這個共享變數的副本,彼此之間的操作互相獨立,並不影響。

想像一下,多個人(多執行緒)有一個籃子(共享變數),多個人同時往籃子中加水果很容裡造成籃子水果數量的不正確性。所以ThreadLocal的作用相當於為每個人買了一個單獨的籃子,這樣每個人操作屬於自己的籃子時就不會出錯了———執行緒封閉

1.2 ThreadLocal 原始碼分析

Java併發程式設計——ThreadLocal
上圖展示了Thread,ThreadLocal 和 ThreadLocalMap 三者的關係

ThreadLocal ——執行緒變數:clock1:

ThreadLocal 從字面上理解為 執行緒變數,即與執行緒有關。所以首先需要關注一個點,即每個執行緒都會維護兩個 ThreadLocal.ThreadLocalMap 型別的變數 : threadLocalsinheritableThreadLocals。

    //這兩行程式碼均位於Thread類下,即屬於執行緒的!
    /* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */
    ThreadLocal.ThreadLocalMap threadLocals = null;

    /*
     * InheritableThreadLocal values pertaining to this thread. This map is
     * maintained by the InheritableThreadLocal class.
     */
    ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;

ThreadLocal.ThreadLocalMap——靜態內部類?:clock2:

到這裡不難發現——ThreadLocal.ThreadLocalMap這種寫法意味著 ThreadLocal 中有一個靜態內部類——ThreadLocalMap。那我們來看一下這個靜態內部類的定義

Java併發程式設計——ThreadLocal

static class ThreadLocalMap {
        static class Entry extends WeakReference<ThreadLocal<?>> { 
        //entry竟然繼承自弱引用???有意思
            /** The value associated with this ThreadLocal. */
            Object value;

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

        /**
         * The initial capacity -- MUST be a power of two.
         */
        private static final int INITIAL_CAPACITY = 16;


         //畢竟叫ThreadLocalMap,應該和HashMap有差不多的資料結構吧?
        private Entry[] table;

        /**
         * The number of entries in the table.
         */
        private int size = 0;

        /**
         * The next size value at which to resize.
         */
        private int threshold; // Default to 0

        /**
         * Set the resize threshold to maintain at worst a 2/3 load factor.
         */
        private void setThreshold(int len) {
            threshold = len * 2 / 3;
        }

        /**
         * Increment i modulo len.
         */
        private static int nextIndex(int i, int len) {
            return ((i + 1 < len) ? i + 1 : 0);
        }

        /**
         * Decrement i modulo len.
         */
        private static int prevIndex(int i, int len) {
            return ((i - 1 >= 0) ? i - 1 : len - 1);
        }

        /**
         * Construct a new map initially containing (firstKey, firstValue).
         * ThreadLocalMaps are constructed lazily, so we only create
         * one when we have at least one entry to put in it.
         */
         //構造方法!看樣子其構造是懶載入,即當我們真正新增元素的時候才建立!
        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的原始碼我們能獲得以下資訊:

  1. 其資料結構與HashMap類似,但其Entry繼承自弱引用類——WeakReference< ThreadLocal<?> >,說明key值是弱引用,如果處理不當,會造成記憶體洩漏
  2. ThreadLocalMap與很多容器例如list和map類似,都是懶載入,即當第一次真正的往陣列中新增元素的時候才會去執行建構函式來進行初始化
  3. ThreadLocalMap 也有相應的rehash和resize方法,但是由於其沒有連結串列這種資料結構,故其發生hash衝突的時候有其他方法處理

ThreadLocalMap的雜湊演算法 :clock3:

既然是Map結構,那麼ThreadLocalMap當然也要實現自己的hash演算法來決定每一個entry的存放位置

int i = key.threadLocalHashCode & (len-1);

可以發現,這裡取決於key(即ThreadLocal的物件)的雜湊值——threadLocalHashCode

    private final int threadLocalHashCode = nextHashCode();
    //儲存全域性靜態工廠方法生成的屬於自己的唯一的雜湊值

    private static AtomicInteger nextHashCode =new AtomicInteger(); 
    //全域性靜態變數,初始化一次,因為所有執行緒共享,所以需要保證執行緒安全,故設定為AtomicInteger!

    private static final int HASH_INCREMENT = 0x61c88647; 
    //黃金分割數

    private static int nextHashCode() {
        //所有執行緒共用一個靜態工廠,專門生成雜湊值
        return nextHashCode.getAndAdd(HASH_INCREMENT); 
    }

讀到這裡我們發現,threadLocalHashCode是通過一個全域性的靜態工廠方法 nextHashCode 生成一個屬於自己的雜湊值,每當有一個ThreadLocal物件,其就增加0x61c88647!
這個值很特殊,它是斐波那契數 也叫 黃金分割數。hash增量為 這個數字時 hash 分佈非常均勻。儘管有時也可能會發生雜湊衝突:boom::boom::boom:

測試黃金分割數

public class Thread_Local {
    private static final int HASH_INCREMENT = 0x61c88647;
    public static void main(String[] args){
        int hash = 0;
        for(int i = 0; i < 16;i++){
            hash+=HASH_INCREMENT;
            System.out.println("當前元素的位置:" + (hash & 15));
        }
    }
}

輸出結果,可以看到每個元素的分佈非常均勻

當前元素的位置:7
當前元素的位置:14
當前元素的位置:5
當前元素的位置:12
當前元素的位置:3
當前元素的位置:10
當前元素的位置:1
當前元素的位置:8
當前元素的位置:15
當前元素的位置:6
當前元素的位置:13
當前元素的位置:4
當前元素的位置:11
當前元素的位置:2
當前元素的位置:9
當前元素的位置:0

ThreadLocalMap的雜湊衝突 :clock4:

既然ThreadLocalMap也是根據雜湊演算法來存放元素,當然其就會有雜湊衝突!

註明: 下面所有示例圖中,綠色塊Entry代表正常資料灰色塊代表Entrykey值為null已被垃圾回收白色塊表示Entrynull

雖然ThreadLocalMap中使用了黃金分隔數來作為hash計算因子,大大減少了Hash衝突的概率,但是仍然會存在衝突。

HashMap中解決衝突的方法是在陣列上構造一個連結串列結構,衝突的資料掛載到連結串列上,如果連結串列長度超過一定數量則會轉化成紅黑樹。而ThreadLocalMap中並沒有連結串列結構,所以這裡不能適用HashMap解決衝突的方式了。

整體上來看,ThreadLocalMap 採用開放地址法解決雜湊衝突。
如上圖所示,如果我們插入一個value=27的資料,通過hash計算後應該落入第4個槽位中,而槽位4已經有了Entry資料。此時就會線性向後查詢,一直找到Entrynull的槽位才會停止查詢,將當前元素放入此槽位中。當然迭代過程中還有其他的情況,比如遇到了Entry不為nullkey值相等的情況,還有Entry中的key值為null的情況等等都會有不同的處理。

這裡還畫了一個Entry中的keynull的資料(Entry=2的灰色塊資料),因為key值是弱引用型別,所以會有這種資料存在。在set過程中,如果遇到了key過期的Entry資料,實際上是會進行一輪探測式清理操作的。

ThreadLocalMap的核心方法——set( ) :clock5:

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) { //舊的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();
        }

閱讀原始碼我們可以發現,ThreadLocalMap的set方法分為以下幾種情況:

  1. 通過hash計算後的槽位對應的Entry資料為空,不進入for迴圈,直接生成新增新Entry即可:
    Java併發程式設計——ThreadLocal

  2. 槽位資料不為空,key與當前一致,直接更新返回:
    Java併發程式設計——ThreadLocal

  3. 槽位資料不為空且key不一致,即發生雜湊衝突,採用開發地址法,向後探測,直到碰到相同的key值更新或者新的槽位新增(此過程沒有過期key):
    Java併發程式設計——ThreadLocal

  4. 向後探測的過程中,出現過期的key(即下圖灰色的entry),啟動探測式清理,執行相應新增邏輯:
    Java併發程式設計——ThreadLocal
    雜湊陣列下標為7位置對應的Entry資料keynull,表明此資料key值已經被垃圾回收掉了,此時就會執行replaceStaleEntry()方法,該方法含義是替換過期資料的邏輯,以index=7位起點開始遍歷,進行探測式資料清理工作。以下為探測式資料清理的原始碼:

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

ThreadLocalMap中的key為弱引用型別,當其key無外部強引用時,會由垃圾收集器回收,進而造成key 為null,value還存在的情況,引發記憶體洩漏!解決方案可以對於已經不再使用的 ThreadLocal 變數應及時做remove處理!

測試程式碼 1

public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException, InterruptedException {
        Thread t = new Thread(() -> test(123, true));
        t.start();

    }

    private static void test(Integer s,boolean isGC)  {
        try {
            ThreadLocal<Integer> threadLocal1 = new ThreadLocal<>(); 
            //此處由於ThreadLocal還有一個強引用——threadLocal1,故垃圾回收器不可能對其回收
            threadLocal1.set(s);
            if (isGC) {
                System.gc();
                System.out.println("--gc後--");
            }
            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) {
                    Class<?> entryClass = o.getClass();
                    Field valueField = entryClass.getDeclaredField("value");
                    //獲取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) {
            e.printStackTrace();
        }
    }
--gc後--
弱引用key:java.lang.ThreadLocal@39054944,:123
弱引用key:java.lang.ThreadLocal@c88240f,:java.lang.ref.SoftReference@654839fd

輸出結果第一行可以理解,畢竟還有threadLocal1這個強引用在,第二行輸出非常令我迷惑,通過debug檢視當前執行緒的threadlocals竟然發現其table的容量為3!如下圖
Java併發程式設計——ThreadLocal
檢視陣列各元素如下:

Java併發程式設計——ThreadLocal

  1. 下標為 5 的資料的value是一個Object型別的陣列,其內有一個UTF_8.Decoder型別的解碼器
    Java併發程式設計——ThreadLocal
  1. 下標為 7 的資料的value是一個軟引用,指向一個時間戳
    Java併發程式設計——ThreadLocal

  2. 下標為 10 的資料的value就是真正set進去的值

綜上:我只新增了一個ThreadLocal< String >型別的變數,為何莫名其妙多出現兩個???

測試程式碼 2

將ThreadLocal例項的強引用置空,觀察輸出結果

 ThreadLocal<Integer> threadLocal1 = new ThreadLocal<>();
            threadLocal1.set(s);
            if (isGC) {
                threadLocal1 = null;
                System.gc();
                System.out.println("--gc後--");
            }
--gc後--
弱引用key:null,:123
弱引用key:java.lang.ThreadLocal@477a074c,:java.lang.ref.SoftReference@40b0b466

此處很明顯,當前threadLocal並沒有被完全清理,造成記憶體洩漏!

測試程式碼 3

將ThreadLocal採用安全的remove方法,觀察輸出結果

ThreadLocal<String> threadLocal1 = new ThreadLocal<>();
            threadLocal1.set(s);

            if (isGC) {
                threadLocal1.remove();
                System.gc();
            }
--gc後--

Process finished with exit code 0

正常!

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章