首頁 > 軟體

ThreadLocal作用原理與記憶體洩露範例解析

2022-09-04 18:04:00

ThreadLocal作用

對於Android程式設計師來說,很多人都是在學習訊息機制時候瞭解到ThreadLocal這個東西的。那它有什麼作用呢?官方檔案大致是這麼描述的:

  • ThreadLocal提供了執行緒區域性變數
  • 每個執行緒都擁有自己的變數副本,可以通過ThreadLocal的set或者get方法去設定或者獲取當前執行緒的變數,變數的初始化也是執行緒獨立的(需要實現initialValue方法)
  • 一般而言ThreadLocal範例在類中被private static修飾
  • 當執行緒活著並且ThreadLocal範例能夠存取到時,每個執行緒都會持有一個到它的變數的參照
  • 當一個執行緒死亡後,所有ThreadLocal範例給它提供的變數都會被gc回收(除非有其它的參照指向這些變數) 上述中“變數”是指ThreadLocal的get方法獲取的值

簡單例子

先來看一個簡單的使用例子吧:

public class ThreadId {
    private static final AtomicInteger nextId = new AtomicInteger(0);
    private static final ThreadLocal<Integer> threadId = new ThreadLocal<Integer>() {
        @Override
        protected Integer initialValue() {
            return nextId.get();
        }
    };
    public static int get() {
        return threadId.get();
    }
}

這也是官方檔案上的例子,非常簡單,就是通過在不同執行緒呼叫ThredId.get()可以獲取唯一的執行緒Id。如果在呼叫ThreadLocal的get方法之前沒有主動呼叫過set方法設定值的話,就會返回initialValue方法的返回值,並把這個值儲存為當前執行緒的變數。

ThreadLocal到底是用來解決什麼問題,適用什麼場景呢,例子是看懂了,但好像還是沒什麼體會?ThreadLocal既然是提供變數的,我們不妨把我們見過的變數型別拿出來,做個對比

區域性變數、成員變數 、 ThreadLocal、靜態變數

變數型別作用域生命週期執行緒共用性作用
區域性變數方法(程式碼塊)內部,其他方法(程式碼塊)不能存取方法(程式碼塊)開始到結束只存在於每個執行緒的工作記憶體,不能線上程中共用解決變數在方法(程式碼塊)內部的程式碼行之間的共用
成員變數範例內和範例相同可線上程間共用解決變數在實體方法之間的共用,否則方法之間只能靠引數傳遞變數
靜態變數類內部和類的生命週期相同可在多個執行緒間共用解決變數在多個範例之間的共用
ThreadLocal儲存的變數整個執行緒一般而言與執行緒的生命週期相同不再多執行緒間共用解決變數在單個執行緒中的共用問題,執行緒中處處可存取

ThreadLocal儲存的變數本質上間接算是Thread的成員變數,ThreadLocal只是提供了一種對開發者透明的可以為每個執行緒儲存同一維度成員變數的方式。

共用 or 隔離

網上有很多人持有如下的看法: ThreadLocal為解決多執行緒程式的並行問題提供了一種新思路或者ThreadLocal是為了解決多執行緒存取資源時的共用問題。 個人認為這些都是錯誤的,ThreadLocal儲存的變數是執行緒隔離的,與資源共用沒有任何關係,也沒有解決什麼並行問題,這一點看了ThreadLocal的原理就會更加清楚。就好比上面的例子,每個執行緒應該有一個執行緒Id,這並不是什麼並行問題啊。

同時他們會拿ThreadLocal與sychronized做對比,我們要清楚它們根本不是為了解決同一類問題設計的。sychronized是在牽涉到共用變數時候,要做到執行緒間的同步,保證並行中的原子性與記憶體可見性,典型的特徵是多個執行緒會存取相同的變數。而ThreadLocal根本不是解決執行緒同步問題的,它的場景是A執行緒儲存的變數只有A執行緒需要存取,而其它的執行緒並不需要存取,其他執行緒也只存取自己儲存的變數。

原理

我們來一個開放性的問題,假如現在要給每個執行緒增加一個執行緒Id,並且Java的Thread類你能隨便修改,你要怎麼操作?非常簡單吧,程式碼大概是這樣

public class Thread{
      private int id;
      public void setId(int id){
          this.id=id;
      }
}

那好,現在題目變了,我們現在還得為每個執行緒儲存一個Looper物件,那怎麼辦呢?再加一個Looper的欄位不就好了,顯然這種做法肯定是不具有擴充套件性的。那我們用一個容器類不就好了,很自然地就會想到Map,像下面這樣

public class Thread{
      private Map<String,Object> map;
     public Map<String,Object> getMap(){
         if(map==null)
            map=new HashMap<>();
         return map;
     }
}

然後我們在程式碼裡就可以通過如下程式碼來給Thread設定“成員變數”了

   Thread.currentThread().getMap().put("id",id);
   Thread.currentThread().getMap().put("looper",looper);

然後可以在該執行緒執行的任意地方,這樣存取:

  Looper looper=(Looper) Thread.currentThread().getMap().get("looper");

看上去還不錯,但是還是有些問題:

  • 儲存和獲取變數都要用到字元換key
  • 因為map中要儲存各種值,因此泛型只得用Object,這樣獲取時候就需要強制轉換(可用泛型方法解)
  • 當該變數沒有作用時候,此時執行緒還沒有執行完,需要手動設定該變數為空,否則會造成記憶體漏失

為了不通過字串存取,同時省去強制轉換,我們封裝一個類,就叫ThreadLocal吧,虛擬碼如下:

  public class ThreadLocal<T> {
    public void set(T value) {
        Thread t = Thread.currentThread();
         Map map = t.getMap();
        if (map != null)
           //以自己為鍵
            map.put(this, value);
        else
            createMap(t, value);
    }
    public T get() {
        Thread t = Thread.currentThread();
        Map<ThreadLocal<?>,T> map = t.getMap();
        if (map != null) {
            T e = map.get(this);
            return e;
        }
        return setInitialValue();
    }
}

沒錯,以上基本上就是ThreadLocal的整體設計了,只是執行緒中儲存資料的Map是特意實現的ThreadLocal.ThreadLocalMap。

ThredLocal本身並不儲存變數,只是向每個執行緒的threadLocals中儲存鍵值對。ThreadLocal橫跨執行緒,提供一種類似切面的概念,這種切面是作用線上程上的。

我們對ThreadLocal已經有一個整體的認識了,接下來我們大致看一下原始碼

原始碼分析

TheadLocal

   public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }

set方法通過Thread.currentThread方法獲取當前執行緒,然後呼叫getMap方法獲取執行緒的threadLocals欄位,並往ThreadLocalMap中放入鍵值對,其中鍵為ThreadLocal範例自己。

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

接著看get方法:

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

很清晰,其中值得注意的是最後一行的setInitialValue方法,這個方法在我們沒有呼叫過set方法時候呼叫。

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

setInitialValue方法會獲取initialValue的返回值並把它放進當前執行緒的threadLocals中。預設情況下initialValue返回null,我們可以實現這個方法來對變數進行初始化,就像上面TheadId的例子一樣。

remove方法,從當前執行緒的ThreadLocalMap中移除元素。

public void remove() {
         ThreadLocalMap m = getMap(Thread.currentThread());
         if (m != null)
             m.remove(this);
     }

TheadLocalMap

看ThreadLocalMap的程式碼我們主要是關注以下兩個方面:

  • 雜湊表的一般設計問題。包括雜湊函數,雜湊衝突問題解決,負載因子,再雜湊等。
  • 記憶體漏失的相關處理。一般而言ThreadLocal 參照使用private static修飾,但是假設某種情況下我們真的不再需要使用它了,手動把參照置空。上面我們知道TreadLocal本身作為鍵儲存在TheadLocalMap中,而ThreadLocalMap又被Thread參照,那執行緒沒結束的情況下ThreadLocal能被回收嗎?

雜湊函數 先來理一下雜湊函數吧,我們在之後的程式碼中會看到ThreadLocalMap通過 int i = key.threadLocalHashCode & (len-1);決定元素的位置,其中表大小len為2的冪,因此這裡的&操作相當於取模。另外我們關注的是threadLocalHashCode的取值。

  private final int threadLocalHashCode = nextHashCode();
 private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT);
    }
  private static AtomicInteger nextHashCode =
        new AtomicInteger();
   private static final int HASH_INCREMENT = 0x61c88647;

這裡很有意思,每個ThreadLocal範例的threadLocalHashCode是在之前ThreadLocal範例的threadLocalHashCode上加 0x61c88647,為什麼偏偏要加這麼個數呢? 這個魔數的選取與斐波那契雜湊有關以及黃金分割法有關,具體不是很清楚。它的作用是這樣產生的值與2的冪取模後能在雜湊表中均勻分佈,即便擴容也是如此。看下面一段程式碼:

  public class MagicHashCode {
      //ThreadLocal中定義的魔數
     private static final int HASH_INCREMENT = 0x61c88647;
     public static void main(String[] args) {
         hashCode(16);//初始化16
         hashCode(32);//2倍擴容
         hashCode(64);
     }
    private static void hashCode(int length){         
        int hashCode = 0; 
         for(int i=0;i<length;i++){
            hashCode = i*HASH_INCREMENT+HASH_INCREMENT;
             System.out.print(hashCode & (length-1));//求取模後的下標
             System.out.print(" ");
         }
         System.out.println();
     }
 }

輸出結果為:

7 14 5 12 3 10 1 8 15 6 13 4 11 2 9 0   //容量為16時
7 14 21 28 3 10 17 24 31 6 13 20 27 2 9 16 23 30 5 12 19 26 1 8 15 22 29 4 11 18 25 0  //容量為32時
7 14 21 28 35 42 49 56 63 6 13 20 27 34 41 48 55 62 5 12 19 26 33 40 47 54 61 4 11 18 25 32 39 46 53 60 3 10 17 24 31 38 45 52 59 2 9 16 23 30 37 44 51 58 1 8 15 22 29 36 43 50 57 0  //容量為64時

因為ThreadLocalMap使用線性探測法解決衝突(下文會看到),均勻分佈的好處在於發生了衝突也能很快找到空的slot,提高效率。

瞄一眼成員變數:

       /**
         * 初始容量,必須是2的冪。這樣的話,方便把取模運算轉化為與運算, 
         * 效率高
         */
        private static final int INITIAL_CAPACITY = 16;
        /**
         * 容納Entry元素,長度必須是2的冪
         */
        private Entry[] table;
        /**
         * table中的元素個數.
         */
        private int size = 0;
        /**
         * table裡的元素達到這個值就需要擴容了
         * 其實是有個裝載因子的概念的
         */
        private int threshold; // Default to 0

建構函式:

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

firstKey和firstValue就是Map存放的第一個鍵值對嘍。其中firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1)很關鍵,就是當容量為2的冪時候,這相當於一個取模操作。然後把Entry儲存到陣列的第i個位置,設定擴容的閾值。

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

這說明當陣列裡的元素容量達到2/3時候就要擴容,也就是裝載因子是2/3。 接下來我們來看下Entry

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

就這麼點東西,這個Entry只是與HashMap不同,只是個普通的鍵值對,沒有連結串列結構相關的東西。

另外Entry只持有對鍵,也就是ThreadLocal的弱參照,那麼我們上面的第二個問題算是有答案了。當沒有其他強參照指向ThreadLocal的時候,它其實是會被回收的。

但是這有引出了另外一個問題,那Entry呢?當鍵都為空的時候這個Entry也是沒有什麼作用啊,也應該被回收啊。不慌,我們接著往下看。

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;
                }
                if (k == null) {
                   //該Entry對應的ThreadLocal已經被回收,執行replaceStaleEntry並返回
                    replaceStaleEntry(key, value, i);
                    return;
                }
            }
            tab[i] = new Entry(key, value);
            int sz = ++size;
            //進行啟發式清理,如果沒有清理任何元素並且表的大小超過了閾值,需要擴容並重雜湊
            if (!cleanSomeSlots(i, sz) && sz >= threshold)
                rehash();
        }

我們發現如果發生衝突的話,整體邏輯會一直呼叫nextIndex方法去探測下一個位置,直到找到沒有元素的位置,邏輯上整個表是一個環形。下面是nextIndex的程式碼,就是加1而已。

private static int nextIndex(int i, int len) {
            return ((i + 1 < len) ? i + 1 : 0);
}

線性探測的過程中,有一種情況是需要清理對應Entry的,也就是Entry的key為null,我們上面討論過這種情況下的Entry是無意義的。

因此呼叫 replaceStaleEntry(key, value, i);在看replaceStaleEntry(key, value, i)我們先明確幾個問題。

採用線性探測發解決衝突,在插入過程中產生衝突的元素之前一定是沒有空的slot的。這樣在也確保在查詢過程,查詢到空的slot就可以停止啦。

但是假如我們刪除了一個元素,就會破壞這種情況,這時需要對錶中刪除的元素後面的元素進行再雜湊,以便填上空隙。

空slot:即該位置沒有元素

無效slot:該位置有元素,但key為null

replaceStaleEntry除了將value放入合適的位置之外,還會在前後連個空的slot之間做一次清理expungeStaleEntry,清理掉無效slot。

private void replaceStaleEntry(ThreadLocal<?> key, Object value,
                               int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;
    Entry e;
    // 向前掃描到一個空的slot為止,找到離這個空slot最近的無效slot,記錄為slotToExpunge
    int slotToExpunge = staleSlot;
    for (int i = prevIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = prevIndex(i, len)) {
        if (e.get() == null) {
            slotToExpunge = i;
        }
    }
    // 向後遍歷table
    for (int i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();
        // 找到了key,將其與無效slot交換
        if (k == key) {
            // 更新對應slot的value值
            e.value = value;
            tab[i] = tab[staleSlot];
            tab[staleSlot] = e;
            //如果之前還沒有探測到過其他無效的slot
            if (slotToExpunge == staleSlot) {
                slotToExpunge = i;
            }
            // 從slotToExpunge開始做一次連續段的清理,再做一次啟發式清理
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
            return;
        }
        // 如果當前的slot已經無效,並且向前掃描過程中沒有無效slot,則更新slotToExpunge為當前位置
        if (k == null && slotToExpunge == staleSlot) {
            slotToExpunge = i;
        }
    }
    // 如果key之前在table中不存在,則放在staleSlot位置
    tab[staleSlot].value = null;
    tab[staleSlot] = new Entry(key, value);
    // 在探測過程中如果發現任何其他無效slot,連續段清理後做啟發式清理
    if (slotToExpunge != staleSlot) {
        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
    }
}

expungeStaleEntry主要是清除連續段之前無效的slot,然後對元素進行再雜湊。返回下一個空的slot位置。

 private int expungeStaleEntry(int staleSlot) {
            Entry[] tab = table;
            int len = tab.length;
            // 刪除 staleSlot
            tab[staleSlot].value = null;
            tab[staleSlot] = null;
            size--;
            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;
        }

啟發式地清理: i對應是非無效slot(slot為空或者有效) n是用於控制控制掃描次數 正常情況下如果log n次掃描沒有發現無效slot,函數就結束了。 但是如果發現了無效的slot,將n置為table的長度len,做一次連續段的清理,再從下一個空的slot開始繼續掃描。

這個函數有兩處地方會被呼叫,一處是插入的時候可能會被呼叫,另外個是在替換無效slot的時候可能會被呼叫, 區別是前者傳入的n為實際元素個數,後者為table的總容量。

private boolean cleanSomeSlots(int i, int n) {
    boolean removed = false;
    Entry[] tab = table;
    int len = tab.length;
    do {
        // i在任何情況下自己都不會是一個無效slot,所以從下一個開始判斷
        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;
}

接著看set函數,如果迴圈過程中沒有返回,找到合適的位置,插入元素,表的size增加1。這個時候會做一次啟發式清理,如果啟發式清理沒有清理掉任何無效元素,判斷清理前表的大小大於閾值threshold的話,正常就要進行擴容了,但是表中可能存在無效元素,先把它們清除掉,然後再判斷。

private void rehash() {
    // 全量清理
    expungeStaleEntries();
    //因為做了一次清理,所以size可能會變小,這裡的實現是調低閾值來判斷是否需要擴容。 threshold預設為len*2/3,所以這裡的threshold - threshold / 4相當於len/2。
    if (size >= threshold - threshold / 4) {
        resize();
    }
}

作用即清除所有無效slot

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

保證table的容量len為2的冪,擴容時候要擴大2倍

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

get方法:

private Entry getEntry(ThreadLocal<?> key) {
    int i = key.threadLocalHashCode & (table.length - 1);
    Entry e = table[i];
    // 對應的entry存在且key未被回收
    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;
    // 不斷向後探測直到遇到空entry
    while (e != null) {
        ThreadLocal<?> k = e.get();
        // 找到
        if (k == key) {
            return e;
        }
        if (k == null) {
            // 該entry對應的ThreadLocal範例已經被回收,呼叫expungeStaleEntry來清理無效的entry
            expungeStaleEntry(i);
        } else {
            // 下一個位置
            i = nextIndex(i, len);
        }
        e = tab[i];
    }
    return null;
}

remove方法,比較簡單,在table中找key,如果找到了斷開弱參照,做一次連續段清理。

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與記憶體漏失

從上文我們知道當呼叫ThreadLocalMap的set或者getEntry方法時候,有很大概率會去自動清除掉key為null的Entry,這樣就可以斷開value的強參照,使物件被回收。

但是如果如果我們之後再也沒有在該執行緒操作過任何ThreadLocal範例的set或者get方法,那麼就只能等執行緒死亡才能回收無效value。

因此當我們不需要用ThreadLocal的變數時候,顯示呼叫ThreadLocal的remove方法是一種好的習慣。

小結

  • ThredLocal為每個執行緒儲存一個自己的變數,但其實ThreadLocal本身並不儲存變數,變數儲存線上程自己的範例變數ThreadLocal.ThreadLocalMap threadLocals
  • ThreadLocal的設計並不是為了解決並行問題,而是解決一個變數線上程內部的共用問題,線上程內部處處可以存取
  • 因為每個執行緒都只會存取自己ThreadLocalMap 儲存的變數,所以不存線上程安全問題

以上就是ThreadLocal作用原理與記憶體洩露範例解析的詳細內容,更多關於ThreadLocal記憶體洩露的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com