深入學習ThreadLocal原理


  上文我們學習了ThreadLocal的基本用法以及基本原理,ThreadLocal中的方法並不多,基本用到的也就get、set、remove等方法,但是其核心邏輯還是在定義在ThreadLocal內部的靜態內部類ThreadLocalMap中,里面有很多設計非常精妙的地方,本文中我們就從ThreadLocalMap的角度入手深入學習ThreadLocal的原理。

 1. 基本數據結構

  按照官方的解釋是:這是一個定制化的Hash類型的map,專門用來保存線程本地變量。其內部采用是通過一個自定義的Entry來封裝數據,並且保存在一個Entry數組中。為了便於處理大量且長時間存活的對象引用(其實是ThreadLocal),Entry采用WeakReference作為key的類型,當map中空間不夠時,key為null的ertry將會被刪除。ThreadLocalMap內部數據結構如下:

 

static class ThreadLocalMap {

  static class Entry extends WeakReference<ThreadLocal<?>> {
      /** 要保存到線程本地的變量 */
      Object value;

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

  /**
   * 數組初始容量 -- 必須為2的倍數.
   */
  private static final int INITIAL_CAPACITY = 16;

  /**
   * 存儲entry的數組,長度為2的倍數
   */
  private Entry[] table;

  /**
   * entries數量
   */
  private int size = 0;

  /**
   * resize閾值
   */
  private int threshold; // Default to 0

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

  /**
   * i+1,大於等於len則從0開始繼續
   */
  private static int nextIndex(int i, int len) {
      return ((i + 1 < len) ? i + 1 : 0);
  }

  /**
   * i-1,小於0則從len-1開始繼續
   */
  private static int prevIndex(int i, int len) {
      return ((i - 1 >= 0) ? i - 1 : len - 1);
  }

  ......

}

  在ThreadLocalMap內部通過自定義的Entry類來封裝要保存的數據,以ThreadLocal類型對象為key,Object類型對象為value。這個Entry繼承自WeakReference<ThreadLocal<?>>,每個Entry都可以是一個指向ThreadLocal對象的弱引用,可通過Entry的get方法來獲取對ThreadLocal對象的引用,而這個引用就是key。所有的Entry統一保存在一個Entry數組table中,數組的長度必須為2的倍數,通過key的hashcode與數組長度減1進行與運算來定位Entry在數組中的存儲位置,這點和hashmap類似,但是當發生hash碰撞時hashmap的處理方法是放入鏈表或者樹中(都在同一個hash桶中),而ThreadLocalMap則是依次往后查找可以保存的地方,沒有桶的概念(這點后面會結合代碼詳細講)。

  

  既然ThreadLocalMap內部是一個數組,通過key的hashcode來定位到數組下標,這里我們不得不說一下key的hashcode的生成方式,非常精妙,因為key類型為ThreadLocal,所以其hashcode的生成方式也在ThreadLocal中:

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

  對於每個ThreadLocal對象,都有一個獨自不變的hashcode,每新增一個ThreadLocal對象,會自動生成其自己的hashcode,其實就是讓nextHashCode自增0x61c88647,目的是為了讓生成的hashcode均勻的分布在2的冪次方上,而數組長度也是2的冪次方,這樣就保證了要插入的元素可以均勻分布在數組中。

  雖然ThreadLocal使用了很牛逼的辦法來生成hashcode,但是還是不可避免會產生hash碰撞,當出現碰撞時是如何來處理呢?我們接着看:

2. 獲取元素

  我們知道ThreadLocalMap是以Entry為基本單元保存數據的,而且是以key-value對的形式,我們先來看一下是如何通過key獲取到Entry的:

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的hashcode獲取數組下標(與運算);
  • 如果下標對應處Entry不為空,且key與傳入的key是指向同一個ThreadLocal對象則認為找到,直接返回Entry;
  • 否則執行getEntryAfterMiss;
/**
* 有三種情況下會執行這個方法
* 1. e為null;
* 2. e!=null,e的key=null;
* 3. e!=null,e的key!=null,e的key!=要找的key,即出現hash碰撞
**/
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; }

  這里的邏輯也比較清晰:

  • 獲取內部保存Entry的數組及數組長度;
  • 獲取傳入Entry對應的key,如果和傳入的key相等則直接返回key;
  • 如果Entry對應的key為空,則執行expungeStaleEntry,傳入的參數為當前Entry所在數組下標i;
  • 否則將獲取e在數組中后面那個元素並賦值給e,如果e不為空,則循環從第2步執行,否則直接退出循環;

  對於key為空的Entry在ThreadLocal里面稱為staleSlot,接下來看一下expungeStaleEntry:

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

    // 直接將下標為staleSlot處的元素擦除,value和Entry都要擦除
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;

    // Rehash操作直到數組對應下標處元素為空的情況
    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;
                while (tab[h] != null)
                    h = nextIndex(h, len);
                tab[h] = e;
            }
        }
    }
    return i;
}

  邏輯會稍微復雜一些,我們還是一步一步看:

  • 獲取內部保存Entry的數組及數組長度;
  • key為空代表這個Entry已經不需要了,直接置空,幫助gc,並將size減1;
  • 從傳入的staleSlot下標后面的元素開始,依次遍歷過去,循環執行下面的操作,直到遇到Entry為空停止;
  • 如果Entry為staleSlot(即key為null),則清空;
  • 否則檢查該Entry是否在它應該在的位置(根據hashcode計算出來的下標與其實際下標是否相等);
  • 如果不在則將當前slot置為空,繼續往后尋找,直到一個Entry為空的slot,將其放進去,重復下一次循環;

 

  expungeStaleEntry的作用是清除傳入的staleSlot處的Entry,除此之外還會管兩件"閑事":

  • 從其后面開始清除遇到的staleSlot;
  • rehash計算下標與實際下標不相符的Entry,
  • 直到遇到Entry為空的slot則停止。

  從上面的分析我們得出,通過key獲取元素時,如果從計算出來的下標能獲取到符合要求的值則直接返回,否則會從該位置開始依次往后找;遇到Entry不為空但是Entry的key為空的會擦除該Entry並繼續循環;遇到Entry不為空且key不為空(hash碰撞)則直接往后找;在整個找的過程中遇到Entry為null則停止查找,直接返回null。

3. 設置元素 

  接下來我們看看設置元素,也就是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;
        }
     // 發現staleSlot,則執行replaceStaleEntry,然后直接返回
        if (k == null) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }
   // 如果沒有找到,則new一個Entry插入數組中
    tab[i] = new Entry(key, value);
    int sz = ++size;
   // 插入新的Etry之后需要試探的去擦除一些過期的slot(key=null的Entry),如果Entry數量大於閾值,則執行擴容
if (!cleanSomeSlots(i, sz) && sz >= threshold) rehash(); }

  這也是一個私有方法,這里看起來代碼不多,但是里面涉及到的東西很多,邏輯也要比get方法復雜,但是沒關系,我們層層遞進,一一分解。

  • 獲取Entry數組、數組長度以及通過要插入的key的hashcode計算出其在數組中的下標;
  • 拿到下標之后,對應下標處如果有Entry存在,則有三種情況:
    • key不為空,且等於要插入的key,則直接將value替換成要執行的value,返回;
    • key為空,則執行replaceStaleEntry中的邏輯,返回;
    • 如果key不為空但是又不等於要插入的key,則取下標i處后一個元素,循環執行上面的操作;
  • 如果如上的循環結束,到這里代表沒有找到要插入的key,且當前i處的Entry為空,則直接new一個Entry,將待插入的key和value放入其中,再放入數組;
  • 將代表數組中Entry數量的size加1;
  • 執行cleanSomeSlots中的邏輯,如果有刪除一些Slot,並且size大於閾值,則需要執行rehash中的邏輯進行擴容,否則set執行結束;

  上面的步驟看完之后,我們來看看其中當key為空時需要執行的replaceStaleEntry的邏輯:

private void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;
    Entry e;

    // 現在staleSlot處對應的Entry其key=null,往前查找看是否能不能找到一個stale的Entry
    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();

        // 找到了直接替換,替換之后再嘗試刪除一些stale的Entry
        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;
        }
        // 如果i處對應的Entry是stale,並且前面往前沒有找到stale的Entry,則將i標識為待擦除的slot
        if (k == null && slotToExpunge == staleSlot)
            slotToExpunge = i;
    }

    // 如果沒有找到傳入key對應的entry,則new一個新Entry放在傳入staleSlot下標處,現在staleSlot處的Entry不再是stale(過期的)了
    tab[staleSlot].value = null;
    tab[staleSlot] = new Entry(key, value);

    // 如果還發現有其他stale entries存在, 將其清除
    if (slotToExpunge != staleSlot)
        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}

  這個replaceStaleEntry的邏輯比較難理解,只要清楚它主要干了下面兩件事:

  • 嘗試查找和傳入key對應的Entry,找到則替換,沒找到則在傳入的staleSlot處插入一個新的Entry;
  • 在上面的過程中,盡力地去擦除一些找到的staleSlot;

  以及插入一個新的Entry之后,試探性地去刪除多余的staleSlot(注意,是試探性的哦),邏輯在cleanSomeSlots中:

/**
* @param i 掃描起始下標,從第i+1處開始掃描
*
* @param n 掃描次數控制量,在往后面掃描的過程中,如果沒有發現staleSlot,則最多掃描log2(n)個元素,否則在staleSlot之后再掃log2(table.length-1)個
**/
private
boolean cleanSomeSlots(int i, int n) {
   // 標識是否有刪除過staleSlot
   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; }

  從i+1處開始,往后掃描,如果遇到staleSlot,則執行expungeStaleEntry,往后掃描log2(n)次結束循環,n為傳入的參數,如果發現staleSlot,則將n更新為Entry數組長度len。

  這個設計非常巧妙,試探性的掃描一些單元看是否能發現staleSlot(不新鮮的entrys,也就是key=null)。當一個新元素添加進來或者一個staleSlot被清除的時候,會調用這個方法。該方法掃描元素的數量是對數級的,如果不掃描就不能及時清除key為null的entry(會浪費內存),如果全數組掃描則會導致一次插入的時間復雜度為O(n),采用這種試探性的掃描方式其實是一種在功能和性能之間的平衡,盡最大努力清理垃圾,又不導致過於消耗性能。

 

  如果插入了新Entry,且執行了cleanSomeSlots之后size的數量還是大於閾值的話,這時就需要rehash擴容了:

private void rehash() {
    expungeStaleEntries();

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

// 掃描全表,清除所有staleSlot
private void expungeStaleEntries() { Entry[] tab = table; int len = tab.length; for (int j = 0; j < len; j++) { Entry e = tab[j]; if (e != null && e.get() == null) expungeStaleEntry(j); } }
// 將表容量擴大一倍
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; }

  首先掃描全表,清除所有staleSlot,如果這還不能減小size,則將table容量擴大一倍。擴容的邏輯比較簡單,根據新數組容量來計算新的數組下標,如果存在hash沖突就往后找,直到Entry為空則把元素放進去。

  到這里我們學習了ThreadLocal的基本原理、核心數據結構、最常用的get和set方法,是不是對ThreadLocal有了更深入的了解呢?如果有,那非常高興我的文章能給你帶來一丁點價值^_^

4. 內存泄漏

  前面有講到,ThreadLocalMap中的Entry其類型是屬於弱引用(繼承了WeakReference),被弱引用指向的對象,在下一次GC時是會被回收的,除非這個對象還有強引用指向它(對Java中強、軟、弱、虛引用不清楚的同學可以詳細了解下),之所以這樣設計,我的理解是Entry是存在ThreadLocalMap中,而這個map又是保存在線程thread中的,用戶是不能直接獲取到的,也是不能直接操作的,也就會影響到垃圾回收。為了避免因為ThreadLocalMap存儲了ThreadLocal對象而影響到ThreadLocal對象的垃圾回收,JDK的設計者把主動權完全交給調用方,一旦調用方不想使用,只需設置ThreadLocal對象為null,內存就可以被回收掉了,這也是弱引用的一個主要使用場景。

  另一方面,在set和getEntry的過程中會頻繁的去清理stale entry,以及時釋放空余位置,這樣就可以及時清除value,因為value是我們要保存到ThreadLocal中的值,而這是強引用,即便是key被回收了,value依然不會被回收。

  雖然ThreadLocal中做了種種設計來防止內存泄漏,但是如果使用不當還是會導致內存泄漏,我這里借用一個網上的例子,一起來感受下:

 

public class ThreadLocalLeakDemo {
  
  public static void main(String[] args) {
    new Thread(new Runnable() {

      @Override
      public void run() {
        for(int i = 0; i< 1000 ;i++) {
          TestClass t = new TestClass(i);
          t.printId();
      // 行1,注釋掉這一行時不會導致內存溢出 t
= null;
      // 行2,注釋掉這一行時會導致內存溢出 t.threadLocal.remove(); } } }).start();; }
static class TestClass{ private int id; private int[] arr;
   // 注意,這是一個普通成員哦
private ThreadLocal<TestClass> threadLocal; TestClass(int id){ this.id = id; arr = new int[1000000]; threadLocal = new ThreadLocal(); threadLocal.set(this); } public void printId() { System.out.println(threadLocal.get().id); } } }

/**
* 注釋行2,放開行1時,會導致內存溢出,結果如下:
**/
...
449
450
451
Exception in thread "Thread-0" java.lang.OutOfMemoryError: Java heap space
at testDemos.annotationDemos.ThreadLocalLeakDemo$TestClass.<init>(ThreadLocalLeakDemo.java:28)
at testDemos.annotationDemos.ThreadLocalLeakDemo$1.run(ThreadLocalLeakDemo.java:13)
at java.lang.Thread.run(Unknown Source)
...
 
         
/**
* 注釋行1,放開行2時,不會導致內存泄漏,結果如下:
**/
...
997
998
999

  上面其實就是改了一行代碼,就導致內存溢出,增加的那一步操作就是調用了ThreadLocal的remove,那我們就來看看remove的邏輯:

  移除元素的邏輯很簡單,根據傳入的key定位到數組下標i,從這個下標開始往后循環,直到遇到Entry為空時停止循環。如果找到key對應的entry,則調用Entry的clear方法。

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

   結合上面的例子和源碼,我們解釋一下為什么沒有調用remove方法會導致內存溢出。如上,在不調用remove時,每一次循環都會插入一個新的Entry對象到ThreadLocalMap中,這個Entry是指向一個新的ThreadLocal對象,對於這個ThreadLocal對象存在兩個引用:

  • Entry-->ThreadLocal,這是弱引用;
  • Entry-->value(TestClass)-->ThreadLocal,這是強引用;

  由於強引用一直存在,而t=null並不能讓value不可達,因為value是保存在線程本地內存中的,所以沒法回收這個新的ThreadLocal對象,導致一直堆積,最終報OOM

  而如果調用remove的話,則會直接將對應Entry以及其保存的value清空,這樣就不會內存泄漏了。

  其實上面的例子是使用不當導致的,如果將ThreadLocal成員變量置為static,也不會出現這個問題,因為即便有1000次循環,但是都是用的同一個ThreadLocal,在線程本地始終只有一份,用private static來修飾ThreadLocal也是一個官方推薦的慣用法。

5. 總結

  1. ThreadLocal內部數據結構:Entry數組
  2. Entry封裝要保存的數據,以key-value的形式,key的類型為指向ThreadLocal的WeakReference,value為要保存的對象
  3. 通過key的hashcode來初步定位其在數組中的位置,如果沒有則往后依次查找,如果找到則返回(getEntry)或替換(set),直到碰到為空的Entry為止,這就是解決hash碰撞所采用的方法;
  4. 當出現hash沖突時,ThreadLocalMap采用的辦法就是繼續往后面找,這是線性操作所以會比較低效。但是ThreadLocal采用的散列算法效果很好,沖突的概率非常小,再加上在set和getEntry的過程中會頻繁的去清理stale entry(expungeStaleEntry、replaceStaleEntry、cleanSomeSlots中都有涉及到),是為了能夠及時釋放空余位置,進一步降低這種低效帶來的影響。
  5. 由於Entry是指向ThreadLocal對象的弱引用,所以當ThreadLocal對象不存在強引用的時候,是可以被回收的,回收之后Entry就指向空了(get獲取的key為null),但是這時候Entry中的value仍然不為空,可以可能導致內存泄漏,有兩種方式可以清除:
  •   在ThreadLocal的get、set方法中會頻繁的去清除staleSlot
  •   手動調用TreadLocal的remove方法來清除

  以上為個人總結,如有不對,煩請指正。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM