被大廠面試官連環炮轟炸的ThreadLocal (吃透源碼的細節及原理)

01 引言

ThreadLocal 是面試過程中非常高頻的一個類,這類的複雜程度絕對是可以帶出一系列連環炮的面試轟炸。biu biu biu ~~~~.

被大廠面試官連環炮轟炸的ThreadLocal (吃透源碼的細節及原理)

一直覺得自己對這個類很瞭解了,但是直到去看源碼,接二連三的技術浮出水面(弱引用,避免內存溢出的操作,開放地址法解決hash 衝突,各種內部類的複雜的關係),看到你懷疑人生,直到根據代碼一步一步的畫圖才最終理解(所以本篇文章會有大量的圖)。 這裡也給大家一個啟示,面對複雜的事情的時候,實在被問題繞暈了,就畫圖吧,藉助圖可以讓問題可視化,便於理解。

02 WAHT

ThreadLocal 是一個線程的本地變量,也就意味著這個變量是線程獨有的,是不能與其他線程共享的,這樣就可以避免資源競爭帶來的多線程的問題,這種解決多線程的安全問題和lock(這裡的lock 指通過synchronized 或者Lock 等實現的鎖) 是有本質的區別的:

  1. lock 的資源是多個線程共享的,所以訪問的時候需要加鎖。
  2. ThreadLocal 是每個線程都有一個副本,是不需要加鎖的。
  3. lock 是通過時間換空間的做法。
  4. ThreadLocal 是典型的通過空間換時間的做法。

當然他們的使用場景也是不同的,關鍵看你的資源是需要多線程之間共享的還是單線程內部共享的

03 使用

ThreadLocal 的使用是非常簡單的,看下面的代碼

public class Test {
public static void main(String[] args) {
ThreadLocal<string> local = new ThreadLocal<>();
//設置值
local.set("hello word");
//獲取剛剛設置的值
System.out.println(local.get());
}
}

/<string>

看到這裡是不是覺得特別簡單?別高興太早,點進去代碼看看,你絕對會懷疑人生

04 源碼分析

在分析源碼之前先畫一下ThreadLocal ,ThreadLocalMap 和Thread 的關係,如果你對他們的關係還不瞭解的話,請看我的另一篇文章BAT面試必考:ThreadLocal ,ThreadLocalMap 和Thread 的關係

被大廠面試官連環炮轟炸的ThreadLocal (吃透源碼的細節及原理)

4.1 set 方法

 public void set(T value) {
Thread t = Thread .currentThread();
// 獲取線程綁定的ThreadLocalMap
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
//第一次設置值的時候進來是這裡
createMap(t, value);
}

createMap 方法只是在第一次設置值的時候創建一個ThreadLocalMap 賦值給Thread 對象的threadLocals 屬性進行綁定,以後就可以直接通過這個屬性獲取到值了。從這裡可以看出,為什麼說ThreadLocal 是線程本地變量來的了

 void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}

值真正是放在ThreadLocalMap 中存取的,ThreadLocalMap 內部類有一個Entry 類,key是ThreadLocal 對象,value 就是你要存放的值,上面的代碼value 存放的就是hello word。ThreadLocalMap 和HashMap的功能類似,但是實現上卻有很大的不同:

  • HashMap 的數據結構是數組+鏈表
  • ThreadLocalMap的數據結構僅僅是數組
  • HashMap 是通過鏈地址法解決hash 衝突的問題
  • ThreadLocalMap 是通過開放地址法來解決hash 衝突的問題
  • HashMap 裡面的Entry 內部類的引用都是強引用
  • ThreadLocalMap裡面的Entry 內部類中的key 是弱引用,value 是強引用

4.2 為什麼ThreadLocalMap 採用開放地址法來解決哈希衝突?

jdk 中大多數的類都是採用了鏈地址法來解決hash 衝突,為什麼ThreadLocalMap 採用開放地址法來解決哈希衝突呢?首先我們來看看這兩種不同的方式

(1)鏈地址法

這種方法的基本思想是將所有哈希地址為i的元素構成一個稱為同義詞鏈的單鏈表,並將單鏈表的頭指針存在哈希表的第i個單元中,因而查找、插入和刪除主要在同義詞鏈中進行。列如對於關鍵字集合{12,67,56,16,25,37, 22,29,15,47,48,34},我們用前面同樣的12為除數,進行除留餘數法:

被大廠面試官連環炮轟炸的ThreadLocal (吃透源碼的細節及原理)

(2)開放地址法

這種方法的基本思想是一旦發生了衝突,就去尋找下一個空的散列地址(這非常重要,源碼都是根據這個特性,必須理解這裡才能往下走),只要散列表足夠大,空的散列地址總能找到,並將記錄存入。

比如說,我們的關鍵字集合為{12,33,4,5,15,25},表長為10。 我們用散列函數f(key) = key mod l0。 當計算前S個數{12,33,4,5}時,都是沒有衝突的散列地址,直接存入(藍色代表為空的,可以存放數據):

被大廠面試官連環炮轟炸的ThreadLocal (吃透源碼的細節及原理)

計算key = 15時,發現f(15) = 5,此時就與5所在的位置衝突。於是我們應用上面的公式f(15) = (f(15)+1) mod 10 =6。於是將15存入下標為6的位置。這其實就是房子被人買了於是買下一間的作法:

被大廠面試官連環炮轟炸的ThreadLocal (吃透源碼的細節及原理)

(3)鏈地址法和開放地址法的優缺點

開放地址法:

  • 容易產生堆積問題,不適於大規模的數據存儲。
  • 散列函數的設計對沖突會有很大的影響,插入時可能會出現多次衝突的現象。
  • 刪除的元素是多個衝突元素中的一個,需要對後面的元素作處理,實現較複雜。

鏈地址法:

  • 處理衝突簡單,且無堆積現象,平均查找長度短。
  • 鏈表中的結點是動態申請的,適合構造表不能確定長度的情況。
  • 刪除結點的操作易於實現。只要簡單地刪去鏈表上相應的結點即可。
  • 指針需要額外的空間,故當結點規模較小時,開放定址法較為節省空間。

(4)ThreadLocalMap 採用開放地址法原因

  • ThreadLocal 中看到一個屬性 HASH_INCREMENT = 0x61c88647 ,0x61c88647 是一個神奇的數字,讓哈希碼能均勻的分佈在2的N次方的數組裡, 即 Entry[] table,關於這個神奇的數字google 有很多解析,這裡就不重複說了
  • ThreadLocal 往往存放的數據量不會特別大(而且key 是弱引用又會被垃圾回收,及時讓數據量更小),這個時候開放地址法簡單的結構會顯得更省空間,同時數組的查詢效率也是非常高,加上第一點的保障,衝突概率也低

4.3 弱引用

如果對弱引用不了解的同學,先看下我之前的寫的一篇文章別再找了,一文徹底解析Java 中的弱引用(參考官網)系

接下來我們看看ThreadLocalMap 中的存放數據的內部類Entry 的實現源碼

 static class Entry extends WeakReference<threadlocal>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal> k, Object v) {
super(k);
value = v;
}
}

/<threadlocal>

我們可以知道Entry 的key 是一個弱引用,也就意味這可能會被垃圾回收器回收掉

threadLocal.get()==null

也就意味著被回收掉了

4.4 ThreadLocalMap set 方法

 private void set(ThreadLocal> key, Object value) {
Entry[] tab = table;
int len = tab.length;
//計算數組的下標
int i = key.threadLocalHashCode & (len-1);
//注意這裡結束循環的條件是e != //null,這個很重要,還記得上面講的開放地址法嗎?忘記的回到上面看下,一定看懂才往下走,不然白白浪費時間
//這裡遍歷的邏輯是,先通過hash 找到數組下標,然後尋找相等的ThreadLocal對象
//找不到就往下一個index找,有兩種可能會退出這個循環
// 1.找到了相同ThreadLocal對象
// 2.一直往數組下一個下標查詢,直到下一個下標對應的是null 跳出
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal> k = e.get();
//如果找到直接設置value 值返回,這個很簡單沒什麼好講的
if (k == key) {
e.value = value;
return;
}
// k==null&&e!=null 說明key被垃圾回收了,這裡涉及到弱引用,接下來講
if (k == null) {
//被回收的話就需要替換掉過期過期的值,把新的值放在這裡返回
replaceStaleEntry(key, value, i);

return;
}
}
//來到這裡,說明沒找到
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
//進行擴容,這裡先不講
rehash();
}

還是拿上面解釋開放地址法解釋的例子來說明下。 比如說,我們的關鍵字集合為{12,33,4,5,15,25},表長為10。 我們用散列函數f(key) = key mod l0。 當計算前S個數{12,33,4,5,15,25}時,並且此時key=33,k=5 已經過期了(藍色代表為空的,可以存放數據,紅色代表key 過期,過期的key為null):

被大廠面試官連環炮轟炸的ThreadLocal (吃透源碼的細節及原理)

這時候來了一個新的數據,key=15,value=new,通過計算f(15)=5,此時5已經過期,進入到下面這個if 語句 if (k == null) {
//key 過期了,要進行替換
replaceStaleEntry(key, value, i);
return;
}

(1)replaceStaleEntry 這個方法


private void replaceStaleEntry(ThreadLocal> key, Object value,
int staleSlot) {
Entry[] tab = table;
int len = tab.length;
Entry e;
//這裡採用的是從當前的staleSlot 位置向前面遍歷,i--
//這樣的話是為了把前面所有的的已經被垃圾回收的也一起釋放空間出來
//(注意這裡只是key 被回收,value還沒被回收,entry更加沒回收,所以需要讓他們回收),
//同時也避免這樣存在很多過期的對象的佔用,導致這個時候剛好來了一個新的元素達到閥值而觸發一次新的rehash
int slotToExpunge = staleSlot;
for (int i = prevIndex(staleSlot, len);
(e = tab[i]) != null;
i = prevIndex(i, len))
//slotToExpunge 記錄staleSlot左手邊第一個空的entry 到staleSlot 之間key過期最小的index
if (e.get() == null)
slotToExpunge = i;

// 這個時候是從數組下標小的往下標大的方向遍歷,i++,剛好跟上面相反。
//這兩個遍歷就是為了在左邊遇到的第一個空的entry到右邊遇到的第一空的 entry之間查詢所有過期的對象。
//注意:在右邊如果找到需要設置值的key(這個例子是key=15)相同的時候就開始清理,然後返回,不再繼續遍歷下去了
for (int i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal> k = e.get();
//說明之前已經存在相同的key,所以需要替換舊的值並且和前面那個過期的對象的進行交換位置,
//交換的目的下面會解釋
if (k == key) {
e.value = value;
tab[i] = tab[staleSlot];
tab[staleSlot] = e;
//這裡的意思就是前面的第一個for 循環(i--)往前查找的時候沒有找到過期的,只有staleSlot
// 這個過期,由於前面過期的對象已經通過交換位置的方式放到index=i上了,
// 所以需要清理的位置是i,而不是傳過來的staleSlot
if (slotToExpunge == staleSlot)
slotToExpunge = i;
//進行清理過期數據
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
}
// 如果我們在第一個for 循環(i--) 向前遍歷的時候沒有找到任何過期的對象

// 那麼我們需要把slotToExpunge 設置為向後遍歷(i++) 的第一個過期對象的位置
// 因為如果整個數組都沒有找到要設置的key 的時候,該key 會設置在該staleSlot的位置上
//如果數組中存在要設置的key,那麼上面也會通過交換位置的時候把有效值移到staleSlot位置上
//綜上所述,staleSlot位置上不管怎麼樣,存放的都是有效的值,所以不需要清理的
if (k == null && slotToExpunge == staleSlot)
slotToExpunge = i;
}
// 如果key 在數組中沒有存在,那麼直接新建一個新的放進去就可以
tab[staleSlot].value = null;
tab[staleSlot] = new Entry(key, value);
// 如果有其他已經過期的對象,那麼需要清理他
if (slotToExpunge != staleSlot)
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}

第一個for 循環是向前遍歷數據的,直到遍歷到空的entry 就停止(這個是根據開放地址的線性探測法),這裡的例子就是遍歷到index=1就停止了。向前遍歷的過程同時會找出過期的key,這個時候找到的是下標index=3 的為過期,進入到

 if (e.get() == null)
slotToExpunge = i;

注意此時slotToExpunge=3,staleSlot=5

第二個for 循環是從index=staleSlot開始,向後編列的,找出是否有和當前匹配的key,有的話進行清理過期的對象和重新設置當前的值。這個例子遍歷到index=6 的時候,匹配到key=15的值,進入如下代碼

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

先進行數據交換,注意此時slotToExpunge=3,staleSlot=5,i=6。這裡就是把5 和6 的位置的元素進行交換,並且設置新的value=new,交換後的圖是這樣的

被大廠面試官連環炮轟炸的ThreadLocal (吃透源碼的細節及原理)

(2)為什麼要交換

這裡解釋下為什麼交換,我們先來看看如果不交換的話,經過設置值和清理過期對象,會是以下這張圖

被大廠面試官連環炮轟炸的ThreadLocal (吃透源碼的細節及原理)

這個時候如果我們再一次設置一個key=15,value=new2 的值,通過f(15)=5,這個時候由於上次index=5是過期對象,被清空了,所以可以存在數據,那麼就直接存放在這裡了

被大廠面試官連環炮轟炸的ThreadLocal (吃透源碼的細節及原理)

你看,這樣整個數組就存在兩個key=15 的數據了,這樣是不允許的,所以一定要交換數

4.5 expungeStaleEntry


private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
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) {
//這裡設置為null ,方便讓GC 回收
e.value = null;
tab[i] = null;
size--;
} else {
//這裡主要的作用是由於採用了開放地址法,所以刪除的元素是多個衝突元素中的一個,需要對後面的元素作
//處理,可以簡單理解就是讓後面的元素往前面移動
//為什麼要這樣做呢?主要是開放地址尋找元素的時候,遇到null 就停止尋找了,你前面k==null
//的時候已經設置entry為null了,不移動的話,那麼後面的元素就永遠訪問不了了,下面會畫圖進行解釋說明

int h = k.threadLocalHashCode & (len - 1);
//他們不相等,說明是經過hash 是有衝突的
if (h != i) {
tab[i] = null;
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
}
}
return i;
}

接下來我們詳細模擬下整個過程 根據我們的例子,key=5,15,25 都是衝突的,並且k=5的值已經過期,經過replaceStaleEntry 方法,在進入expungeStaleEntry 方法之前,數據結構是這樣的

被大廠面試官連環炮轟炸的ThreadLocal (吃透源碼的細節及原理)

此時傳進來的參數staleSlot=6,

 if (k == null) {
//這裡設置為null ,方便讓GC 回收
e.value = null;
tab[i] = null;
size--;
}

這個時候會把index=6設置為null,數據結構變成下面的情況

被大廠面試官連環炮轟炸的ThreadLocal (吃透源碼的細節及原理)

接下來我們會遍歷到i=7,經過int h = k.threadLocalHashCode & (len - 1) (實際上對應我們的舉例的函數int h= f(25)); 得到的h=5,而25實際存放在index=7 的位置上,這個時候我們需要從h=5的位置上重新開始編列,直到遇到空的entry 為止 int h = k.threadLocalHashCode & (len - 1);
if (h != i) {
tab[i] = null;
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}

這個時候h=6,並把k=25 的值移到index=6 的位置上,同時設置index=7 為空,如下圖

被大廠面試官連環炮轟炸的ThreadLocal (吃透源碼的細節及原理)

其實目的跟replaceStaleEntry 交換位置的原理是一樣的,為了防止由於回收掉中間那個衝突的值,導致後面衝突的值沒辦法找到(因為e==null 就跳出循環了)

4.6 ThreadLocal 內存溢出問題

通過上面的分析,我們知道expungeStaleEntry() 方法是幫助垃圾回收的,根據源碼,我們可以發現 get 和set 方法都可能觸發清理方法expungeStaleEntry(),所以正常情況下是不會有內存溢出的 但是如果我們沒有調用get 和set 的時候就會可能面臨著內存溢出,養成好習慣不再使用的時候調用remove(),加快垃圾回收,避免內存溢出

退一步說,就算我們沒有調用get 和set 和remove 方法,線程結束的時候,也就沒有強引用再指向ThreadLocal 中的ThreadLocalMap了,這樣ThreadLocalMap 和裡面的元素也會被回收掉,但是有一種危險是,如果線程是線程池的, 在線程執行完代碼的時候並沒有結束,只是歸還給線程池,這個時候ThreadLocalMap 和裡面的元素是不會回收掉

最後讀者福利~

分享一波自己整理的面試真題(含詳細解析),轉發+私信關鍵詞【面試】即可免費領取~

被大廠面試官連環炮轟炸的ThreadLocal (吃透源碼的細節及原理)

數據庫

被大廠面試官連環炮轟炸的ThreadLocal (吃透源碼的細節及原理)

架構專題+學習書籍資料


分享到:


相關文章: