該文章屬於《Android Handler機制之》系列文章,如果想了解更多,請點選 《Android Handler機制之總目錄》
前言
要想了解Android 的Handle機制,我們首先要了解ThreadLocal,根據字面意思我們都能猜出個大概。就是執行緒本地變數。那麼我們把變數儲存在本地有什麼好處呢?其中的原理又是什麼呢?下面我們就一起來討論一下ThreadLocal的使用與原理。
ThreadLocal簡單介紹
該類提供執行緒區域性變數。這些變數不同於它們的正常變數,即每一個執行緒訪問自身的區域性變數時,都有它自己的,獨立初始化的副本。該變數通常是與執行緒關聯的私有靜態欄位,列如用於ID或事物ID。大家看了介紹後,有可能還是不瞭解其主要的主要作用,簡單的畫個圖幫助大家理解。
從圖上可以看出,通過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的使用都涉及到執行緒中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值相同的情況,ThreadLocal內部操作是直接覆蓋的。這種情況就不過多的介紹了。
第二種情況,如果當前位置對應Entry的Key值為null
第二種情況相對來說比較複雜,這裡先給圖,然後會根據具體程式碼來講解。
從圖中我們可以看出來。當我們新增新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);
}
複製程式碼
上面程式碼看起來比較繁雜,但是大家仔細梳理就會發現其實該方法,主要對四種情況進行了判斷,具體情況如下圖表所示:
我們已經瞭解了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
圖上為了方便大家,理解清空上下資料的情況,我並沒有重新計算位置(希望大家注意!!!)
看到這裡,為了方便大家避免不必要的查閱程式碼,我直接將程式碼貼出來了。程式碼如下。
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變數的生命週期取決於類的生命週期,也就是說類被解除安裝時,靜態變數才會被銷燬並釋放記憶體空間。而類的生命週期結束與下面三個條件相關。
- 該類所有的例項都已經被回收,也就是 Java 堆中不存在該類的任何例項。
- 載入該類的ClassLoader已經被回收。
- 該類對應的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。