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

引言

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 的關係

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爲除數,進行除留餘數法:

開放地址法

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

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


計算key = 15時,發現f(15) = 5,此時就與5所在的位置衝突。

於是我們應用上面的公式f(15) = (f(15)+1) mod 10 =6。於是將15存入下標爲6的位置。這其實就是房子被人買了於是買下一間的作法:

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

開放地址法:

  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):


這時候來了一個新的數據,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,交換後的圖是這樣的

爲什麼要交換

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


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


你看,這樣整個數組就存在兩個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 方法之前,數據結構是這樣的

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

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

這個時候會把index=3和index = 6 都會進入被設置爲null,變成以下的數據結構


接下來我們會遍歷到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 爲空,如下圖


其實目的跟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);
        }


爲了解釋,上圖中的 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」,不定期分享原創知識,原創不易,請多支持(裏面還提供刷題小程序哦)。

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章