前言
日常開發(fā)中,為了更好管理線程資源,減少創(chuàng)建線程和銷毀線程的資源損耗,我們會使用線程池來執(zhí)行一些異步任務(wù)。但是線程池使用不當(dāng),就可能會引發(fā)生產(chǎn)事故。今天跟大家聊聊線程池的10個坑。大家看完肯定會有幫助的~
線程池默認使用無界隊列,任務(wù)過多導(dǎo)致OOM
線程創(chuàng)建過多,導(dǎo)致OOM
共享線程池,次要邏輯拖垮主要邏輯
線程池拒絕策略的坑
Spring內(nèi)部線程池的坑
使用線程池時,沒有自定義命名
線程池參數(shù)設(shè)置不合理
線程池異常處理的坑
使用完線程池忘記關(guān)閉
ThreadLocal與線程池搭配,線程復(fù)用,導(dǎo)致信息錯亂。
1.線程池默認使用無界隊列,任務(wù)過多導(dǎo)致OOM
JDK開發(fā)者提供了線程池的實現(xiàn)類,我們基于Executors組件,就可以快速創(chuàng)建一個線程池 。日常工作中,一些小伙伴為了開發(fā)效率,反手就用Executors新建個線程池。寫出類似以下的代碼:
publicclassNewFixedTest{ publicstaticvoidmain(String[]args){ ExecutorServiceexecutor=Executors.newFixedThreadPool(10); for(inti=0;i{ try{ Thread.sleep(10000); }catch(InterruptedExceptione){ //donothing } }); } } }
使用newFixedThreadPool創(chuàng)建的線程池,是會有坑的,它默認是無界的阻塞隊列,如果任務(wù)過多,會導(dǎo)致OOM問題。運行一下以上代碼,出現(xiàn)了OOM。
Exceptioninthread"main"java.lang.OutOfMemoryError:GCoverheadlimitexceeded atjava.util.concurrent.LinkedBlockingQueue.offer(LinkedBlockingQueue.java:416) atjava.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1371) atcom.example.dto.NewFixedTest.main(NewFixedTest.java:14)
這是因為newFixedThreadPool使用了無界的阻塞隊列的LinkedBlockingQueue,如果線程獲取一個任務(wù)后,任務(wù)的執(zhí)行時間比較長(比如,上面demo代碼設(shè)置了10秒),會導(dǎo)致隊列的任務(wù)越積越多,導(dǎo)致機器內(nèi)存使用不停飆升, 最終出現(xiàn)OOM。
看下newFixedThreadPool的相關(guān)源碼,是可以看到一個無界的阻塞隊列的,如下:
//阻塞隊列是LinkedBlockingQueue,并且是使用的是無參構(gòu)造函數(shù) publicstaticExecutorServicenewFixedThreadPool(intnThreads){ returnnewThreadPoolExecutor(nThreads,nThreads, 0L,TimeUnit.MILLISECONDS, newLinkedBlockingQueue()); } //無參構(gòu)造函數(shù),默認最大容量是Integer.MAX_VALUE,相當(dāng)于無界的阻塞隊列的了 publicLinkedBlockingQueue(){ this(Integer.MAX_VALUE); }
因此,工作中,建議大家自定義線程池 ,并使用指定長度的阻塞隊列 。
2. 線程池創(chuàng)建線程過多,導(dǎo)致OOM
有些小伙伴說,既然Executors組件創(chuàng)建出的線程池newFixedThreadPool,使用的是無界隊列,可能會導(dǎo)致OOM。那么,Executors組件還可以創(chuàng)建別的線程池,如newCachedThreadPool,我們用它也不行嘛?
我們可以看下newCachedThreadPool的構(gòu)造函數(shù):
publicstaticExecutorServicenewCachedThreadPool(){ returnnewThreadPoolExecutor(0,Integer.MAX_VALUE, 60L,TimeUnit.SECONDS, newSynchronousQueue()); }
它的最大線程數(shù)是Integer.MAX_VALUE。大家應(yīng)該意識到使用它,可能會引發(fā)什么問題了吧。沒錯,如果創(chuàng)建了大量的線程也有可能引發(fā)OOM!
筆者在以前公司,遇到這么一個OOM問題:一個第三方提供的包,是直接使用new Thread實現(xiàn)多線程的。在某個夜深人靜的夜晚,我們的監(jiān)控系統(tǒng)報警了。。。這個相關(guān)的業(yè)務(wù)請求瞬間特別多,監(jiān)控系統(tǒng)告警OOM了。
所以我們使用線程池的時候,還要當(dāng)心線程創(chuàng)建過多,導(dǎo)致OOM問題。大家盡量不要使用newCachedThreadPool,并且如果自定義線程池時,要注意一下最大線程數(shù)。
3. 共享線程池,次要邏輯拖垮主要邏輯
要避免所有的業(yè)務(wù)邏輯共享一個線程池。比如你用線程池A來做登錄異步通知,又用線程池A來做對賬。如下圖:
如果對賬任務(wù)checkBillService響應(yīng)時間過慢,會占據(jù)大量的線程池資源,可能直接導(dǎo)致沒有足夠的線程資源去執(zhí)行l(wèi)oginNotifyService的任務(wù),最后影響登錄。就這樣,因為一個次要服務(wù),影響到重要的登錄接口,顯然這是絕對不允許的。因此,我們不能將所有的業(yè)務(wù)一鍋燉,都共享一個線程池,因為這樣做,風(fēng)險太高了,猶如所有雞蛋放到一個籃子里。應(yīng)當(dāng)做線程池隔離 !
4. 線程池拒絕策略的坑,使用不當(dāng)導(dǎo)致阻塞
我們知道線程池主要有四種拒絕策略,如下:
AbortPolicy: 丟棄任務(wù)并拋出RejectedExecutionException異常。(默認拒絕策略)
DiscardPolicy:丟棄任務(wù),但是不拋出異常。
DiscardOldestPolicy:丟棄隊列最前面的任務(wù),然后重新嘗試執(zhí)行任務(wù)。
CallerRunsPolicy:由調(diào)用方線程處理該任務(wù)。
如果線程池拒絕策略設(shè)置不合理,就容易有坑。我們把拒絕策略設(shè)置為DiscardPolicy或DiscardOldestPolicy并且在被拒絕的任務(wù),F(xiàn)uture對象調(diào)用get()方法,那么調(diào)用線程會一直被阻塞。
我們來看個demo:
publicclassDiscardThreadPoolTest{ publicstaticvoidmain(String[]args)throwsExecutionException,InterruptedException{ //一個核心線程,隊列最大為1,最大線程數(shù)也是1.拒絕策略是DiscardPolicy ThreadPoolExecutorexecutorService=newThreadPoolExecutor(1,1,1L,TimeUnit.MINUTES, newArrayBlockingQueue<>(1),newThreadPoolExecutor.DiscardPolicy()); Futuref1=executorService.submit(()->{ System.out.println("提交任務(wù)1"); try{ Thread.sleep(3000); }catch(InterruptedExceptione){ e.printStackTrace(); } }); Futuref2=executorService.submit(()->{ System.out.println("提交任務(wù)2"); }); Futuref3=executorService.submit(()->{ System.out.println("提交任務(wù)3"); }); System.out.println("任務(wù)1完成"+f1.get());//等待任務(wù)1執(zhí)行完畢 System.out.println("任務(wù)2完成"+f2.get());//等待任務(wù)2執(zhí)行完畢 System.out.println("任務(wù)3完成"+f3.get());//等待任務(wù)3執(zhí)行完畢 executorService.shutdown();//關(guān)閉線程池,阻塞直到所有任務(wù)執(zhí)行完畢 } }
運行結(jié)果:一直在運行中。。。
這是因為DiscardPolicy拒絕策略,是什么都沒做,源碼如下:
publicstaticclassDiscardPolicyimplementsRejectedExecutionHandler{ /** *Createsa{@codeDiscardPolicy}. */ publicDiscardPolicy(){} /** *Doesnothing,whichhastheeffectofdiscardingtaskr. */ publicvoidrejectedExecution(Runnabler,ThreadPoolExecutore){ } }
我們再來看看線程池 submit 的方法:
publicFuture>submit(Runnabletask){ if(task==null)thrownewNullPointerException(); //把Runnable任務(wù)包裝為Future對象 RunnableFutureftask=newTaskFor(task,null); //執(zhí)行任務(wù) execute(ftask); //返回Future對象 returnftask; } publicFutureTask(Runnablerunnable,Vresult){ this.callable=Executors.callable(runnable,result); this.state=NEW;//Future的初始化狀態(tài)是New }
我們再來看看Future的get() 方法
//狀態(tài)大于COMPLETING,才會返回,要不然都會阻塞等待 publicVget()throwsInterruptedException,ExecutionException{ ints=state; if(s<=?COMPLETING) ????????????s?=?awaitDone(false,?0L); ????????return?report(s); ????} ???? ????FutureTask的狀態(tài)枚舉 ????private?static?final?int?NEW??????????=?0; ????private?static?final?int?COMPLETING???=?1; ????private?static?final?int?NORMAL???????=?2; ????private?static?final?int?EXCEPTIONAL??=?3; ????private?static?final?int?CANCELLED????=?4; ????private?static?final?int?INTERRUPTING?=?5; ????private?static?final?int?INTERRUPTED??=?6;
阻塞的真相水落石出啦,F(xiàn)utureTask的狀態(tài)大于COMPLETING才會返回,要不然都會一直阻塞等待 。又因為拒絕策略啥沒做,沒有修改FutureTask的狀態(tài),因此FutureTask的狀態(tài)一直是NEW,所以它不會返回,會一直等待。
這個問題,可以使用別的拒絕策略,比如CallerRunsPolicy,它讓主線程去執(zhí)行拒絕的任務(wù),會更新FutureTask狀態(tài)。如果確實想用DiscardPolicy,則需要重寫DiscardPolicy的拒絕策略。
溫馨提示 ,日常開發(fā)中,使用 Future.get() 時,盡量使用帶超時時間的 ,因為它是阻塞的。
future.get(1,TimeUnit.SECONDS);
難道使用別的拒絕策略,就萬無一失了嘛? 不是的,如果使用CallerRunsPolicy拒絕策略,它表示拒絕的任務(wù)給調(diào)用方線程用,如果這是主線程,那會不會可能也導(dǎo)致主線程阻塞 呢?總結(jié)起來,大家日常開發(fā)的時候,多一份心眼吧,多一點思考吧。
5. Spring內(nèi)部線程池的坑
工作中,個別開發(fā)者,為了快速開發(fā),喜歡直接用spring的@Async,來執(zhí)行異步任務(wù)。
@Async publicvoidtestAsync()throwsInterruptedException{ System.out.println("處理異步任務(wù)"); TimeUnit.SECONDS.sleep(newRandom().nextInt(100)); }
Spring內(nèi)部線程池,其實是SimpleAsyncTaskExecutor,這玩意有點坑,它不會復(fù)用線程的 ,它的設(shè)計初衷就是執(zhí)行大量的短時間的任務(wù)。有興趣的小伙伴,可以去看看它的源碼:
/** *{@linkTaskExecutor}implementationthatfiresupanewThreadforeachtask, *executingitasynchronously. * *Supportslimitingconcurrentthreadsthroughthe"concurrencyLimit" *beanproperty.Bydefault,thenumberofconcurrentthreadsisunlimited. * *
NOTE:Thisimplementationdoesnotreusethreads!Considera *thread-poolingTaskExecutorimplementationinstead,inparticularfor *executingalargenumberofshort-livedtasks. * *@authorJuergenHoeller *@since2.0 *@see#setConcurrencyLimit *@seeSyncTaskExecutor *@seeorg.springframework.scheduling.concurrent.ThreadPoolTaskExecutor *@seeorg.springframework.scheduling.commonj.WorkManagerTaskExecutor */ @SuppressWarnings("serial") publicclassSimpleAsyncTaskExecutorextendsCustomizableThreadCreatorimplementsAsyncListenableTaskExecutor,Serializable{ ...... }
也就是說來了一個請求,就會新建一個線程!大家使用spring的@Async時,要避開這個坑,自己再定義一個線程池。正例如下:
@Bean(name="threadPoolTaskExecutor") publicExecutorthreadPoolTaskExecutor(){ ThreadPoolTaskExecutorexecutor=newThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(10); executor.setThreadNamePrefix("tianluo-%d"); //其他參數(shù)設(shè)置 returnnewThreadPoolTaskExecutor(); }
6. 使用線程池時,沒有自定義命名
使用線程池時,如果沒有給線程池一個有意義的名稱,將不好排查回溯問題。這不算一個坑吧,只能說給以后排查埋坑 ,哈哈。我還是單獨把它放出來算一個點,因為個人覺得這個還是比較重要的。反例如下:
publicclassThreadTest{ publicstaticvoidmain(String[]args)throwsException{ ThreadPoolExecutorexecutorOne=newThreadPoolExecutor(5,5,1, TimeUnit.MINUTES,newArrayBlockingQueue(20)); executorOne.execute(()->{ System.out.println("關(guān)注:芋道源碼"); thrownewNullPointerException(); }); } }
運行結(jié)果:
Exceptioninthread"pool-1-thread-1"java.lang.NullPointerException atcom.example.dto.ThreadTest.lambda$main$0(ThreadTest.java:17) atjava.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149) atjava.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624) atjava.lang.Thread.run(Thread.java:748)
可以發(fā)現(xiàn),默認打印的線程池名字是pool-1-thread-1,如果排查問題起來,并不友好。因此建議大家給自己線程池自定義個容易識別的名字。其實用CustomizableThreadFactory即可,正例如下:
publicclassThreadTest{ publicstaticvoidmain(String[]args)throwsException{ ThreadPoolExecutorexecutorOne=newThreadPoolExecutor(5,5,1, TimeUnit.MINUTES,newArrayBlockingQueue(20),newCustomizableThreadFactory("Tianluo-Thread-pool")); executorOne.execute(()->{ System.out.println("關(guān)注:芋道源碼"); thrownewNullPointerException(); }); } }
7. 線程池參數(shù)設(shè)置不合理
線程池最容易出坑的地方,就是線程參數(shù)設(shè)置不合理。比如核心線程設(shè)置多少合理,最大線程池設(shè)置多少合理等等。當(dāng)然,這塊不是亂設(shè)置的,需要結(jié)合具體業(yè)務(wù) 。
比如線程池如何調(diào)優(yōu),如何確認最佳線程數(shù)?
最佳線程數(shù)目=((線程等待時間+線程CPU時間)/線程CPU時間)*CPU數(shù)目
我們的服務(wù)器CPU核數(shù)為8核,一個任務(wù)線程cpu耗時為20ms,線程等待(網(wǎng)絡(luò)IO、磁盤IO)耗時80ms,那最佳線程數(shù)目:( 80 + 20 )/20 * 8 = 40。也就是設(shè)置 40個線程數(shù)最佳。
8. 線程池異常處理的坑
我們來看段代碼:
publicclassThreadTest{ publicstaticvoidmain(String[]args)throwsException{ ThreadPoolExecutorexecutorOne=newThreadPoolExecutor(5,5,1, TimeUnit.MINUTES,newArrayBlockingQueue(20),newCustomizableThreadFactory("Tianluo-Thread-pool")); for(inti=0;i5;?i++)?{ ????????????executorOne.submit(()->{ System.out.println("currentthreadname"+Thread.currentThread().getName()); Objectobject=null; System.out.print("result#"+object.toString()); }); } } }
按道理,運行這塊代碼應(yīng)該拋空指針異常 才是的,對吧。但是,運行結(jié)果卻是這樣的;
currentthreadnameTianluo-Thread-pool1 currentthreadnameTianluo-Thread-pool2 currentthreadnameTianluo-Thread-pool3 currentthreadnameTianluo-Thread-pool4 currentthreadnameTianluo-Thread-pool5
這是因為使用submit提交任務(wù),不會把異常直接這樣拋出來。大家有興趣的話,可以去看看源碼??梢愿臑閑xecute方法執(zhí)行,當(dāng)然最好就是try...catch捕獲,如下:
publicclassThreadTest{ publicstaticvoidmain(String[]args)throwsException{ ThreadPoolExecutorexecutorOne=newThreadPoolExecutor(5,5,1, TimeUnit.MINUTES,newArrayBlockingQueue(20),newCustomizableThreadFactory("Tianluo-Thread-pool")); for(inti=0;i5;?i++)?{ ????????????executorOne.submit(()->{ System.out.println("currentthreadname"+Thread.currentThread().getName()); try{ Objectobject=null; System.out.print("result#"+object.toString()); }catch(Exceptione){ System.out.println("異常了"+e); } }); } } }
其實,我們還可以為工作者線程設(shè)置UncaughtExceptionHandler,在uncaughtException方法中處理異常。大家知道這個坑就好啦。
9. 線程池使用完畢后,忘記關(guān)閉
如果線程池使用完,忘記關(guān)閉的話,有可能會導(dǎo)致內(nèi)存泄露 問題。所以,大家使用完線程池后,記得關(guān)閉一下。同時,線程池最好也設(shè)計成單例模式,給它一個好的命名,以方便排查問題。
publicclassThreadTest{ publicstaticvoidmain(String[]args)throwsException{ ThreadPoolExecutorexecutorOne=newThreadPoolExecutor(5,5,1, TimeUnit.MINUTES,newArrayBlockingQueue(20),newCustomizableThreadFactory("Tianluo-Thread-pool")); executorOne.execute(()->{ System.out.println("關(guān)注:芋道源碼"); }); //關(guān)閉線程池 executorOne.shutdown(); } }
10. ThreadLocal與線程池搭配,線程復(fù)用,導(dǎo)致信息錯亂。
使用ThreadLocal緩存信息,如果配合線程池一起,有可能出現(xiàn)信息錯亂的情況。先看下一下例子:
privatestaticfinalThreadLocalcurrentUser=ThreadLocal.withInitial(()->null); @GetMapping("wrong") publicMapwrong(@RequestParam("userId")IntegeruserId){ //設(shè)置用戶信息之前先查詢一次ThreadLocal中的用戶信息 Stringbefore=Thread.currentThread().getName()+":"+currentUser.get(); //設(shè)置用戶信息到ThreadLocal currentUser.set(userId); //設(shè)置用戶信息之后再查詢一次ThreadLocal中的用戶信息 Stringafter=Thread.currentThread().getName()+":"+currentUser.get(); //匯總輸出兩次查詢結(jié)果 Mapresult=newHashMap(); result.put("before",before); result.put("after",after); returnresult; }
按理說,每次獲取的before應(yīng)該都是null,但是呢,程序運行在 Tomcat 中,執(zhí)行程序的線程是Tomcat的工作線程,而Tomcat的工作線程是基于線程池 的。
線程池會重用固定的幾個線程 ,一旦線程重用,那么很可能首次從 ThreadLocal 獲取的值是之前其他用戶的請求遺留的值。這時,ThreadLocal 中的用戶信息就是其他用戶的信息。
把tomcat的工作線程設(shè)置為1
server.tomcat.max-threads=1
用戶1,請求過來,會有以下結(jié)果,符合預(yù)期:
用戶2請求過來,會有以下結(jié)果,「不符合預(yù)期」:
因此,使用類似 ThreadLocal 工具來存放一些數(shù)據(jù)時,需要特別注意在代碼運行完后,顯式地去清空設(shè)置的數(shù)據(jù),正例如下:
@GetMapping("right") publicMapright(@RequestParam("userId")IntegeruserId){ Stringbefore=Thread.currentThread().getName()+":"+currentUser.get(); currentUser.set(userId); try{ Stringafter=Thread.currentThread().getName()+":"+currentUser.get(); Mapresult=newHashMap(); result.put("before",before); result.put("after",after); returnresult; }finally{ //在finally代碼塊中刪除ThreadLocal中的數(shù)據(jù),確保數(shù)據(jù)不串 currentUser.remove(); } }
審核編輯:劉清
-
線程池
+關(guān)注
關(guān)注
0文章
57瀏覽量
6846 -
JDK
+關(guān)注
關(guān)注
0文章
81瀏覽量
16596 -
Thread
+關(guān)注
關(guān)注
2文章
83瀏覽量
25926
原文標題:細數(shù)線程池的10個坑,面試線程不怕不怕啦
文章出處:【微信號:芋道源碼,微信公眾號:芋道源碼】歡迎添加關(guān)注!文章轉(zhuǎn)載請注明出處。
發(fā)布評論請先 登錄
相關(guān)推薦
評論