Java面試必問:ThreadLocal終極篇 淦!
- 2020 年 7 月 27 日
- 筆記
點贊再看,養成習慣,微信搜一搜【敖丙】關注這個互聯網苟且偷生的程序員。
本文 GitHub //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 //github.com/JavaFamily 已經收錄,有大廠面試完整考點,歡迎Star。