Java面試必問:ThreadLocal終極篇 淦!

敖丙發表於2020-07-27

點贊再看,養成習慣,微信搜一搜【敖丙】關注這個網際網路苟且偷生的程式設計師。

本文 GitHub https://github.com/JavaFamily 已收錄,有一線大廠面試完整考點、資料以及我的系列文章。

開場白

張三最近天氣很熱心情不是很好,所以他決定出去面試跟面試官聊聊天排解一下,結果剛投遞簡歷就有人約了面試。

我丟,什麼情況怎麼剛投遞出去就有人約我面試了?誒。。。真煩啊,哥已經不在江湖這麼久了,江湖還是有哥的傳說,我還是這麼搶手的麼?太煩惱了,帥無罪。

暗自竊喜的張三來到了某東現場面試的辦公室,我丟,這面試官?不是吧,這滿是劃痕的Mac,這髮量,難道就是傳說中的架構師?

張三的心態一下子就崩了,出來第一場面試就遇到一個頂級面試官,這誰頂得住啊。

你好,我是你的面試官Tony,看我的髮型應該你能猜到我的身份了,我也話不說,我們直接開始好不好?看你簡歷寫了多執行緒,來你跟我聊一下ThreadLocal吧,我很久沒寫程式碼不太熟悉了,你幫我回憶一下。

我丟?這TM是人話?這是什麼邏輯啊,說是問多執行緒然後一上來就來個這麼冷門的ThreadLocal?心態崩了呀,再說你TM自己忘了不知道下去看看書麼,來我這裡找答案是什麼鬼啊...

儘管十分不情願,但是張三還是高速運轉他的小腦袋,回憶起了ThreadLocal的種種細節...

面試官說實話我在實際開發過程中用到ThreadLocal的地方不是很多,我在寫這個文章的時候還刻意去把我電腦上幾十個專案開啟之後去全域性搜尋ThreadLocal發現除了系統原始碼的使用,很少在專案中用到,不過也還是有的。

ThreadLocal的作用主要是做資料隔離,填充的資料只屬於當前執行緒,變數的資料對別的執行緒而言是相對隔離的,在多執行緒環境下,如何防止自己的變數被其它執行緒篡改。

你能跟我說說它隔離有什麼用,會用在什麼場景麼?

這,我都說了我很少用了,還問我,難受了呀,哦哦哦,有了想起來了,事務隔離級別。

面試官你好,其實我第一時間想到的就是Spring實現事務隔離級別的原始碼,這還是當時我大學被女朋友甩了,一個人在圖書館哭泣的時候無意間發現的。

Spring採用Threadlocal的方式,來保證單個執行緒中的資料庫操作使用的是同一個資料庫連線,同時,採用這種方式可以使業務層使用事務時不需要感知並管理connection物件,通過傳播級別,巧妙地管理多個事務配置之間的切換,掛起和恢復。

Spring框架裡面就是用的ThreadLocal來實現這種隔離,主要是在TransactionSynchronizationManager這個類裡面,程式碼如下所示:

private static final Log logger = LogFactory.getLog(TransactionSynchronizationManager.class);

 private static final ThreadLocal<Map<Object, Object>> resources =
   new NamedThreadLocal<>("Transactional resources");

 private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations =
   new NamedThreadLocal<>("Transaction synchronizations");

 private static final ThreadLocal<String> currentTransactionName =
   new NamedThreadLocal<>("Current transaction name");

  ……

Spring的事務主要是ThreadLocal和AOP去做實現的,我這裡提一下,大家知道每個執行緒自己的連結是靠ThreadLocal儲存的就好了,繼續的細節我會在Spring章節細說的,暖麼?

除了原始碼裡面使用到ThreadLocal的場景,你自己有使用他的場景麼?一般你會怎麼用呢?

來了來了,加分項來了,這個我還真遇到過,裝B的機會終於來了。

有的有的面試官,這個我會!!!

之前我們上線後發現部分使用者的日期居然不對了,排查下來是SimpleDataFormat的鍋,當時我們使用SimpleDataFormat的parse()方法,內部有一個Calendar物件,呼叫SimpleDataFormat的parse()方法會先呼叫Calendar.clear(),然後呼叫Calendar.add(),如果一個執行緒先呼叫了add()然後另一個執行緒又呼叫了clear(),這時候parse()方法解析的時間就不對了。

其實要解決這個問題很簡單,讓每個執行緒都new 一個自己的 SimpleDataFormat就好了,但是1000個執行緒難道new1000個SimpleDataFormat

所以當時我們使用了執行緒池加上ThreadLocal包裝SimpleDataFormat,再呼叫initialValue讓每個執行緒有一個SimpleDataFormat的副本,從而解決了執行緒安全的問題,也提高了效能。

那……

還有還有,我還有,您彆著急問下一個,讓我再加點分,拖延一下面試時間。

我在專案中存在一個執行緒經常遇到橫跨若干方法呼叫,需要傳遞的物件,也就是上下文(Context),它是一種狀態,經常就是是使用者身份、任務資訊等,就會存在過渡傳參的問題。

使用到類似責任鏈模式,給每個方法增加一個context引數非常麻煩,而且有些時候,如果呼叫鏈有無法修改原始碼的第三方庫,物件引數就傳不進去了,所以我使用到了ThreadLocal去做了一下改造,這樣只需要在呼叫前在ThreadLocal中設定引數,其他地方get一下就好了。

before
  
void work(User user) 
{
    getInfo(user);
    checkInfo(user);
    setSomeThing(user);
    log(user);
}

then
  
void work(User user) 
{
try{
   threadLocalUser.set(user);
   // 他們內部  User u = threadLocalUser.get(); 就好了
    getInfo();
    checkInfo();
    setSomeThing();
    log();
    } finally {
     threadLocalUser.remove();
    }
}

我看了一下很多場景的cookie,session等資料隔離都是通過ThreadLocal去做實現的。

對了我面試官允許我再秀一下知識廣度,在Android中,Looper類就是利用了ThreadLocal的特性,保證每個執行緒只存在一個Looper物件。

static final ThreadLocal<Looper> sThreadLocal = new ThreadLocal<Looper>();
private static void prepare(boolean quitAllowed) {
    if (sThreadLocal.get() != null) {
        throw new RuntimeException("Only one Looper may be created per thread");
    }
    sThreadLocal.set(new Looper(quitAllowed));
}

面試官:我丟,這貨怎麼知道這麼多場景?還把Android都扯了出來,不是吧阿sir,下面我要考考他原理了。

嗯嗯,你回答得很好,那你能跟我說說他底層實現的原理麼?

好的面試官,我先說一下他的使用:

ThreadLocal<String> localName = new ThreadLocal();
localName.set("張三");
String name = localName.get();
localName.remove();

其實使用真的很簡單,執行緒進來之後初始化一個可以泛型的ThreadLocal物件,之後這個執行緒只要在remove之前去get,都能拿到之前set的值,注意這裡我說的是remove之前。

他是能做到執行緒間資料隔離的,所以別的執行緒使用get()方法是沒辦法拿到其他執行緒的值的,但是有辦法可以做到,我後面會說。

我們先看看他set的原始碼:

public void set(T value) {
    Thread t = Thread.currentThread();// 獲取當前執行緒
    ThreadLocalMap map = getMap(t);// 獲取ThreadLocalMap物件
    if (map != null// 校驗物件是否為空
        map.set(this, value); // 不為空set
    else
        createMap(t, value); // 為空建立一個map物件
}

大家可以發現set的原始碼很簡單,主要就是ThreadLocalMap我們需要關注一下,而ThreadLocalMap呢是當前執行緒Thread一個叫threadLocals的變數中獲取的。

ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }
public class Thread implements Runnable {
      ……

    /* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */

    ThreadLocal.ThreadLocalMap threadLocals = null;

    /*
     * InheritableThreadLocal values pertaining to this thread. This map is
     * maintained by the InheritableThreadLocal class.
     */

    ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
  
     ……

這裡我們基本上可以找到ThreadLocal資料隔離的真相了,每個執行緒Thread都維護了自己的threadLocals變數,所以在每個執行緒建立ThreadLocal的時候,實際上資料是存在自己執行緒Thread的threadLocals變數裡面的,別人沒辦法拿到,從而實現了隔離。

ThreadLocalMap底層結構是怎麼樣子的呢?

面試官這個問題問得好啊,內心暗罵,讓我歇一會不行麼?

張三笑著回答道,既然有個Map那他的資料結構其實是很像HashMap的,但是看原始碼可以發現,它並未實現Map介面,而且他的Entry是繼承WeakReference(弱引用)的,也沒有看到HashMap中的next,所以不存在連結串列了。

static class ThreadLocalMap {

        static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;

            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }
        ……
    }    

結構大概長這樣:

稍等,我有兩個疑問你可以解答一下麼?

好呀,面試官你說。

為什麼需要陣列呢?沒有了連結串列怎麼解決Hash衝突呢?

用陣列是因為,我們開發過程中可以一個執行緒可以有多個TreadLocal來存放不同型別的物件的,但是他們都將放到你當前執行緒的ThreadLocalMap裡,所以肯定要陣列來存。

至於Hash衝突,我們先看一下原始碼:

private void set(ThreadLocal<?> key, Object value) {
           Entry[] tab = table;
            int len = tab.length;
            int i = key.threadLocalHashCode & (len-1);
            for (Entry e = tab[i];
                 e != null;
                 e = tab[i = nextIndex(i, len)]) {
                ThreadLocal<?> k = e.get();

                if (k == key) {
                    e.value = value;
                    return;
                }
                if (k == null) {
                    replaceStaleEntry(key, value, i);
                    return;
                }
            }
            tab[i] = new Entry(key, value);
            int sz = ++size;
            if (!cleanSomeSlots(i, sz) && sz >= threshold)
                rehash();
        }

我從原始碼裡面看到ThreadLocalMap在儲存的時候會給每一個ThreadLocal物件一個threadLocalHashCode,在插入過程中,根據ThreadLocal物件的hash值,定位到table中的位置i,int i = key.threadLocalHashCode & (len-1)

然後會判斷一下:如果當前位置是空的,就初始化一個Entry物件放在位置i上;

if (k == null) {
    replaceStaleEntry(key, value, i);
    return;
}

如果位置i不為空,如果這個Entry物件的key正好是即將設定的key,那麼就重新整理Entry中的value;

if (k == key) {
    e.value = value;
    return;
}

如果位置i的不為空,而且key不等於entry,那就找下一個空位置,直到為空為止。

這樣的話,在get的時候,也會根據ThreadLocal物件的hash值,定位到table中的位置,然後判斷該位置Entry物件中的key是否和get的key一致,如果不一致,就判斷下一個位置,set和get如果衝突嚴重的話,效率還是很低的。

以下是get的原始碼,是不是就感覺很好懂了:

 private Entry getEntry(ThreadLocal<?> key) {
            int i = key.threadLocalHashCode & (table.length - 1);
            Entry e = table[i];
            if (e != null && e.get() == key)
                return e;
            else
                return getEntryAfterMiss(key, i, e);
        }

 private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
            Entry[] tab = table;
            int len = tab.length;
// get的時候一樣是根據ThreadLocal獲取到table的i值,然後查詢資料拿到後會對比key是否相等  if (e != null && e.get() == key)。
            while (e != null) {
                ThreadLocal<?> k = e.get();
              // 相等就直接返回,不相等就繼續查詢,找到相等位置。
                if (k == key)
                    return e;
                if (k == null)
                    expungeStaleEntry(i);
                else
                    i = nextIndex(i, len);
                e = tab[i];
            }
            return null;
        }

能跟我說一下物件存放在哪裡麼?

在Java中,棧記憶體歸屬於單個執行緒,每個執行緒都會有一個棧記憶體,其儲存的變數只能在其所屬執行緒中可見,即棧記憶體可以理解成執行緒的私有記憶體,而堆記憶體中的物件對所有執行緒可見,堆記憶體中的物件可以被所有執行緒訪問。

那麼是不是說ThreadLocal的例項以及其值存放在棧上呢?

其實不是的,因為ThreadLocal例項實際上也是被其建立的類持有(更頂端應該是被執行緒持有),而ThreadLocal的值其實也是被執行緒例項持有,它們都是位於堆上,只是通過一些技巧將可見性修改成了執行緒可見。

如果我想共享執行緒的ThreadLocal資料怎麼辦?

使用InheritableThreadLocal可以實現多個執行緒訪問ThreadLocal的值,我們在主執行緒中建立一個InheritableThreadLocal的例項,然後在子執行緒中得到這個InheritableThreadLocal例項設定的值。

private void test() {    
final ThreadLocal threadLocal = new InheritableThreadLocal();       
threadLocal.set("帥得一匹");    
Thread t = new Thread() {        
    @Override        
    public void run() {            
      super.run();            
      Log.i( "張三帥麼 =" + threadLocal.get());        
    }    
  };          
  t.start(); 

在子執行緒中我是能夠正常輸出那一行日誌的,這也是我之前面試視訊提到過的父子執行緒資料傳遞的問題。

怎麼傳遞的呀?

傳遞的邏輯很簡單,我在開頭Thread程式碼提到threadLocals的時候,你們再往下看看我刻意放了另外一個變數:

Thread原始碼中,我們看看Thread.init初始化建立的時候做了什麼:

public class Thread implements Runnable {
  ……
   if (inheritThreadLocals && parent.inheritableThreadLocals != null)
      this.inheritableThreadLocals=ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
  ……
}

我就擷取了部分程式碼,如果執行緒的inheritThreadLocals變數不為空,比如我們上面的例子,而且父執行緒的inheritThreadLocals也存在,那麼我就把父執行緒的inheritThreadLocals給當前執行緒的inheritThreadLocals

是不是很有意思?

小夥子你懂的確實很多,那你算是一個深度的ThreadLocal使用者了,你發現ThreadLocal的問題了麼?

你是說記憶體洩露麼?

我丟,這小子為啥知道我要問什麼?嗯嗯對的,你說一下。

這個問題確實會存在的,我跟大家說一下為什麼,還記得我上面的程式碼麼?

ThreadLocal在儲存的時候會把自己當做Key存在ThreadLocalMap中,正常情況應該是key和value都應該被外界強引用才對,但是現在key被設計成WeakReference弱引用了。

我先給大家介紹一下弱引用:

只具有弱引用的物件擁有更短暫的生命週期,在垃圾回收器執行緒掃描它所管轄的記憶體區域的過程中,一旦發現了只具有弱引用的物件,不管當前記憶體空間足夠與否,都會回收它的記憶體。

不過,由於垃圾回收器是一個優先順序很低的執行緒,因此不一定會很快發現那些只具有弱引用的物件。

這就導致了一個問題,ThreadLocal在沒有外部強引用時,發生GC時會被回收,如果建立ThreadLocal的執行緒一直持續執行,那麼這個Entry物件中的value就有可能一直得不到回收,發生記憶體洩露。

就比如執行緒池裡面的執行緒,執行緒都是複用的,那麼之前的執行緒例項處理完之後,出於複用的目的執行緒依然存活,所以,ThreadLocal設定的value值被持有,導致記憶體洩露。

按照道理一個執行緒使用完,ThreadLocalMap是應該要被清空的,但是現線上程被複用了。

那怎麼解決?

在程式碼的最後使用remove就好了,我們只要記得在使用的最後用remove把值清空就好了。

ThreadLocal<String> localName = new ThreadLocal();
try {
    localName.set("張三");
    ……
finally {
    localName.remove();
}

remove的原始碼很簡單,找到對應的值全部置空,這樣在垃圾回收器回收的時候,會自動把他們回收掉。

那為什麼ThreadLocalMap的key要設計成弱引用?

key不設定成弱引用的話就會造成和entry中value一樣記憶體洩漏的場景。

補充一點:ThreadLocal的不足,我覺得可以通過看看netty的fastThreadLocal來彌補,大家有興趣可以康康。

好了,你不僅把我問的都回答了,我不知道的你甚至都說了,ThreadLocal你過關了,不過JUC的面試才剛剛開始,希望你以後越戰越勇,最後拿個好offer喲。

什麼鬼,突然這麼煽情,不是很為難我的麼?難道是為了鍛鍊我?難為大師這樣為我著想,我還一直心裡暗罵他,不說了回去好好學了。

總結

其實ThreadLocal用法很簡單,裡面的方法就那幾個,算上註釋原始碼都沒多少行,我用了十多分鐘就過了一遍了,但是在我深挖每一個方法背後邏輯的時候,也讓我不得不感慨Josh Bloch 和 Doug Lea的厲害之處。

在細節設計的處理其實往往就是我們和大神的區別,我認為很多不合理的點,在Google和自己不斷深入瞭解之後才發現這才是合理,真的不服不行。

ThreadLocal是多執行緒裡面比較冷門的一個類,使用頻率比不上別的方法和類,但是通過我這篇文章,不知道你是否有新的認知呢?

絮叨

另外,敖丙把自己的面試文章整理成了一本電子書,共 1630頁!目錄如下,還有我複習時總結的面試題以及簡歷模板

現在免費送給大家,在我的公眾號三太子敖丙回覆 【888】 即可獲取。

我是敖丙,你知道的越多,你不知道的越多,我們下期見!

人才們的 【三連】 就是敖丙創作的最大動力,如果本篇部落格有任何錯誤和建議,歡迎人才們留言!


文章持續更新,可以微信搜一搜「 敖丙 」第一時間閱讀,關注後回覆【資料】有我準備的一線大廠面試資料和簡歷模板,本文 GitHub https://github.com/JavaFamily 已經收錄,有大廠面試完整考點,歡迎Star。

相關文章