今天分享一位朋友線上出現了一個比較嚴重的故障,這個故障是多線程使用不當引起的。
挺有代表性的,所以分享給大家,希望能幫大家避坑。
問題簡述
先簡單介紹一下問題產生的背景,我們有個返利業務。
其中有個搜索場景,這個場景是用戶在 app 輸入搜索關鍵詞,然后 server 會根據這個關鍵詞到各個平臺(如淘寶,京東,拼多多等)調一下搜索接口,聚合這些搜索結果后再返回給用戶。
最開始這個搜索場景處理是單線程的,但隨著接入的平臺越來越多,搜索請求耗時也越來越長,由于每個平臺的搜索請求都是獨立的,很顯然,單線程是可以優化為多線程的,如下
img這樣的話,搜索請求的耗時就只取決于搜索接口耗時最長的那個平臺。
所以使用多線程顯然對接口性能是一個極大的優化,但使用多線程改造上線后,短時間內社群中有多名用戶反饋前臺展示「APP 需要升級的提示」。
經定位后發現是因為在多線程中無法獲取客戶端信息,由于客戶端信息缺失,導致返回給用戶需要升級的提示,偽代碼如下:
//開啟多線程處理
newThread(newRunnable(){
@Override
publicvoidrun(){
MapclientInfoMap=Context.getContext().getClientInfo();
//無法獲取客戶端信息,返回需要升級的信息
if(clientInfoMap==null){
thrownewException("版本號過低,請升級版本");
}
Stringversion=clientInfoMap.get("version");
//以下正常邏輯
....
}
}).start();
畫外音:在生產中多線程使用的是線程池來實現,這里為了方便演示,直接 new Thread,效果都一樣,大家知道即可。
那么問題來了,改成多線程后客戶端信息怎么就取不到了呢?
要搞清楚這個問題,就得先了解客戶端信息是如何存儲的了。
Threadlocal 簡介
不同客戶端請求的客戶端信息(wifi 還是 4G,機型,app名稱,電量等)顯然不一樣,dubbo 業務線程拿到客戶端請求后首先會將有用的請求信息提取出來(如本文中的 MapclientInfo)。
但這個 clientInfo 可能會在線程調用的各個方法中用到,于是如何存儲就成為了一個現實的問題。
相信有經驗的朋友一下就想到了,沒錯,用 Threadlocal !
為什么用它,它有什么優勢,簡單來說有兩點
-
無鎖化提升并發性能
-
簡化變量的傳遞邏輯
1.無鎖化提升并發性能
先說第一個,無鎖化提升并發性能,影響并發的原因有很多,其中一個很重要的原因就是鎖,為了防止對共享變量的競用,不得不對共享變量加鎖
如果對共享變量爭用的線程數增多,顯然會嚴重影響系統的并發度,最好的辦法就是使用“影分身術”為每個線程都創建一個線程本地變量,這樣就避免了對共享變量的競用,也就實現了無鎖化
無鎖化ThreadLocal 即線程本地變量,它可以為每個線程創建一份線程本地變量,使用方法如下
staticThreadLocalthreadLocal1=newThreadLocal(){
@Override
protectedSimpleDateFormatinitialValue(){
returnnewSimpleDateFormat("yyyy-MM-dd");
}
};
publicStringformatDate(Datedate){
returnthreadLocal1.get().format(date);
}
這樣的話每個線程就獨享一份與其他線程無關的 SimpleDateFormat 實例副本,它們調用 formatDate 時使用的 SimpleDateFormat 實例也是自己獨有的副本,無論對副本怎么操作對其他線程都互不影響
通過以上例子我們可以看出,可以通過 new ThreadLocal
+ initialValue
來為創建的 ThreadLocal 實例初始化本地變量(initialValue
方法會在首次調用 get 時被調用以初始化本地變量)。
當然,如果之后需要修改本地變量的話,也可以用以下方式來修改
threadLocal1.set(newSimpleDateFormat("yyyy-MM-dd"))
而使用 threadLocal1.get()
這樣的方法即可獲得線程本地變量
可能一些朋友會好奇線程本地變量是如何存儲的,一圖勝千言
每一個線程(Thread)內部都有一個 ThreadLocalMap, ThreadLocal 的 get 和 set 操作其實在底層都是針對 ThreadLocalMap 進行操作的。
publicclassThreadimplementsRunnable{
/*ThreadLocalvaluespertainingtothisthread.Thismapismaintained
*bytheThreadLocalclass.*/
ThreadLocal.ThreadLocalMapthreadLocals=null;
}
它與 HashMap 類似,存儲的都是鍵值對,只不過每一項(Entry)中的 key 為 threadlocal 變量(如上文案例中的 threadLocal1),value 才為我們要存儲的值(如上文中的 SimpleDateFormat 實例)。
此外它們在碰到 hash 沖突時的處理策略也不同,HashMap 在碰到 hash 沖突時采用的是鏈表法,而 ThreadLocalMap 采用的是線性探測法
2.簡化變量的傳遞邏輯
接下來我們來看使用 ThreadLocal 的等二個好處,簡化變量的傳遞邏輯。
線程在處理業務邏輯時可能會調用幾十個方法,如果這些方法中只有幾個需要用到 clientInfo,難道要在這幾十個方法中定義一個 clientInfo 參數來層層傳遞嗎,顯然不現實。
那該怎么辦呢,使用 ThreadLocal 即可解決此問題。
由上文可知通過 ThreadLocal 設置的本地變量是同 threadlocal 一起保存在 Thread 的 ThreadLocalMap 這個內部類中的,所以可在線程調用的任意方法中取出,偽代碼如下:
publicclassThreadLocalWithUserContextimplementsRunnable{
privatestaticThreadLocal
中間定義的任何方法都無需為了傳遞 clientInfo 而定義一個額外的變量,代碼優雅了不少。
由以上分析可知,使用 ThreadLocal 確實比較方便。
在此我們先停下來思考一個問題:如果線程在調用過程中只用到一個 clientInfo 這樣的信息,只定義一個 ThreadLocal 變量當然就夠了,但實際上在使用過程中我們可能要傳遞多個類似 clientInfo 這樣的信息(如 userId,cookie,header),難道因此要定義多個 ThreadLocal 變量嗎?
這么做不是不可以,但不夠優雅。
更合適的做法是我們只定義一個 ThreadLocal 變量,變量存的是一個上下文對象,其他像 clientInfo,userId,header 等信息就作為此上下文對象的屬性即可,代碼如下:
publicfinalclassContext{
privatestaticfinalThreadLocalLOCAL=newThreadLocal(){
protectedContextinitialValue(){
returnnewContext();
}
};
privateLonguid;//用戶uid
privateMapclientInfo;//客戶端信息
privateMapheaders=null;//請求頭信息
privateMap>cookies=null;//請求cookie
publicstaticContextgetContext(){
return(Context)LOCAL.get();
}
}
這樣的話我們可通過 Context.getContext().getXXX()
的形式來獲取線程所需的信息,通過這樣的方式我們不僅避免了定義無數 ThreadLocal 變量的煩惱,而且還收攏了上下文信息的管理。
通過以上介紹相信大家也都知道了 clientInfo 其實是借由 ThreadLocal 存儲的。
認清了這個事實后那我們現在再回頭看開頭的生產問題:將單線程改成多線程后,為什么在新線程中就拿不到 clientInfo 了?
問題剖析
源碼之下無秘密,我們查看一下源碼來一探究竟,獲取本地變量的值使用的是 ThreadLocal.get 方法,那就來看下這個方法:
publicclassThreadLocal<T>{
publicTget(){
//1.先獲取當前線程
Threadt=Thread.currentThread();
//2.再獲取當前線程的ThreadLocalMap
ThreadLocalMapmap=getMap(t);
if(map!=null){
ThreadLocalMap.Entrye=map.getEntry(this);
if(e!=null){
Tresult=(T)e.value;
returnresult;
}
}
returnsetInitialValue();
}
}
可以看到 get 方法主要步驟如下
-
首先需要獲取當前線程
-
其次獲取當前線程的 ThreadLocalMap
-
進而再去獲取相應的本地變量值
-
如果沒有的話則調用 initiaValue 方法來初始化本地變量
由此可知當我們調用 threadlocal.get 時,會拿到當前線程的 ThreadLocalMap,然后再去拿 entry 中的本地變量,而對多線程來說,新線程的 ThreadLocalMap 里面的東西本來就未做任何設置,是空的,拿不到線程本地變量也就合情合理了
解決方案
問題清楚了,那怎么解決呢,不難得知主要有兩種方案
1.我們之前是在新線程的執行方法中調用 threadlocal.get 方法,可以改成先從當前執行線程中調用 threadlocal.get 獲得 clientInfo,然后再把 clientInfo 傳入新線程,偽代碼如下:
//先從當前線程的Context中獲取clientInfo
MapclientInfoMap=Context.getContext().getClientInfo();
newThread(newRunnable(){
@Override
publicvoidrun(){
//此時的clientInfoMap由于是在新線程創建前獲取的,肯定是有值的
Stringversion=clientInfoMap.get("version");
//以下正常邏輯
....
}
}).start();
2.只需把 ThreadLocal 換成 InheritableThreadLocal,如下:
publicfinalclassContext{
privatestaticfinalInheritableThreadLocalLOCAL=newInheritableThreadLocal(){
protectedContextinitialValue(){
returnnewContext();
}
};
publicstaticContextgetContext(){
return(Context)LOCAL.get();
}
}
newThread(newRunnable(){
@Override
publicvoidrun(){
//此時的clientInfo能正常獲取到
MapclientInfo=Context.getContext().getClientInfo();
Stringversion=clientInfo.get("version");
//以下正常邏輯
....
}
}).start();
為什么 InheritableThreadLocal 能有這么神奇,背后的原理是什么?
由前文介紹我們得知,ThreadLocal 變量最終是存在 ThreadLocalMap 中的。
那么能否在創建新線程的時候,把當前線程的 ThreadLocalMap 復制給新線程的 ThreadLocalMap 呢?
這樣的話即便你從新線程中調用 threadlocal.get 也照樣能獲得對應的本地變量,和 InheritableThreadLocal 相關的底層干的就是這個事。
我們先來瞧一瞧 InheritableThreadLocal 長啥樣:
publicclassInheritableThreadLocal<T>extendsThreadLocal<T>{
ThreadLocalMapgetMap(Threadt){
returnt.inheritableThreadLocals;
}
voidcreateMap(Threadt,TfirstValue){
t.inheritableThreadLocals=newThreadLocalMap(this,firstValue);
}
}
由此可知 InheritableThreadLocal 其實是繼承自 ThreadLocal 類的。
此外我們在 getMap 和 createMap 這兩個方法中也發現它的底層其實是用 inheritableThreadLocals 來存儲的,而 ThreadLocal 用的是 threadLocals 變量存儲的。
publicclassThreadimplementsRunnable{
//ThreadLocal實例的底層存儲
ThreadLocal.ThreadLocalMapthreadLocals=null;
//inheritableThreadLocals實例的底層存儲
ThreadLocal.ThreadLocalMapinheritableThreadLocals=null;
}
知道了這些,我們再來看下創建線程時涉及到的 inheritableThreadLocals 復制相關的關鍵代碼如下:
public
classThreadimplementsRunnable{
publicThread(){
init(null,null,"Thread-"+nextThreadNum(),0);
}
privatevoidinit(ThreadGroupg,Runnabletarget,Stringname,
longstackSize){
init(g,target,name,stackSize,null,true);
}
privatevoidinit(ThreadGroupg,Runnabletarget,Stringname,
longstackSize,AccessControlContextacc,
booleaninheritThreadLocals){
...
Threadparent=currentThread();
if(inheritThreadLocals&&parent.inheritableThreadLocals!=null)
//將當前線程的inheritableThreadLocals復制給新創建線程的inheritableThreadLocals
this.inheritableThreadLocals=
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
}
}
由此可知,在創建新線程時,在初始化時其實相關邏輯是幫我們干了復制 inheritableThreadLocals 的操作,至此真相大白!
總結
看完本文,相信大家對 Threadlocal 與 InheritableThreadLocal 的使用及其底層原理的掌握已不存在疑問。
這也提醒我們熟練地掌握一個組件或一項技術最好的方式還是熟讀它的源碼,畢竟源碼之下無秘密。
當我們使用到別人封裝好的組件或類時,如果有興趣也可以也看一下它的源碼。
以本文為例,其實我們工程中多處地方都使用了 Context.getContext().getClientInfo();
這樣的獲取客戶端信息的形式,用慣了導致在多線程環境下沒有引起警惕,以致踩了坑。
另外需要注意的是 ThreadLocal 使用不當可能導致內存泄漏,需要在線程結束后及時 remove 掉,這些技術細節不是本文重點,故而沒有深入詳解,有興趣的大家可以去查閱相關資料。
歷史好文:
多個線程為了同個資源打起架來了,該如何讓他們安分?
美團三面:一直追問我, MySQL 幻讀被徹底解決了嗎?
原來墻,是這么把我 TCP 連接干掉的!
面試官:你確定 Redis 是單線程的進程嗎?
字節一面:HTTPS 一定安全可靠嗎?
審核編輯 :李倩
-
多線程
+關注
關注
0文章
278瀏覽量
19943 -
代碼
+關注
關注
30文章
4780瀏覽量
68527 -
變量
+關注
關注
0文章
613瀏覽量
28360
原文標題:多線程引發的慘案!直接把年終給干沒了
文章出處:【微信號:小林coding,微信公眾號:小林coding】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
相關推薦
評論