Android Handler機制之ThreadLocal

AndyJennifer發表於2018-09-22

小積木.jpg

該文章屬於《Android Handler機制之》系列文章,如果想了解更多,請點選 《Android Handler機制之總目錄》

前言

要想了解Android 的Handle機制,我們首先要了解ThreadLocal,根據字面意思我們都能猜出個大概。就是執行緒本地變數。那麼我們把變數儲存在本地有什麼好處呢?其中的原理又是什麼呢?下面我們就一起來討論一下ThreadLocal的使用與原理。

ThreadLocal簡單介紹

該類提供執行緒區域性變數。這些變數不同於它們的正常變數,即每一個執行緒訪問自身的區域性變數時,都有它自己的,獨立初始化的副本。該變數通常是與執行緒關聯的私有靜態欄位,列如用於ID或事物ID。大家看了介紹後,有可能還是不瞭解其主要的主要作用,簡單的畫個圖幫助大家理解。

ThreadLocal示意圖.png

從圖上可以看出,通過ThreadLocal,每個執行緒都能獲取自己執行緒內部的私有變數,有可能大家覺得無圖無真相,“你一個人在那裡神吹,我怎麼知道你說的對還是不對呢?”,下面我們通過具體的例子詳細的介紹,來看下面的程式碼。

class ThreadLocalTest {
	//會出現記憶體洩漏的問題,下文會描述
    private static ThreadLocal<String> mThreadLocal = new ThreadLocal<>();

    public static void main(String[] args) {
        mThreadLocal.set("執行緒main");
        new Thread(new A()).start();
        new Thread(new B()).start();
        System.out.println(mThreadLocal.get());
    }

    static class A implements Runnable {

        @Override
        public void run() {
            mThreadLocal.set("執行緒A");
            System.out.println(mThreadLocal.get());
        }
    }

    static class B implements Runnable {

        @Override
        public void run() {
            mThreadLocal.set("執行緒B");
            System.out.println(mThreadLocal.get());
        }
    }
}
複製程式碼

在上訴程式碼中,我們在主執行緒中設定mThreadLocal的值為"執行緒main",線上程A中設定為”執行緒A“,線上程B中設定為”執行緒B",執行程式列印結果如下圖所示:

main
執行緒A
執行緒B
複製程式碼

從上面結果可以看出,雖然是在不同的執行緒中訪問的同一個變數mThreadLocal,但是他們通過ThreadLocl獲取到的值卻是不一樣的。也就驗證了上面我們所畫的圖是正確的了,那麼現在,我們已經知道了ThreadLocal的用法,那麼我們現在來看看其中的內部原理。

ThreadLocal原理

為了幫助大家快速的知曉ThreadLocal原理,這裡我將ThreadLocal的原理用下圖表示出來了:

threadLocal.png

在上圖中我們可以發現,整個ThreadLocal的使用都涉及到執行緒中ThreadLocalMap,雖然我們在外部呼叫的是ThreadLocal.set(value)方法,但本質是通過執行緒中的ThreadLocalMap中的set(key,value)方法,那麼通過該情況我們大致也能猜出get方法也是通過ThreadLocalMap。那麼接下來我們一起來看看ThreadLocal中set與get方法的具體實現與ThreadLocalMap的具體結構。

ThreadLocal的set方法

在使用ThreadLocal時,我們會呼叫ThreadLocal的set(T value)方法對執行緒中的私有變數設定,我們來檢視ThreadLocal的set方法

    public void set(T value) {
        Thread t = Thread.currentThread();//獲取當前執行緒
        ThreadLocalMap map = getMap(t);//拿到執行緒的LocalMap
        if (map != null)
            map.set(this, value);//設值 key->當前ThreadLocal物件。value->為當前賦的值
        else
            createMap(t, value);//建立新的ThreadLocalMap並設值
    }
複製程式碼

當呼叫set(T value) 方法時,方法內部會獲取當前執行緒中的ThreadLocalMap,獲取後進行判斷,如果不為空,就呼叫ThreadLocalMap的set方法(其中key為當前ThreadLocal物件,value為當前賦的值)。反之,讓當前執行緒建立新的ThreadLocalMap並設值,其中getMap()與createMap()方法具體程式碼如下:

  ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }
    
  void createMap(Thread t, T firstValue) {
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }
複製程式碼

簡簡單單的通過ThreadLocalMap的set()方法,我們已經大致瞭解了。ThreadLocal為什麼能操作執行緒內的私有資料了,ThreadLocal中所有的資料操作都與執行緒中的ThreadLocalMap有關,同時那我們接下來看看ThreadLocalMap相關程式碼。

ThreadLocalMap 內部結構

ThreadLocalMap是ThreadLocal中的一個靜態內部類,官方的註釋寫的很全面,這裡我大概的翻譯了一下,ThreadLocalMap是為了維護執行緒私有值建立的自定義雜湊對映。其中執行緒的私有資料都是非常大且使用壽命長的資料(其實想一想,為什麼要儲存這些資料呢,第一是為了把常用的資料放入執行緒中提高了訪問的速度,第二是如果資料是非常大的,避免了該資料頻繁的建立,不僅解決了儲存空間的問題,也減少了不必要的IO消耗)。

ThreadLocalMap 具體程式碼如下:

 static class ThreadLocalMap {
		//儲存的資料為Entry,且key為弱引用
        static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;

            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }
        //table初始容量
        private static final int INITIAL_CAPACITY = 16;
      
        //table 用於儲存資料
        private Entry[] table;
        
	    //負載因子,用於陣列容量擴容
        private int threshold; // Default to 0
        
		//負載因子,預設情況下為當前陣列長度的2/3
        private void setThreshold(int len) {
            threshold = len * 2 / 3;
        }
	    //第一次放入Entry資料時,初始化陣列長度,定義擴容閥值,
        ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
            table = new Entry[INITIAL_CAPACITY];//初始化陣列長度為16
            int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
            table[i] = new Entry(firstKey, firstValue);
            size = 1;
            setThreshold(INITIAL_CAPACITY);//閥值為當前陣列預設長度的2/3
        }

複製程式碼

從程式碼中可以看出,雖然官方申明為ThreadLocalMap是一個雜湊表,但是它與我們傳統認識的HashMap等雜湊表內部結構是不一樣的。ThreadLocalMap內部僅僅維護了Entry[] table,陣列。其中Entry實體中對應的key為弱引用(下文會將為什麼會用弱引用),在第一次放入資料時,會初始化陣列長度(為16),定義陣列擴容閥值(當前預設陣列長度的2/3)。

ThreadLocalMap 的set()方法

 private void set(ThreadLocal<?> key, Object value) {

		    //根據雜湊值計算位置
            Entry[] tab = table;
            int len = tab.length;
            int i = key.threadLocalHashCode & (len-1);
            
            //判斷當前位置是否有資料,如果key值相同,就替換,如果不同則找空位放資料。
            for (Entry e = tab[i];
                 e != null;
                 e = tab[i = nextIndex(i, len)]) {//獲取下一個位置的資料
                ThreadLocal<?> k = e.get();
			//判斷key值相同否,如果是直接覆蓋 (第一種情況)
                if (k == key) {
                    e.value = value;
                    return;
                }
			//如果當前Entry物件對應Key值為null,則清空所有Key為null的資料(第二種情況)
                if (k == null) {
                    replaceStaleEntry(key, value, i);
                    return;
                }
            }
            //以上情況都不滿足,直接新增(第三種情況)
            tab[i] = new Entry(key, value);
            int sz = ++size;
            if (!cleanSomeSlots(i, sz) && sz >= threshold)//如果當前陣列到達閥值,那麼就進行擴容。
                rehash();
        }
複製程式碼

直接通過程式碼理解比較困難,這裡直接將set方法分為了三個步驟,下面我們我們就分別對這個三個步驟,分別通過圖與程式碼的方式講解。

第一種情況, Key值相同

如果當前陣列中,如果當前位置對應的Entry的key值與新新增的Entry的key值相同,直接進行覆蓋操作。具體情況如下圖所示

key值相同情況.png

如果當前陣列中。存在key值相同的情況,ThreadLocal內部操作是直接覆蓋的。這種情況就不過多的介紹了。

第二種情況,如果當前位置對應Entry的Key值為null

第二種情況相對來說比較複雜,這裡先給圖,然後會根據具體程式碼來講解。

對應位置Key值為null.png

從圖中我們可以看出來。當我們新增新Entry(key=19,value =200,index = 3)時,陣列中已經存在舊Entry(key =null,value = 19),當出現這種情況是,方法內部會將新Entry的值全部賦值到舊Entry中,同時會將所有陣列中key為null的Entry全部置為null(圖中大黃色資料)。在原始碼中,當新Entry對應位置存在資料,且key為null的情況下,會走replaceStaleEntry方法。具體程式碼如下:

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

	        //記錄當前要清除的位置
            int slotToExpunge = staleSlot;
            
            //往前找,找到第一個過期的Entry(key為空)
            for (int i = prevIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = prevIndex(i, len))
                if (e.get() == null)//判斷引用是否為空,如果為空,擦除的位置為第一個過期的Entry的位置
                    slotToExpunge = i;

		    //往後找,找到最後一個過期的Entry(key為空),
            for (int i = nextIndex(staleSlot, len);//這裡要注意獲得位置有可能為0,
                 (e = tab[i]) != null;
                 i = nextIndex(i, len)) {
                ThreadLocal<?> k = e.get();
                //在往後找的時候,如果獲取key值相同的。那麼就重新賦值。
                if (k == key) {
                	//賦值到之前傳入的staleSlot對應的位置
                    e.value = value;
                    tab[i] = tab[staleSlot];
                    tab[staleSlot] = e;

                    //如果往前找的時候,沒有過期的Entry,那麼就記錄當前的位置(往後找相同key的位置)
                    if (slotToExpunge == staleSlot)
                        slotToExpunge = i;
                        
                    //那麼就清除slotToExpunge位置下所有key為null的資料
                    cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
                    return;
                }

			    //如果往前找的時候,沒有過期的Entry,且key =null那麼就記錄當前的位置(往後找key==null位置)
                if (k == null && slotToExpunge == staleSlot)
                    slotToExpunge = i;
            }

            // 把當前key為null的對應的資料置為null,並建立新的Entry在該位置上
            tab[staleSlot].value = null;
            tab[staleSlot] = new Entry(key, value);

            //如果往後找,沒有過期的實體, 
            //且staleSlot之前能找到第一個過期的Entry(key為空),
            //那麼就清除slotToExpunge位置下所有key為null的資料
            if (slotToExpunge != staleSlot)
                cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
        }

複製程式碼

上面程式碼看起來比較繁雜,但是大家仔細梳理就會發現其實該方法,主要對四種情況進行了判斷,具體情況如下圖表所示:

TIM截圖20180731110649.png

我們已經瞭解了replaceStaleEntry方法內部會清除key==null的資料,而其中具體的方法與expungeStaleEntry()方法與cleanSomeSlots()方法有關,所以接下來我們來分析這兩個方法。看看其的具體實現。

expungeStaleEntry ()方法

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

            // 將staleSlot位置下的資料置為null
            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) {//清除key為null的資料
                    e.value = null;
                    tab[i] = null;
                    size--;
                } else {
                //如果key不為null,但是該key對應的threadLocalHashCode發生變化,
                //計算位置,並將元素放入新位置中。
                    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;//返回最後一個tab[i]) != null的位置
        }
複製程式碼

expungeStaleEntry()方法主要乾了三件事,第一件,將staleSlot的位置對應的資料置為null,第二件,刪除並刪除此位置後對應相關聯位置key = null的資料。第三件,如果如果key不為null,但是該key對應的threadLocalHashCode發生變化,計算變化後的位置,並將元素放入新位置中。

cleanSomeSlots()方法

    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;//如果有過期的資料被刪除,就返回true,反之false
        }
複製程式碼

在瞭解了expungeStaleEntry()方法後,再來理解cleanSomeSlots()方法就很簡單了。其中第一個參數列示開始掃描的位置,第二個引數是掃描的長度。從程式碼我們明顯的看出。就是簡單的遍歷刪除所有位置下key==null的資料。

第三種情況,當前對應位置為null

沒有資料的情況.png

圖上為了方便大家,理解清空上下資料的情況,我並沒有重新計算位置(希望大家注意!!!)

看到這裡,為了方便大家避免不必要的查閱程式碼,我直接將程式碼貼出來了。程式碼如下。

tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
                rehash();
               
複製程式碼

從上述程式碼其實,大家很明顯的看出來,就是清除key==null的資料,判斷當前資料的長度是不是到達閥值(預設沒擴容前為INITIAL_CAPACITY *2/3,其中INITIAL_CAPACITY = 16),如果達到了重新計算資料的位置。關於rehash()方法,具體程式碼如下:

 private void rehash() {
         expungeStaleEntries();

         // Use lower threshold for doubling to avoid hysteresis
         if (size >= threshold - threshold / 4)
                resize();
        }
        
 //清空所有key==null的資料
 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);
            }
        }
 //重新計算key!=null的資料。新的陣列長度為之前的兩倍      
 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++;
                    }
                }
            }
			//重新計算閥值(負載因子)為擴容之後的陣列長度的2/3
            setThreshold(newLen);
            size = count;
            table = newTab;
        }
複製程式碼

rehash內部所有涉及到的方法,我都列舉出來了。可以看出在新增資料的時候,會進行判斷是否擴容操作,如果需要擴容,會清除所有的key==null的資料,(也就是呼叫expungeStaleEntries()方法,其中expungeStaleEntry()方法已經介紹了,就不過多描述),同時會重新計算資料中的位置。

ThreadLocal的get()方法

在瞭解了ThreadLocal的set()方法之後,我們看看怎麼獲取ThreadLocal中的資料,具體程式碼如下:

  public T get() {
        Thread t = Thread.currentThread();//獲取當前執行緒
        ThreadLocalMap map = getMap(t);//拿到執行緒中的Map
        if (map != null) {
            //根據key值(ThreadLocal)物件,獲取儲存的資料
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        //如果ThreadLocalMap為空,建立新的ThreadLocalMap 
        return setInitialValue();
    }
複製程式碼

其實ThreadLocal的get方法其實很簡單,就是獲取當前執行緒中的ThreadLocalMap物件,如果沒有則建立,如果有,則根據當前的 key(當前ThreadLocal物件),獲取相應的資料。其中內部呼叫了ThreadLocalMap的getEntry()方法區獲取資料,我們繼續檢視getEntry()方法。

 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);
        }
複製程式碼

getEntry()方法內部也很簡單,也只是根據當前key雜湊後計算的位置,去找陣列中對應位置是否有資料,如果有,直接將資料放回,如果沒有,則呼叫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)//如果key相同,直接返回
                    return e;
                if (k == null)//如果key==null,清除當前位置下所有key=null的資料。
                    expungeStaleEntry(i);
                else
                    i = nextIndex(i, len);
                e = tab[i];
            }
            return null;//沒有資料直接返回null
        }
複製程式碼

從上述程式碼我們可以知道,如果從陣列中,獲取的key==null的情況下,get方法內部也會呼叫expungeStaleEntry()方法,去清除當前位置所有key==null的資料,也就是說現在不管是呼叫ThreadLocal的set()還是get()方法,都會去清除key==null的資料。

ThreadLocal記憶體洩漏的問題

通過整個ThreadLocal機制的探索,我相信大家肯定會有一個疑惑,為什麼ThreadLocalMap中採用是的是弱引用作為Key?關於該問題,涉及到Java的回收機制。

為什麼使用弱引用

在Java中判斷一個物件到底是不是需要回收,都跟引用相關。在Java中引用分為了4類。

  • 強引用:只要引用存在,垃圾回收器永遠不會回收Object obj = new Object();而這樣 obj物件對後面new Object的一個強引用,只有當obj這個引用被釋放之後,物件才會被釋放掉。
  • 軟引用:是用來描述,一些還有但並非必須的物件,對於軟引用關聯著的物件,在系統將要發生記憶體溢位異常之前,將會把這些物件列進回收範圍之中進行第二次回收。(SoftReference)
  • 弱引用:也是用來描述非必須的物件,但是它的強度要比軟引用更弱一些。被弱引用關聯的物件只能生存到下一次垃圾收集發生之前,當垃圾收集器工作是,無論當前記憶體是否足夠,都會回收掉被弱引用關聯的物件。(WeakReference)
  • 虛引用:也被稱為幽靈引用,它是最弱的一種關係。一個物件是否有引用的存在,完全不會對其生存時間構成影響,也無法通過一個虛引用來取得一個例項物件。

通過該知識點的瞭解後,我們再來了解為什麼ThreadLocal不能使用強引用,如果key使用強引用,那麼當引用ThreadLocal的物件被回收了,但ThreadLocalMap中還持有ThreadLocal的強引用,如果沒有手動刪除,ThreadLocal不會被回收,導致記憶體洩漏。

弱引用帶來的問題

當我們知道了為什麼採用弱引用來作為ThreadLocalMap中的key的知識點後,這個時候又會引申出另一個問題不管是呼叫ThreadLocal的set()還是get()方法,都會去清除key==null的資料。為毛我們要去清除那些key==null的Entry呢?

為什麼清除key==null的Entry主要有以下兩個原因,具體如下所示:

  • 從上面我們已經知道了,ThreadLocalMap使用ThreadLocal的弱引用作為key,也就是說,如果一個ThreadLocal沒有外部強引用來引用它,那麼系統 GC 的時候,這個ThreadLocal勢必會被回收。這樣一來,ThreadLocalMap中就會出現key為null的Entry,就沒有辦法訪問這些key為null的Entry的value,
  • 如果當前執行緒遲遲不結束的話,這些key為null的Entry的value就會一直存在一條強引用鏈:Thread Ref(當前執行緒引用) -> Thread -> ThreadLocalMap -> Entry -> value,那麼將會導致這些Entry永遠無法回收,造成記憶體洩漏。

通過以上分析,我們可以瞭解在ThreadLocalMap的設計中其實已經考慮到上述兩種情況,也加上了一些防護措施。(在呼叫ThreadLocal的get(),set(),remove()方法的時候都會清除執行緒ThreadLocalMap裡所有key為null的Entry)

ThreadLocal使用注意事項

雖然ThreadLocal幫我們考慮了記憶體洩漏的問題,為我們加上了一些防護措施。但是在實際使用中,我們還是需要注意避免以下兩種情況,下述兩種情況仍然有可能會導致記憶體洩漏。

避免使用static的ThreadLocal

使用static修飾的ThreadLocal,延長了ThreadLocal的生命週期,可能導致的記憶體洩漏。具體原因是在Java虛擬機器在載入類的過程中為靜態變數分配記憶體。static變數的生命週期取決於類的生命週期,也就是說類被解除安裝時,靜態變數才會被銷燬並釋放記憶體空間。而類的生命週期結束與下面三個條件相關。

  1. 該類所有的例項都已經被回收,也就是 Java 堆中不存在該類的任何例項。
  2. 載入該類的ClassLoader已經被回收。
  3. 該類對應的java.lang.Class物件沒有任何地方被引用,沒有在任何地方通過反射訪問該類的方法。

分配使用了ThreadLocal又不再呼叫get(),set(),remove()方法

其實理解起來也很簡單,就是第一次呼叫了ThreadLocal設定資料後,就不在呼叫get()、set()、remove()方法。也就是說現在ThreadLocalMap中就只有一條資料。那麼如果呼叫ThreadLocal的執行緒一直不結束的話,即使ThreadLocal已經被置為null(被GC回收),也一直存在一條強引用鏈:Thread Ref(當前執行緒引用) -> Thread -> ThreadLocalMap -> Entry -> value,導致資料無法回收,造成記憶體洩漏。

總結

  • ThreadLocal本質是操作執行緒中ThreadLocalMap來實現本地執行緒變數的儲存的
  • ThreadLocalMap是採用陣列的方式來儲存資料,其中key(弱引用)指向當前ThreadLocal物件,value為設的值
  • ThreadLocal為記憶體洩漏採取了處理措施,在呼叫ThreadLocal的get(),set(),remove()方法的時候都會清除執行緒ThreadLocalMap裡所有key為null的Entry
  • 在使用ThreadLocal的時候,我們仍然需要注意,避免使用static的ThreadLocal,分配使用了ThreadLocal後,一定要根據當前執行緒的生命週期來判斷是否需要手動的去清理ThreadLocalMap中清key==null的Entry。

相關文章