從池化技術到底層實現,一篇文章帶你貫通線程池技術。
1、池化技術簡介
在系統開發過程中,我們經常會用到池化技術來減少系統消耗,提升系統性能。
在編程領域,比較典型的池化技術有:
線程池、連接池、內存池、對象池等。
對象池通過復用對象來減少創建對象、垃圾回收的開銷;連接池(資料庫連接池、Redis連接池和HTTP連接池等)通過復用TCP連接來減少創建和釋放連接的時間。線程池通過復用線程提升性能。簡單來說,池化技術就是通過復用來提升性能。
線程、內存、資料庫的連接對象都是資源,在程序中,當你創建一個線程或者在堆上申請一塊內存的時候都涉及到很多的系統調用,也是非常消耗CPU的。如果你的程序需要很多類似的工作線程或者需要頻繁地申請釋放小塊內存,在沒有對這方面進行優化的情況下,這部分代碼很可能會成為影響你整個程序性能的瓶頸。
如果每次都是如此的創建線程->執行任務->銷毀線程,會造成很大的性能開銷。復用已創建好的線程可以提高系統的性能,藉助池化技術的思想,通過預先創建好多個線程,放在池中,這樣可以在需要使用線程的時候直接獲取,避免多次重複創建、銷毀帶來的開銷。
(1)線程池的優點
線程是稀缺資源,使用線程池可以減少創建和銷毀線程的次數,每個工作線程都可以重複使用。可以根據系統的承受能力,調整線程池中工作線程的數量,防止因為消耗過多內存導致伺服器崩潰。(2)線程池的風險
雖然線程池是構建多線程應用程式的強大機制,但使用它並不是沒有風險的。用線程池構建的應用程式容易遭受任何其它多線程應用程式容易遭受的所有並發風險,諸如同步錯誤和死鎖,它還容易遭受特定於線程池的少數其它風險,諸如與池有關的死鎖、資源不足和線程洩漏。
死鎖任何多線程應用程式都有死鎖風險。當一組進程或線程中的每一個都在等待一個只有該組中另一個進程才能引起的事件時,我們就說這組進程或線程 死鎖了。死鎖的最簡單情形是:線程 A 持有對象 X 的獨佔鎖,並且在等待對象 Y 的鎖,而線程 B 持有對象 Y 的獨佔鎖,卻在等待對象 X 的鎖。除非有某種方法來打破對鎖的等待(Java 鎖定不支持這種方法),否則死鎖的線程將永遠等下去。
資源不足線程池的一個優點在於:相對於其它替代調度機制(有些我們已經討論過)而言,它們通常執行得很好。但只有恰當地調整了線程池大小時才是這樣的。
線程消耗包括內存和其它系統資源在內的大量資源。除了
Thread 對象所需的內存之外,每個線程都需要兩個可能很大的執行調用堆棧。除此以外,JVM 可能會為每個 Java
線程創建一個本機線程,這些本機線程將消耗額外的系統資源。最後,雖然線程之間切換的調度開銷很小,但如果有很多線程,環境切換也可能嚴重地影響程序的性能。
如果線程池太大,那麼被那些線程消耗的資源可能嚴重地影響系統性能。在線程之間進行切換將會浪費時間,而且使用超出比您實際需要的線程可能會引起資源匱乏問題,因為池線程正在消耗一些資源,而這些資源可能會被其它任務更有效地利用。
除了線程自身所使用的資源以外,服務請求時所做的工作可能需要其它資源,例如 JDBC 連接、套接字或文件,這些也都是有限資源,有太多的並發請求也可能引起失效,例如不能分配 JDBC 連接。
並發錯誤線程池和其它排隊機制依靠使用
wait() 和 notify()
方法,這兩個方法都難於使用。如果編碼不正確,那麼可能丟失通知,導致線程保持空閒狀態,儘管隊列中有工作要處理。使用這些方法時,必須格外小心;即便是專家也可能在它們上面出錯。而最好使用現有的、已經知道能工作的實現,例如在
util.concurrent 包。
線程洩漏各種類型的線程池中一個嚴重的風險是線程洩漏,當從池中除去一個線程以執行一項任務,而在任務完成後該線程卻沒有返回池時,會發生這種情況。發生線程洩漏的一種情形出現在任務拋出一個 RuntimeException 或一個 Error 時。
如果池類沒有捕捉到它們,那麼線程只會退出而線程池的大小將會永久減少一個。當這種情況發生的次數足夠多時,線程池最終就為空,而且系統將停止,因為沒有可用的線程來處理任務。
請求過載僅僅是請求就壓垮了伺服器,這種情況是可能的。在這種情形下,我們可能不想將每個到來的請求都排隊到我們的工作隊列,因為排在隊列中等待執行的任務可能會消耗太多的系統資源並引起資源缺乏。在這種情形下決定如何做取決於您自己;在某些情況下,您可以簡單地拋棄請求,依靠更高級別的協議稍後重試請求,您也可以用一個指出伺服器暫時很忙的響應來拒絕請求。
2、 如何配置線程池大小配置
一般需要根據任務的類型來配置線程池大小:
如果是CPU密集型任務,就需要儘量壓榨CPU,參考值可以設為 NCPU+1如果是IO密集型任務,參考值可以設置為2*NCPU當然,這只是一個參考值,具體的設置還需要根據實際情況進行調整,比如可以先將線程池大小設置為參考值,再觀察任務運行情況和系統負載、資源利用率來進行適當調整。
3、線程池的底層原理
(1)線程池的狀態
線程池和線程一樣擁有自己的狀態,在ThreadPoolExecutor類中定義了一個volatile變量runState來表示線程池的狀態,線程池有四種狀態,分別為RUNNING、SHURDOWN、STOP、TERMINATED。
線程池創建後處於RUNNING狀態。調用shutdown後處於SHUTDOWN狀態,線程池不能接受新的任務,會等待緩衝隊列的任務完成。調用shutdownNow後處於STOP狀態,線程池不能接受新的任務,並嘗試終止正在執行的任務。當線程池處於SHUTDOWN或STOP狀態,並且所有工作線程已經銷毀,任務緩存隊列已經清空或執行結束後,線程池被設置為TERMINATED狀態。
其中ctl這個AtomicInteger的功能很強大,其高3位用於維護線程池運行狀態,低29位維護線程池中線程數量
RUNNING:-1<<COUNT_BITS,即高3位為1,低29位為0,該狀態的線程池會接收新任務,也會處理在阻塞隊列中等待處理的任務
SHUTDOWN:0<<COUNT_BITS,即高3位為0,低29位為0,該狀態的線程池不會再接收新任務,但還會處理已經提交到阻塞隊列中等待處理的任務
STOP:1<<COUNT_BITS,即高3位為001,低29位為0,該狀態的線程池不會再接收新任務,不會處理在阻塞隊列中等待的任務,而且還會中斷正在運行的任務
TIDYING:2<<COUNT_BITS,即高3位為010,低29位為0,所有任務都被終止了,workerCount為0,為此狀態時還將調用terminated()方法
TERMINATED:3<<COUNT_BITS,即高3位為100,低29位為0,terminated()方法調用完成後變成此狀態
這些狀態均由int型表示,大小關係為 RUNNING<SHUTDOWN<STOP<TIDYING<TERMINATED,這個順序基本上也是遵循線程池從 運行 到 終止這個過程。
runStateOf(int c) 方法:c & 高3位為1,低29位為0的~CAPACITY,用於獲取高3位保存的線程池狀態workerCountOf(int c)方法:c & 高3位為0,低29位為1的CAPACITY,用於獲取低29位的線程數量ctlOf(int rs, int wc)方法:參數rs表示runState,參數wc表示workerCount,即根據runState和workerCount打包合併成ctl(2)為什麼ctl負責兩種角色
在Doug Lea的設計中,ctl負責兩種角色可以避免多餘的同步邏輯。
很多人會想,一個變量表示兩個值,就節省了存儲空間,但是這裡很顯然不是為了節省空間而設計的,即使將這輛個值拆分成兩個Integer值,一個線程池也就多了4個字節而已,為了這4個字節而去大費周章地設計一通,顯然不是Doug Lea的初衷。
在多線程的環境下,運行狀態和有效線程數量往往需要保證統一,不能出現一個改而另一個沒有改的情況,如果將他們放在同一個AtomicInteger中,利用AtomicInteger的原子操作,就可以保證這兩個值始終是統一的。
(3)線程池工作流程
預先啟動一些線程,線程無限循環從任務隊列中獲取一個任務進行執行,直到線程池被關閉。如果某個線程因為執行某個任務發生異常而終止,那麼重新創建一個新的線程而已,如此反覆。
一個任務從提交到執行完畢經歷過程如下:
第一步:如果當前線程池中的線程數目小於corePoolSize,則每來一個任務,就會創建一個線程去執行這個任務;
第二步:如果當前線程池中的線程數目>=corePoolSize,則每來一個任務,會嘗試將其添加到任務緩存隊列當中,若添加成功,則該任務會等待空閒線程將其取出去執行;若添加失敗(一般來說是任務緩存隊列已滿),則會嘗試創建新的線程去執行這個任務;
第三步:如果線程池中的線程數量大於等於corePoolSize,且隊列workQueue已滿,但線程池中的線程數量小於maximumPoolSize,則會創建新的線程來處理被添加的任務
第四步:如果當前線程池中的線程數目達到maximumPoolSize,則會採取任務拒絕策略進行處理;
流程圖如下:
4、ThreadPoolExecutor解析
ThreadPoolExecutor繼承自AbstractExecutorService,同時實現了ExecutorService接口,也是Executor框架默認的線程池實現類,一般我們使用線程池,如沒有特殊要求,直接創建ThreadPoolExecutor,初始化一個線程池,如果需要特殊的線程池,則直接繼承ThreadPoolExecutor,並實現特定的功能,如ScheduledThreadPoolExecutor,它是一個具有定時執行任務的線程池。
(1)Executor框架
在深入源碼之前先來看看J.U.C包中的線程池類圖:
它們的最頂層是一個Executor接口,它只有一個方法:
它提供了一個運行新任務的簡單方法,Java線程池也稱之為Executor框架。
ExecutorService擴展了Executor,添加了操控線程池生命周期的方法,如shutDown(),shutDownNow()等,以及擴展了可異步跟蹤執行任務生成返回值Future的方法,如submit()等方法。
(2)Worker解析
Worker類繼承了AQS,並實現了Runnable接口,它有兩個重要的成員變量:firstTask和thread。firstTask用於保存第一次新建的任務;thread是在調用構造方法時通過ThreadFactory來創建的線程,是用來處理任務的線程。
需要注意workers的數據結構為HashSet,非線程安全,所以操作workers需要加同步鎖。添加步驟做完後就啟動線程來執行任務了。
(3)如何在線程池中添加任務
線程池要執行任務,那麼必須先添加任務,execute()雖說是執行任務的意思,但裡面也包含了添加任務的步驟在裡面,下面源碼:
addWorker添加任務,方法源碼有點長,按照邏輯拆分成兩部分講解:
java.util.concurrent.ThreadPoolExecutor#addWorker:
這裡特別強調,firstTask是開啟線程執行的首個任務,之後常駐在線程池中的線程執行的任務都是從阻塞隊列中取出的,需要注意。
以上for循環代碼主要作用是判斷ctl變量當前的狀態是否可以添加任務,特別說明了如果線程池處於SHUTDOWN狀態時,可以繼續執行阻塞隊列中的任務,但不能繼續往線程池中添加任務了;同時增加工作線程數量使用了AQS作同步,如果同步失敗,則繼續循環執行。
以上源碼主要的作用是創建一個Worker對象,並將新的任務裝進Worker中,開啟同步將Worker添加進workers中,這裡需要注意workers的數據結構為HashSet,非線程安全,所以操作workers需要加同步鎖。添加步驟做完後就啟動線程來執行任務了,繼續往下看。
(4)前置和後置鉤子
如果需要在任務執行前後插入邏輯,你可以實現ThreadPoolExecutor以下兩個方法:
這樣一來,就可以對任務的執行進行實時監控。
5、線程池總結
線程池原理關鍵技術:鎖(lock,cas)、阻塞隊列、hashSet(資源池)
所謂線程池本質是一個Worker對象的hashSet,多餘的任務會放在阻塞隊列中,只有當阻塞隊列滿了後,才會觸發非核心線程的創建,非核心線程只是臨時過來打雜的,直到空閒,然後自己關閉。
線程池提供了兩個鉤子(beforeExecute,afterExecute)給我們,我們繼承線程池,在執行任務前後做一些事情。