被大廠面試官連環炮轟炸的ThreadLocal (吃透原始碼的每一個細節和設計原理)

面試君發表於2019-10-21

引言

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

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

WHAT

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

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

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

使用

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

public class Test {

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


複製程式碼

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

原始碼分析

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

被大廠面試官連環炮轟炸的ThreadLocal (吃透原始碼的每一個細節和設計原理)

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的功能類似,但是實現上卻有很大的不同:

  1. HashMap 的資料結構是陣列+連結串列
  2. ThreadLocalMap的資料結構僅僅是陣列
  3. HashMap 是通過鏈地址法解決hash 衝突的問題
  4. ThreadLocalMap 是通過開放地址法來解決hash 衝突的問題
  5. HashMap 裡面的Entry 內部類的引用都是強引用
  6. ThreadLocalMap裡面的Entry 內部類中的key 是弱引用,value 是強引用

為什麼ThreadLocalMap 採用開放地址法來解決雜湊衝突?

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

鏈地址法

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

被大廠面試官連環炮轟炸的ThreadLocal (吃透原始碼的每一個細節和設計原理)

開放地址法

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

比如說,我們的關鍵字集合為{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 (吃透原始碼的每一個細節和設計原理)

鏈地址法和開放地址法的優缺點

開放地址法:

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

鏈地址法:

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

ThreadLocalMap 採用開放地址法原因

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

弱引用

如果對弱引用不了解的同學,先看下我之前的寫的一篇文章別再找了,一文徹底解析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;
            }
        }
複製程式碼

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

threadLocal.get()==null
複製程式碼

也就意味著被回收掉了

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

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 (吃透原始碼的每一個細節和設計原理)

為什麼要交換

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

被大廠面試官連環炮轟炸的ThreadLocal (吃透原始碼的每一個細節和設計原理)
這個時候如果我們再一次設定一個key=15,value=new2 的值,通過f(15)=5,這個時候由於上次index=5是過期物件,被清空了,所以可以存在資料,那麼就直接存放在這裡了

被大廠面試官連環炮轟炸的ThreadLocal (吃透原始碼的每一個細節和設計原理)
你看,這樣整個陣列就存在兩個key=15 的資料了,這樣是不允許的,所以一定要交換資料

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=3,

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

這個時候會把index=3和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 就跳出迴圈了)

cleanSomeSlots

回到上面那個replaceStaleEntry 方法中的以下程式碼片段

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

剛剛上面執行完expungeStaleEntry 後,會執行cleanSomeSlots 這個方法

//這個方法是從i 開始往後遍歷(i++),尋找過期物件進行清除操作
 private boolean cleanSomeSlots(int i, int n) {
            boolean removed = false;
            Entry[] tab = table;
            int len = tab.length;
            // 用do while 語法,保證 do 裡面的程式碼至少被執行一次
            do {
                i = nextIndex(i, len);
                Entry e = tab[i];
                
                if (e != null && e.get() == null) {
                //如果遇到過期物件的時候,重新賦值n=len 也就是當前陣列的長度
                    n = len;
                    removed = true;
                    //在一次呼叫expungeStaleEntry 來進行垃圾回收(只是幫助垃圾回收)
                    i = expungeStaleEntry(i);
                }
            } while ( (n >>>= 1) != 0);//無符號右移動一位,可以簡單理解為除以2
            return removed;
        }
複製程式碼

經過上面的分析expungeStaleEntry 返回的值i=7,傳進來的n 是陣列的長度n=10; 大家可以看到這個方法的迴圈結束條件是n>>>1!=0,也就是這個方法在沒有遇到過期物件的時候,會執行log2(n)的掃描。這裡沒有選擇掃描全部是為了效能的平衡。由於這裡的跳出迴圈的條件不是遇到空的entry 就停止,那麼空entry 後面的過期物件也有機會被清理掉(對應下圖的index=9,會被清除),注意下標在i 前面的的過期物件也有機會被清理掉,只要是因為如果n>>>1!=0 的情況,並且i 已經是最大值了,呼叫以下程式碼會從下標為0 開始編列,所以對應下圖的index=0 也會被清理掉

        private static int nextIndex(int i, int len) {
            return ((i + 1 < len) ? i + 1 : 0);
        }
複製程式碼

被大廠面試官連環炮轟炸的ThreadLocal (吃透原始碼的每一個細節和設計原理)
為了解釋,上圖中的 index=0 和index =9 這個過期物件資料是臨時加上去了,前面的分析沒有這個物件,大家不要感到太唐突(之前沒計劃講這一塊,所以前面畫圖的時候沒加上,這小節是臨時加上為了解答評論區中網友Mr奎 的問題 (這個整理的話每次是隻整理一段區域的物件麼,如果陣列的結構呈現前中後三塊區域的話,每次set()和get()的元素計算後都落在了中間區域,是不是前後的元素都不會被清理到啊?),再次感謝這位網友讓我有機會是完善這篇文章)

ThreadLocal 記憶體溢位問題:

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

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

看完兩件事

如果你覺得這篇內容對你挺有啟發,我想邀請你幫我2個小忙:

  1. 點贊,讓更多的人也能看到這篇內容(收藏不點贊,都是耍流氓 -_-)
  2. 關注公眾號「面試bat」,不定期分享原創知識,原創不易,請多支援(裡面還提供刷題小程式哦)。

被大廠面試官連環炮轟炸的ThreadLocal (吃透原始碼的每一個細節和設計原理)

下一篇文章 BAT面試官:你先手動用LockSupport實現一個先進先出的不可重入鎖?吊炸天了

相關文章