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

引言

ThreadLocal 是面試過程當中很是高頻的一個類,這類的複雜程度絕對是能夠帶出一系列連環炮的面試轟炸。biu biu biu ~~~~.java

一直以爲本身對這個類很瞭解了,可是直到去看源碼,連續不斷的技術浮出水面(弱引用,避免內存溢出的操做,開放地址法解決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());
    }
}


複製代碼

看到這裏是否是以爲特別簡單?別高興太早,點進去代碼看看,你絕對會懷疑人生bash

源碼分析

在分析源碼以前先畫一下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實現一個先進先出的不可重入鎖?吊炸天了

相關文章
相關標籤/搜索