3.實戰java高併發程序設計--JDK併發包---3.2

3.2 線程複用:線程池

首先,雖然與進程相比,線程是一種輕量級的工具,但其創建和關閉依然需要花費時間,如果爲每一個小的任務都創建一個線程,則很有可能出現創建和銷燬線程所佔用的時間大於該線程真實工作所消耗的時間的情況,反而會得不償失。

其次,線程本身也是要佔用內存空間的,大量的線程會搶佔寶貴的內存資源,如果處理不當,可能會導致Out of Memory異常。即便沒有,大量的線程回收也會給GC帶來很大的壓力,延長GC的停頓時間。

3.2.1 什麼是線程池

爲了避免系統頻繁地創建和銷燬線程,我們可以讓創建的線程複用。如果大家進行過數據庫開發,那麼對數據庫連接池應該不會陌生。爲了避免每次數據庫查詢都重新建立和銷燬數據庫連接,我們可以使用數據庫連接池維護一些數據庫連接,讓它們長期保持在一個激活狀態

線程池也是類似的概念。在線程池中,總有那麼幾個活躍線程。當你需要使用線程時,可以從池子中隨便拿一個空閒線程,當完成工作時,並不急着關閉線程,而是將這個線程退回到線程池中,方便其他人使用。簡而言之,在使用線程池後,創建線程變成了從線程池獲得空閒線程,關閉線程變成了向線程池歸還線程,如圖3.6所示。

3.2.2 不要重複發明輪子:JDK對線程池的支持Executor

爲了能夠更好地控制多線程,JDK提供了一套Executor框架,幫助開發人員有效地進行線程控制,其本質就是一個線程池,它的核心成員如圖3.7所示。

以上成員均在java.util.concurrent包中,是JDK併發包的核心類。其中,ThreadPoolExecutor表示一個線程池。Executors類則扮演着線程池工廠的角色,通過Executors可以取得一個擁有特定功能的線程池。從UML圖中亦可知,ThreadPoolExecutor類實現了Executor接口,因此通過這個接口,任何Runnable的對象都可以被ThreadPoolExecutor線程池調度。

 

● newFixedThreadPool()方法:該方法返回一個固定線程數量的線程池。該線程池中的線程數量始終不變。當有一個新的任務提交時,線程池中若有空閒線程,則立即執行。若沒有,則新的任務會被暫存在一個任務隊列中,待有線程空閒時,便處理任務隊列中的任務。固定數量線程池

● newSingleThreadExecutor()方法:該方法返回一個只有一個線程的線程池。若多餘一個任務被提交到該線程池,任務會被保存在一個任務隊列中,待線程空閒,按先入先出的順序執行隊列中的任務。單個線程線程池

● newCachedThreadPool()方法:該方法返回一個可根據實際情況調整線程數量的線程池。線程池的線程數量不確定,但若有空閒線程可以複用,則會優先使用可複用的線程。若所有線程均在工作,又有新的任務提交,則會創建新的線程處理任務。所有線程在當前任務執行完畢後,將返回線程池進行復用。可自動擴充數量線程池

● newSingleThreadScheduledExecutor()方法:該方法返回一個ScheduledExecutorService對象,線程池大小爲1。ScheduledExecutorService接口在ExecutorService接口之上擴展了在給定時間執行某任務的功能,如在某個固定的延時之後執行,或者週期性執行某個任務。可指定時間的單個線程線程池

● newScheduledThreadPool()方法:該方法也返回一個ScheduledExecutorService對象,但該線程池可以指定線程數量。可指定數量線程池

1.固定大小的線程池

2.計劃任務

另外一個值得注意的方法是newScheduledThreadPool()。它返回一個ScheduledExecutorService對象,可以根據時間需要對線程進行調度。它的一些主要方法如下:

與其他幾個線程池不同,ScheduledExecutorService並不一定會立即安排執行任務。它其實是起到了計劃任務的作用。它會在指定的時間,對任務進行調度。

作爲說明,這裏給出了三個方法。方法schedule()會在給定時間,對任務進行一次調度。方法scheduleAtFixedRate()和方法scheduleWithFixedDelay()會對任務進行週期性的調度,但是兩者有一點小小的區別,如圖3.8所示。

對於FixedRate方式來說,任務調度的頻率是一定的。它是以上一個任務開始執行時間爲起點,在之後的period時間調度下一次任務。而FixDelay方式則是在上一個任務結束後,再經過delay時間進行任務調度。

注意:這裏還想說一個有意思的事情,如果任務的執行時間超過調度時間會發生什麼情況呢?比如,這裏調度週期是2秒,如果任務的執行時間是8秒,是不是會出現多個任務堆疊在一起呢?實際上,ScheduledExecutorService不會讓任務堆疊出現。

週期如果太短,那麼任務就會在上一個任務結束後立即被調用

注意:如果任務遇到異常,那麼後續的所有子任務都會停止調度,因此,必須保證異常被及時處理,爲週期性任務的穩定調度提供條件。

3.2.3 刨根究底:核心線程池的內部實現

對於核心的幾個線程池,無論是newFixedThreadPool()方法、newSingleThreadExecutor()方法,還是newCachedThreadPool()方法,雖然看起來創建的線程有着完全不同的功能特點,但其內部實現均使用了ThreadPoolExecutor類。下面給出了這三個線程池的實現方式:

注意:使用自定義線程池時,要根據應用的具體情況,選擇合適的併發隊列作爲任務的緩衝。當線程資源緊張時,不同的併發隊列對系統行爲和性能的影響均不同。

 

3.2.4 超負載了怎麼辦:拒絕策略

ThreadPoolExecutor類的最後一個參數指定了拒絕策略。也就是當任務數量超過系統實際承載能力時,就要用到拒絕策略了。拒絕策略可以說是系統超負荷運行時的補救措施,通常由於壓力太大而引起的,也就是線程池中的線程已經用完了,無法繼續爲新任務服務,同時,等待隊列中也已經排滿了,再也放不下新任務了。這時,我們就需要有一套機制合理地處理這個問題。

JDK內置的拒絕策略如下。

● AbortPolicy策略:該策略會直接拋出異常,阻止系統正常工作。

● CallerRunsPolicy策略:只要線程池未關閉,該策略直接在調用者線程中,運行當前被丟棄的任務。顯然這樣做不會真的丟棄任務,但是,任務提交線程的性能極有可能會急劇下降。

● DiscardOldestPolicy策略:該策略將丟棄最老的一個請求,也就是即將被執行的一個任務,並嘗試再次提交當前任務。

● DiscardPolicy策略:該策略默默地丟棄無法處理的任務,不予任何處理。如果允許任務丟失,我覺得這可能是最好的一種方案了吧!

以上內置的策略均實現了RejectedExecutionHandler接口,若以上策略仍無法滿足實際應用的需要,完全可以自己擴展RejectedExecutionHandler接口。RejectedExecutionHandler的定義如下:

下面的代碼簡單地演示了自定義線程池和拒絕策略的使用。

 

3.2.5 自定義線程創建:ThreadFactory

之前我們介紹過,線程池的主要作用是爲了線程複用,也就是避免了線程的頻繁創建。但是,最開始的那些線程從何而來呢?答案就是ThreadFactory。

自定義線程池可以幫助我們做不少事。比如,我們可以跟蹤線程池究竟在何時創建了多少線程,也可以自定義線程的名稱、組以及優先級等信息,甚至可以任性地將所有的線程設置爲守護線程。總之,使用自定義線程池可以讓我們更加自由地設置線程池中所有線程的狀態。下面的案例使用自定義的ThreadFactory,一方面記錄了線程的創建,另一方面將所有的線程都設置爲守護線程,這樣,當主線程退出後,將會強制銷燬線程池。

3.2.6 我的應用我做主:擴展線程池

雖然JDK已經幫我們實現了這個穩定的高性能線程池,但如果我們需要對這個線程池做一些擴展,比如,監控每個任務執行的開始時間和結束時間,或者其他一些自定義的增強功能,這時候應該怎麼辦呢?一個好消息是:ThreadPoolExecutor是一個可以擴展的線程池。它提供了beforeExecute()、afterExecute()和terminated()三個接口用來對線程池進行控制

ThreadPoolExecutor.Worker是ThreadPoolExecutor的內部類,它是一個實現了Runnable接口的類。ThreadPoolExecutor線程池中的工作線程也正是Worker實例。Worker.run()方法會調用上述ThreadPoolExecutor.runWorker(Worker w)實現每一個工作線程的固有工作。

在默認的ThreadPoolExecutor實現中,提供了空的beforeExecute()和afterExecute()兩個接口實現。在實際應用中,可以對其進行擴展來實現對線程池運行狀態的跟蹤,輸出一些有用的調試信息,以幫助系統故障診斷,這對於多線程程序錯誤排查是很有幫助的。下面演示了對線程池的擴展,在這個擴展中,我們將記錄每一個任務的執行日誌。

上述代碼第23~40行擴展了原有的線程池,實現了beforeExecute()、afterExecute()和terminiated()三個方法。這三個方法分別用於記錄一個任務的開始、結束和整個線程池的退出。第42~43行向線程池提交5個任務,爲了有更清晰的日誌,我們爲每個任務都取了名字。第43行使用execute()方法提交任務,細心的讀者一定發現,在之前的代碼中,我們都使用了submit()方法提交。有關兩者的區別,我們將在“5.5 Future模式”中詳細介紹。

在提交完成後,調用shutdown()方法關閉線程池。這是一個比較安全的方法,如果當前正有線程在執行,shutdown()方法並不會立即暴力地終止所有任務,它會等待所有任務執行完成後,再關閉線程池,但它並不會等待所有線程執行完成後再返回,因此,可以簡單地理解成shutdown()方法只是發送了一個關閉信號而已。但在shutdown()方法執行後,這個線程池就不能再接受其他新的任務了。

3.2.7 合理的選擇:優化線程池線程數量

線程池的大小對系統的性能有一定的影響。過大或者過小的線程數量都無法發揮最優的系統性能,但是線程池大小的確定也不需要做得非常精確,因爲只要避免極大和極小兩種情況,線程池的大小對系統的性能並不會影響太大。確定線程池的大小需要考慮CPU數量、內存大小等因素。在Java Concurrency in Practice一書中給出了估算線程池大小的公式:

Ncpu = CPU的數量

Ucpu =目標CPU的使用率,0≤Ucpu≤1

W/C = 等待時間與計算時間的比率

W/C = 等待時間與計算時間的比率爲保持處理器達到期望的使用率,最優的線程池的大小等於:在Java中,可以通過如下代碼取得可用的CPU數量。

3.2.8 堆棧去哪裏了:在線程池中尋找堆棧

大家一定還記得在上一章中,我們詳解介紹了一些幽靈般的錯誤。我想,碼農的痛苦也莫過於此了。多線程本身就非常容易引起這類錯誤。如果你使用了線程池,那麼這種幽靈錯誤可能會變得更加常見。下面來看一個簡單的案例,首先,我們有一個Runnable接口,它用來計算兩個數的商。

因此,使用線程池雖然是件好事,但是還是得處處留意這些“坑”。線程池很有可能會“喫”掉程序拋出的異常,導致我們對程序的錯誤一無所知

我的一個領導曾經說過:“最鄙視那些出錯不打印異常堆棧的行爲!”我相信,任何一個得益於異常堆棧而快速定位問題的程序員,一定都對這句話深有體會。這裏我們將和大家討論向線程池討回異常堆棧的方法。

一種最簡單的方法就是放棄submit()方法,改用execute()方法。將上述的任務提交代碼改成

注意了,我這裏說的是部分。這是因爲從這兩個異常堆棧中我們只能知道異常是在哪裏拋出的(這裏是DivTask的第11行)。但是我們還希望得到另外一個更重要的信息,那就是這個任務到底是在哪裏提交的?而任務的具體提交位置已經被線程池完全淹沒了。順着堆棧,我們最多隻能找到線程池中的調度流程,而這對於我們幾乎是沒有價值的。

既然這樣,我們只能自己動手,豐衣足食啦!爲了今後少加幾天班,非常有必要將堆棧的信息徹底挖出來!擴展我們的ThreadPoolExecutor線程池,讓它在調度任務之前,先保存一下提交任務線程的堆棧信息:

在第23行代碼中,wrap()方法的第2個參數爲一個異常,裏面保存着提交任務的線程的堆棧信息。該方法將我們傳入的Runnable任務進行一層包裝,使之能處理異常信息。當任務發生異常時,這個異常會被打印。

3.2.9 分而治之:Fork/Join框架

Fork一詞的原始含義是喫飯用的叉子,也有分叉的意思。在Linux平臺中,方法fork()用來創建子進程,使得系統進程可以多一個執行分支。在Java中也沿用了類似的命名方式。

而join()方法的含義在之前的章節中已經解釋過,這裏表示等待。也就是使用fork()方法後系統多了一個執行分支(線程),所以需要等待這個執行分支執行完畢,纔有可能得到最終的結果,因此join()方法就表示等待。

在實際使用中,如果毫無顧忌地使用fork()方法開啓線程進行處理,那麼很有可能導致系統開啓過多的線程而嚴重影響性能。所以,在JDK中,給出了一個ForkJoinPool線程池,對於fork()方法並不急着開啓線程,而是提交給ForkJoinPool線程池進行處理,以節省系統資源。使用Fork/Join框架進行數據處理時的總體結構如圖3.11所示。幫忙的線程從底部拿,這樣就避免了數據競爭

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章