- 在
趣鏈
的面試中被問到ThreadLocal
的相關問題,被問的一臉懵*,所以有次總結.
ThreadLocal
執行緒區域性變數
是我一直對他的叫法,剛開始接觸是用來儲存jdbc
的連線(這樣想想我接觸的還挺早的)- 作用是為每個執行緒儲存執行緒私有的變數.以空間換時間,也能保證資料的安全性.
ThreadLocal
並不是底層的集合類,而是一個工具類,所有的執行緒私有資料都被儲存在各個Thread
物件中一個叫做threadLocals
的ThreadLocalMap
的成員變數裡,ThreadLocal
也只是操作這些變數的工具類.- 也就是說每個
Thread
都會存有一個ThreadLocalMap
的物件供多個ThreadLocal
的類呼叫,所以你可以發現多個ThreadLocal
操作的Map
會是同一個,而當ThreadLocal
作為key
的發生雜湊碰撞時,會從當前位置開始向後環型遍歷,找到一個空位置,這方法我們可以稱之為線性探測法.
ThreadLocalMap
ThreadLocalMap
出人意料的並沒有繼承任何一個類或介面,是完全獨立的類。
成員變數
// 預設的初始容量 一定要是二的次冪
private static final int INITIAL_CAPACITY = 16;
// 元素陣列/條目陣列
private Entry[] table;
// 大小,用於記錄陣列中實際存在的Entry數目
private int size = 0;
// 閾值
private int threshold; // Default to 0 構造方法
複製程式碼
構造方法
// 預設訪問許可權的初始化方法
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
// 使用預設的`容量`初始化陣列
table = new Entry[INITIAL_CAPACITY];
// 以`ThreadLocal`的`HashCode`計算下標
// 這裡和HashMap中的計算方式一樣,都用與運算
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
// 賦值 修改大小並計算閾值
table[i] = new Entry(firstKey, firstValue);
size = 1;
// `setThreshold`方法也特別簡單,就是2/3的容量。
setThreshold(INITIAL_CAPACITY);
}
複製程式碼
元素獲取相關方法
getEntry
-
以
ThreadLocal
為Key
獲取對應的Entry
。 -
因為
ThreadLocalMap
底層也是使用陣列作為資料結構,所以該方法也借鑑了HashMap
中求元素下標的方式. -
在獲取的元素為空的時候還會呼叫
getEntryAfterMiss
做後續處理.
private Entry getEntry(ThreadLocal<?> key) {
// 和HashMap中一樣的下標計算方式
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
// 獲取到對應的Entry之後就分兩步
if (e != null && e.get() == key)
// 1. e不為空且threadLocal相等
return e;
else
// 2. e為空或者threadLocal不相等
return getEntryAfterMiss(key, i, e);
}
複製程式碼
getEntryAfterMiss
- 該方法是在直接按照
Hash
計算下標後,沒獲取到對應的Entry
物件的時候呼叫。 - 通過遍歷整個陣列的方式獲取相同
key
表示的Entry
物件。
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;
// 此時注意如果從上面情況`2.`進來時,
// e為空則直接返回null,不會進入while迴圈
// 只有e不為空且e.get() != key時才會進while迴圈
while (e != null) {
ThreadLocal<?> k = e.get();
// 找到相同的k,返回得到的Entry,get操作結束
if (k == key)
return e;
// 若此時的k為空,那麼e則被標記為`Stale`需要被`expunge`
if (k == null)
expungeStaleEntry(i);
else // 下面兩個都是遍歷的相關操作
i = nextIndex(i, len);
e = tab[i];
}
return null;
}
複製程式碼
expungeStaleEntry
- 該方法用來清除
staleSlot
位置的Entry物件,並且會清理當前節點到下一個null
節點中間的過期Enyru
. - 是消除
記憶體洩漏
威脅的主力方法,在整個ThreadLocalMap
中會多次呼叫.
/**
* 清空舊的Entry物件
* @param staleSlot: 清理的起始位置
* @param return: 返回的是第一個為空的Entry下標
*/
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
// 清空`staleSlot`位置的Entry
// value引用置為空之後,物件被標記為不可達,下次GC就會被回收.
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;
Entry e;
int i;
// 通過nextIndex從`staleSlot`的下一個開始向後遍歷Entry陣列,直到e不為空
// e賦值為當前的Entry物件
for (i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
// 當k為空的時候清空節點資訊
if (k == null) {
e.value = null;
tab[i] = null;
size--;
} else { // 以下為k存在的情況
int h = k.threadLocalHashCode & (len - 1);
// 元素下標和key計算的不一樣,表明是出現`Hash碰撞`之後調整的位置
// 將當前的元素移動到下一個null位置
if (h != i) {
tab[i] = null;
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
}
}
return i;
}
複製程式碼
Set相關方法
set
- 因為
ThreadLocalMap
底層結構和HashMap
一樣也是陣列,也是通過hash
確定下標,也一樣會發生Hash碰撞
,我們知道在HashMap
中為了解決Hash碰撞
的問題選擇了拉鍊法,但對於ThreadLocalMap
並沒有那麼高的複雜度,所以此處選擇的是開放地址法
. - 從下方原始碼也可以看出來,
Entry
再確定陣列位置之後直接就開始了遍歷,如果key
不匹配就往後遍歷找到key
匹配的元素覆蓋,或者key == null
的替換.
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
// 整個迴圈的功能就是找到相同的key覆蓋value
// 或者找到key為null的節點覆蓋節點資訊
// 只有在e==null的時候跳出迴圈執行下面的程式碼
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
// 找到相等的k,則直接替換value,set操作結束
if (k == key) {
e.value = value;
return;
}
// k為空表示該節點過期,直接替換該節點
if (k == null) { // 1.
replaceStaleEntry(key, value, i);
return;
}
}
// 走到這一步就是找到了e為空的位置,不然在上面兩個判斷裡都return了
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
複製程式碼
replaceStaleEntry
- 原始碼中只有從上面
1.
處進入該方法,用於替換key
為空的Entry
節點,順帶清除陣列中的過期節點.
/**
* 從`set.1.`處進入,key是插入元素ThreadLocal的hash,staleSlot為key為空的陣列節點下標
*/
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
int staleSlot) {
Entry[] tab = table;
int len = tab.length;
Entry e;
int slotToExpunge = staleSlot;
// 從傳入位置,即插入時發現k為null的位置開始,向前遍歷,直到陣列元素為空
// 找到最前面一個key為null的值.
// 這裡要吐槽一下原始碼...大括號都不加 習慣真差
for (int i = prevIndex(staleSlot, len);
(e = tab[i]) != null;
i = prevIndex(i, len)){
if (e.get() == null)
// 因為是環狀遍歷所以此時slotToExpunge是可能等於staleSlot的
slotToExpunge = i;
}
// 該段迴圈的功能就是向後遍歷找到`key`相等的節點並替換
// 並對之後的元素進行清理
for (int i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
if (k == key) {
// e就是tab[i],所以下三行程式碼的功能就是替換Entry
// 新的Entry實際還是在staleSlot下標的位置
e.value = value;
tab[i] = tab[staleSlot];
tab[staleSlot] = e;
// 因為接下來要進行清理操作,所以此處需要重新確定清理的起點.
if (slotToExpunge == staleSlot)
slotToExpunge = i;
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
}
// 其實我對這個`slotToExpunge == staleSlot`的判斷一直挺疑惑的,為什麼需要這個判斷?
if (k == null && slotToExpunge == staleSlot)
slotToExpunge = i;
}
// e==null時跳到下面程式碼執行
// 清空並重新賦值
tab[staleSlot].value = null;
tab[staleSlot] = new Entry(key, value);
// set後的清理
if (slotToExpunge != staleSlot)
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}
複製程式碼
cleanSomeSlots
- 該方法的功能是就是清除陣列中的過期
Entry
- 首次清除從
i
向後開始遍歷log2(n)
次,如果之間發現過期Entry
會直接將n
擴充到len
可以說全陣列範圍的遍歷.發現過期Entry
就呼叫expungeStaleEntry
清除直到未發現Entry
為止.
/**
* @param i 清除的起始節點位置
* @param n 遍歷控制,每次掃描都是log2(n)次,一般取當前陣列的`size`或`len`
*/
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) {
// 當發現有過期`Entry`時,n變為len
// 即擴大範圍,全陣列範圍在遍歷一次
n = len;
removed = true;
i = expungeStaleEntry(i);
}
// 無符號右移一位相當於n = n /2
// 所以在第一次會遍歷`log2(n)`次
} while ( (n >>>= 1) != 0);
// 遍歷過程中沒出現過期`Entry`的情況下會返回是否有清理的標記.
return removed;
}
複製程式碼
擴容調整方法
rehash
- 容量調整的先驅方法,先清理過期
Entry
,並做是否需要resize
的判斷 - 調整的條件是當前size大於閾值的3/4就進行擴容
private void rehash() {
// 清理過期Entry
expungeStaleEntries();
// 初始閾值threshold為10
if (size >= threshold - threshold / 4)
resize();
}
複製程式碼
resize
- 擴容的實際方法.
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();
// k為空即表示為過期節點,當即清理了.
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;
}
複製程式碼
ThreadLocal
的內部方法因為邏輯都不復雜,不需要單獨出來看,就直接全放一塊了.
Get方法
- 整個獲取的過程其實並不難,所以我說
ThreadLocal
的精華只要還是在TheradLocalMap
和這種空間換時間的結構.- 首先通過
getMap
方法獲取當前執行緒繫結的threadLocals
- 不要為空時,以當前
ThreadLocal
物件為引數獲取對應的Entry
物件.為空跳到第四步 - 獲取
Entry
物件中的value
,並返回 - 呼叫
setInitialValue
方法,
- 首先通過
// 直接獲取執行緒私有的資料
public T get() {
// 獲取當前執行緒
Thread t = Thread.currentThread();
// getMap其實很簡單就是獲取`t`中的`threadLocals`,程式碼在`工具方法`中
ThreadLocalMap map = getMap(t);
if (map != null) { // 3.
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) { // 2.
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue(); // 1.
}
// 這個方法只有在上面`1.`處呼叫...不知道為什麼map,thread不直接傳參
// 該方法的功能就是為`Thread`設定`threadLocals`的初始值
private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
// map不為null表明是從上面的`2.`處進入該方法
// 已經初始化`threadLocals`,但並未找到當前對應的`Entry`
// 所以此時直接新增`Entry`就行
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}
// 初始值,`protected`方便子類繼承,並定義自己的初始值.
protected T initialValue() {
return null;
}
// 建立並賦值`threadLocals`的方法
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
複製程式碼
Set方法
- 獲取當前執行緒,並以此獲取執行緒繫結的
ThreadLocalMap
物件. map
不為空時,直接set就好map
為空時需要先建立並賦值.
public void set(T value) {
// 獲取當前執行緒
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t); // .1
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
複製程式碼
工具方法
getMap(Thraed t)
- 獲取
t
中保留的ThreadLocalMap
型別的物件
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
複製程式碼
ThreadLocal相關問題
ThreadLocal的記憶體洩漏問題
記憶體洩漏的原因
-
首先對於對作為
key
的ThreadLocal
物件,因為是弱引用我們完全不用擔心,強引用斷開之後自然會被GC
回收. -
再來看
value
,按照上面所說的作為成員變數儲存在每個Thread
例項的threadLocals
才是儲存資料的物件,那麼它的生命週期是和Thread
相同的,即使將ThreadLocal
被GC
回收, 但對應的value
物件仍然存在thread -> threadLocals -> value引用 -> value物件
的引用關係,所以GC
會認為它可達,並不會做回收處理,但在我們現有的程式碼中並沒有能夠跳過key
去獲取value
的,也就是說實際上value
已經不可達了.這樣就造成了記憶體洩漏.
記憶體洩漏的處理方法
- 究其根本還是斷開
value
的引用關係,就是講value
引用置null
. - 可以看到
ThreadLcoalMap
的方法多處呼叫了expungeStaleEntry
,cleanSomeSlots
檢查陣列中的Entry
物件是否過期,也就是key
是否為空.
ThreadLocal的併發性問題
- 首先
併發問題
在我理解中就是多執行緒情況下對共享資源的合理使用,像是ReentrantLock
,Synchronized
都是幫我們解決共享資源的使用問題. ThreadLocal
則幫我們提供了另外一種思路,就是在每一個執行緒中保留副本,就是上文有提到的以空間換時間的形式保證資源的合理有序使用,所以我覺得也是解決併發問題的一種思路.
- 第一次在掘金髮文章。。也是第一次在網上發文章 有點小緊張
- 這是我的個人部落格CheNbXxx,目前只有一些原始碼的閱讀筆記,和讀書筆記.
- 有空會優化一下一些筆記格式再上傳,感謝瀏覽.