線程池的使用 Java并發編程實戰總結

????????第6章介紹了任務執行框架, 它不僅能簡化任務與線程的生命周期管理, 而且還提供一 種簡單靈活的方式將任務的提交與任務的執行策略解耦開來。 第7章介紹了在實際應用程序中使用任務執行框架時出現的一些與服務生命周期相關的細節問題。 本章將介紹對線程池進行配置與調優的一些高級選項, 并分析在使用任務執行框架時需要注意的各種危險, 以及一些使用 Executor的高級示例。

在任務與執行策略之間的隱性耦合

????????我們已經知道,Executor框架可以將任務的提交與任務的執行策略解耦開來。 就像許多對 復雜過程的解耦操作那樣, 這種論斷多少有些言過其實了。 雖然Executor框架為制定和修改執行策略都提供了相當大的靈活性, 但并非所有的任務都能適用所有的執行策略。 有些類型的任 務需要明確地指定執行策略, 包括:

a.依賴性任務。 大多數行為正確的任務都是獨立的: 它們不依賴于其他任務的執行時序、 執 行結果或其他效果。 當在線程池中執行獨立的任務時, 可以隨意地改變線程池的大小和配置, 這些修改只會對執行性能產生影響。 然而,如果提交給線程池的任務需要依賴其他的任務, 那么就隱含地給執行策略帶來了約束, 此時必須小心地維持次些執行策略以避免產生活躍性問題。

b.使用線程封閉機制的任務。 與線程池相比, 單線程的Executor能夠對并發性做出更強的承諾。 它們能確保任務不會并發地執行, 使你能夠放寬代碼對線程安全的要求。 對象可以封閉在 任務線程中, 使得在該線程中執行的任務在訪問該對象時不需要同步, 即使這些資源不是線程 安全的也沒有問題。 這種情形將在任務與執行策略之間形成隱式的耦合- ??任務要求其執行所在的Executor是單線程的e。如果將Executor從單線程環境改為線程池環境, 那么將會失去線程安全性。

c.對響應時間敏感的任務。GUI應用程序對于響應時間是敏感的:如果用戶在點擊按鈕后需要很長延遲才能得到可見的反饋, 那么他們會感到不滿。如果將一個運行時間較長的任務提交到單線程的Executor中, 或者將多個運行時間較長的任務提交到一個只包含少量線程的線程池 中, 那么將降低由該Executor管理的服務的響應性。

d.使用ThreadLocal的任務。ThreadLocal使每個線程都可以擁有某個變量的一個私有“版本“。然而,只要條件允許,Executor可以自由地重用這些線程。在標準的Executor實現中,當執行需求較低時將回收空閑線程,而當需求增加時將添加新的線程,并且如果從任務中拋出了一個未檢查異常,那么將用一個新的工作者線程來替代拋出異常的線程。只有當線程本地值 的生命周期受限于任務的生命周期時,在線程池的線程中使用ThreadLocal才有意義,而在線 程池的線程中不應該使用 ThreadLocal在任務之間傳遞值。

????????只有當任務都是同類型的并且相互獨立時,線程池的性能才能達到最佳。如果將運行時間較長的與運行時間較短的任務混合在一起,那么除非線程池很大,否則將可能造成 “擁塞 ”。如果提交的任務依賴于其他任務,那么除非線程池無限大,否則將可能造成死鎖。幸運的是, 在基于網絡的典型股務器應用程序中一一網頁服務器、郵件服務器以及文件服務器等,它們的 請求通常都是同類型的并且相互獨立的。

? ??????在一些任務中,需要擁有或排除某種特定的執行策略。如果某些任務依賴于其他的任務,那么會要求線程池足夠大,從而確保它們依賴任務不會被放入等待隊列中或被拒絕,而采用線程封閉機制的任務需要串行執行。通過將這些需求寫入文檔,將來的代碼維護人員就不會由于使用了某種不合適的執行策略而破壞安全性或活躍性。

線程饑餓死鎖

????????在線程池中,如果任務依賴于其他任務,那么可能產生死鎖。在單線程的Executor中,如 果一個任務將另一個任務提交到同一個Executor,并且等待這個被提交任務的結果,那么通常會引發死鎖。第二個任務停留在工作隊列中,并等待第一個任務完成,而第一個任務又無法完 成,因為它在等待第二個任務的完成。在更大的線程池中, 只要線程池中的任務需要無限期地等待一些必須由池中 其他任務才能提供的資源或條件,例如某個任務等待另一個任務的返回值或執行結果,那么除 非線程池足夠大,否則將發生線程饑餓死鎖。?

????????在程序清單8-1的 ThreadDeadlock中給出了線程饑餓死鎖的示例。RenderPageTask向Executor提交了兩個任務來獲取網頁的頁眉和頁腳,繪制頁面,等待獲取頁眉和頁腳任務的結果,然后將頁眉、頁面主體和頁腳組合起來并形成最終的頁面。如果使用單線程的Executor,那么ThreadDeadlock會經常發生死鎖。同樣,如果線程池不夠大,那么當多個任務通過柵欄 (Barrier)機制來彼此協調時,將導致線程饑餓死鎖。


? ? ? ? 每當提交了一個有依賴性Executor任務時,要清楚地知道可能會出現線程“饑餓”死鎖,因此需要在代碼或配置Executor的配置文件中記錄線程池的大小限制或配置限制。

? ??????除了在線程池大小上的顯式限制外, 還可能由于其他資源上的約束而存在一些隱式限制。如果應用程序使用一個包含10個連接的JDBC連接池, 并且每個任務需要一個數據庫連接, 那么線程池就好像只有10個線程, 因為當超過10個任務時, 新的任務需要等待其他任務釋放連接。

運行時間較長的任務

????????如果任務阻塞的時間過長, 那么即使不出現死鎖, 線程池的響應性也會變得糟糕。執行時 間較長的任務不僅會造成線程池堵塞, 甚至還會增加執行時間較短任務的服務時間。如果線程 池中線程的數量遠小于在穩定狀態下執行時間較長任務的數量, 那么到最后可能所有的線程都會運行這些執行時間較長的任務, 從而影響整體的響應性。

????????有一項技術可以緩解執行時間較長任務造成的影響, 即限定任務等待資源的時間, 而不要無限制地等待。在平臺類庫的大多數可阻塞方法中, 都同時定義了限時版本和無限時版本, 例如Thread.join、BlockingQueue.put、CountDownLatch.await以及Selector.select等。如果等待超時,那么可以把任務標識為失敗,然后中止任務或者將任務重新放回隊列以便隨后執行。這樣, 無論任務的最終結果是否成功, 這種辦法都能確保任務總能繼續執行下去, 并將線程釋放 出來以執行一些能更快完成的任務。如果在線程池中總是充滿了被阻塞的任務, 那么也可能表明線程池的規模過小。


設置線程池的大小

? ??????線程池的理想大小取決于被提交任務的類型以及所部署系統的特性。在代碼中通常不會固定線程池的大小, 而應該通過某種配置機制來提供,或者根據Runtime.availableProcessors來動態計算。

????????幸運的是, 要設置線程池的大小也并不困難, 只需要避免 “過大” 和 “過小” 這兩種極端情況。如果線程池過大,那么大量的線程將在相對很少的CPU和內存資源上發生競爭, 這不僅會導致更高的內存使用量, 而且還可能耗盡資源。如果線程池過小, 那么將導致許多空閑的處理器無法執行工作,從而降低吞吐率。

? ??????要想正確地設置線程池的大小,必須分析計算環境、資源預算和任務的特性。在部署的系統中有多少個CPU? 多大的內存?任務是計算密集型、I/0密集型還是二者皆可?它們是否需要像JDBC連接這樣的稀缺資源?如果需要執行不同類別的任務,井且它們之間的行為相差很大,那么應該考慮使用多個線程池,從而使每個線程池可以根據各自的工作負載來調整。

????????對于計算密集型的任務,在擁有N個處理器的系統上,當線程池的大小為N+1時,通常能實現最優的利用率。(即使當計算密集型的線程偶爾由于頁缺失故障或者其他原因而暫停時,這個 “額外 ” 的線程也能確保CPU的時鐘周期不會被浪費。)要正確地設置線程 池的大小,你必須估算出任務的等待時間與計算時間的比值。這種估算不需要很精確,并且可 以通過一些分析或監控工具來獲得。你還可以通過另一種方法來調節線程池的大小:在某個基準負載下,分別設置不同大小的線程池來運行應用程序,并觀察CPU利用率的水平。

????????當然,CPU周期并不是唯一影響線程池大小的資源,還包括內存、文件句柄、套接字句柄和數據庫連接等。計算這些資源對線程池的約束條件是更容易的:計算每個任務對該資源的需求量,然后用該資源的可用總量除以每個任務的需求量,所得結果就是線程池大小的上限。

????????當任務需要某種通過資源池來管理的資源時,例如數據庫連接,那么線程池和資源池的大小將會相互影響。如果每個任務都需要一個數據庫連接,那么連接池的大小就限制了線程池的 大小。同樣,當線程池中的任務是數據庫連接的唯一使用者時,那么線程池的大小又將限制連 接池的大小。

配置ThreadPoolExecutor

? ??????ThreadPoolExecutor為一些Executor提供了基本的實現,這些Executor是由 Executors 中 的newCachedThreadPool、newFixedThreadPool和newScheduledThreadExecutor等工廠方法返回的。 ThreadPoolExecutor是一個靈活的、穩定的線程池,允許進行各種定制。

? ??????如果默認的執行策略不能滿足需求,那么可以通過 ThreadPoolExecutor的構造函數來實例化一個對象,并根據自己的需求來定制,并且可以參考Executors的源代碼來了解默認配置下的執行策略, 然后再以這些執行策略為基礎進行修改。ThreadPoolExecutor定義了很多構造數, 在程序清單8-2中給出了最常見的形式。


線程的創建與銷毀

????????線程池的基本大小(Core Pool Size)、最大大小(Maximum Pool Size)以及存活時間等因素共同負責線程的創建與銷毀。 基本大小也就是線程池的目標大小, 即在沒有任務執行時線程池的大小, 并且只有在工作隊列滿了的情況下才會創建超出這個數益的線程。 線程池的最大大小表示可同時活動的線程數最的上限。 如果某個線程的空閑時間超過了存活時間, 那么將被標記為可回收的, 并且當線程池的當前大小超過了基本大小時, 這個線程將被終止。

? ??????通過調節線程池的基本大小和存活時間, 可以幫助線程池回收空閑線程占有的資源,從而使得這些資源可以用于執行其他工作。(顯然, 這是種折衷: 回收空閑線程會產生額外的延遲, 因為當需求增加時, 必須創建新的線程來滿足需求。)

????????newFixedThreadPool工廠方法將線程池的基本大小和最大大小設置為參數中指定的值, 而且創建的線程池不會超時。newCachedThreadPool工廠方法將線程池的最大大小設置為Integer. MAX_VALUE, 而將基本大小設置為零, 并將超時設置為1分鐘, 這種方法創建出來的線 程池可以被無限擴展, 并且當需求降低時會自動收縮。其他形式的線程池可以通過顯式的 ThreadPoolExecutor構造函數來構造。

管理隊列任務

? ??????在有限的線程池中會限制可并發執行的任務數量。(單線程的Executor是一種值得注意的特例:它們能確保不會有任務并發執行, 因為它們通過線程封閉來實現線程安全性。)

?????????如果無限制地創建線程, 那么將導致不穩定性, 并通過采用固定大小的線程池(而不是每收到一個請求就創建一個新線程 )來解決這個問題。然而,這個方案并不完整。在高負載情況下, 應用程序仍可能耗盡資源, 只是出現問題的概率較小。 如果新請求的到達速率超過了線程池的處理速率, 那么新到來的請求將累積起來。在線程池中,這些請求會在一個由 Executor管理的 Runnable 隊列中等待,而不會像線程那樣去競爭 CPU資源。 通過 一個 Runnable 和一個鏈表節點來表現一個等待中的任務, 當然比使用線程來表示的開銷低很多, 但如果客戶提交給服務器請求的速率超過了服務器的處理速率,那么仍可能會耗盡資源。

????????即使請求的平均到達速率很穩定, 也仍然會出現請求突增的情況。 盡管隊列有助于緩解任 務的突增問題,但如果任務持續高速地到來,那么最終還是會抑制請求的到達率以避免耗盡內存。甚至在耗盡內存之前,響應性能也將隨著任務隊列的增長而變得越來越糟。

????????ThreadPoolExecutor 允許提供一個 BlockingQueue 來保存等待執行的任務。 基本的任務排隊方法有 3 種: 無界隊列、有界隊列和同步移交 (Synchronous Handoff)。隊列的選擇與其他的配置參數有關,例如線程池的大小等。

????????newFixedThreadPool 和 newSingleThreadExecutor在默認情況 下將使用一個無界的 LinkedBlockingQueue。如果所有工作者線程都處于忙碌狀態, 那么任務將在隊列中等候。如果任務持續快速地到達, 并且超過了線程池處理它們的速度, 那么隊列將無限制地增加。

????????一種更穩妥的資源管理策略是使用有界隊列,例如 ArrayBlockingQueue、有界的LinkedBlockingQueue、 PriorityBlockingQueue。有界隊列有助于避免資源耗盡的情況發生, 但它又帶來了新的問題: 當隊列填滿后,新的任務該怎么辦? (有許多飽和策略 (Saturation Policy] 可以解決這個問題。請參見 8.3.3 節。)在使用有界的工作隊列時,隊列的大小與線程池的大小必須一起調節。如果線程池較小而隊列較大,那么有助于減少內存使用量,降低 CPU 的使用率,同時還可以減少上下文切換, 但付出的代價是可能會限制吞吐量

? ??????對于非常大的或者無界的線程池,可以通過使用 SynchronousQueue 來避免任務排隊以及直接將任務從生產者移交給工作者線程。 SynchronousQueue 不是一個真正的隊列, 而是一 種在線程之間進行移交的機制。 要將一個元素 放入 SynchronousQueue 中, 必須有另一個線程正在等待接受這個元素。如果沒有線程正在等待, 并且線程池的當前大小小于最大值, 那么ThreadPoolExecutor 將創建一個新的線程, 否則根據飽和策略, 這個任務將被拒絕。 使用直接移交將更高效, 因為任務會直接移交給執行它的線程, 而不是被首先放在隊列中,然后由工作 者線程從隊列中提取該任務。 只有當線程池是無界的或者可以拒絕任務時, SynchronousQueue才有實際價值。在 newCachedThreadPool 工廠方法中就使用了 SynchronousQueue。

????????當使用像 LinkedBlockingQueue 或 ArrayBlockingQueue 這樣的 FIFO(先進先出)隊列時, 任務的執行順序與它們的到達順序相同。如果想進一步控制任務執行順序, 還可以使用PriorityBlockingQueue, 這個隊列將根據優先級來安排任務。任務的優先級是通過自然順序或Comparator (如果任務實現了Comparable)來定義的。

? ??????只有當任務相互獨立時, 為線程池或工作隊列設置界限才是合理的。如果任務之間存在依賴性, 那么有界的線程池或隊列就可能導致線程 ” 饑餓” 死鎖問題。此時應該使用無界的線程 池, 例如newCachedThreadPool

飽和策略

? ??????當有界隊列被填滿后, 飽和策略開始發揮作用。ThreadPoolExecutor 的飽和策略可以通過調用setRejectedExecutionHandler 來修改。(如果某個任務被提交到一個巳被關閉的Executor時,也會用到飽和策略。) JDK提供了幾種不同的RejectedExecutionHandler實現,每種實現都包含有不固的飽和策略: AbortPolicy、CallerRunsPolicy、DiscardPolicy和DiscardOldestPolicy。

? ? ? ? "中止(Abort)"策略是默認的飽和策略,該策略將拋出未檢查的RejectedExecution--Exception。調用者可以捕獲這個異常,然后根據需求編寫自己的處理代碼。當新提交的任務法保存到隊列中等待執行時,“拋棄(Discard)"策略會悄悄拋棄該任務。 “拋棄最舊的( Discard-Oldest)"策略則會拋棄下一個將被執行的任務, 然后嘗試重新提交新的任務。(如果工 作隊列是一個優先隊列, 那么 “拋棄最舊的” 策略將導致拋棄優先級最高的任務, 因此最好不要將 “拋棄最舊的" 飽和策略和優先級隊列放在一起使用。)

????????“調用者運行(Caller-Runs)"策略實現了一種調節機制, 該策略既不會拋棄任務, 也不會拋出異常, 而是將某些任務回退到調用者, 從而降低新任務的流量。 它不會在線程池的某個線程中執行新提交的任務, 而是在一個調用了execute的線程中執行該任務。 我們可以將WebServer示例修改為使用有界隊列和 “ 調用者運行” 飽和策略, 當線程池中的所有線程都被占用, 并且工作隊列被填滿后, 下一個任務會在調用execute 時在主線程中執行門 由于執行任 務需要一定的時間, 因此主線程至少在一段時間內不能提交任何任務, 從而使得工作者線程有時間來處理完正在執行的任務。在這期間, 主線程不會調用accept, 因此到達的請求將被保存 在TCP層的隊列中而不是在應用程序的隊列中。 如果持續過載, 那么TCP層將最終發現它的 請求隊列被填滿, 因此同樣會開始拋棄請求。 當服務器過載時, 這種過載情況會逐漸向外蔓延開來-從線程池到工作隊列到應用程序再到TCP層, 最終達到客戶端, 導致服務器在高負載下實現一種平緩的性能降低。

????????當創建 Executor 時, 可以選擇飽和策略或者對執行策略進行修改。 程序消單 8-3 給出了如何創建一個固定大小的線程池, 同時使用 “調用者運行” 飽和策略。

????????當工作隊列被填滿后, 沒有預定義的飽和策略來阻塞 execute 。然而, 通過使用 Semaphore(信號量)來限制任務的到達率,就可以實現這個功能。 在程序清單 8-4 的 BoundedExecutor中給出了這種方法。 該方法使用了一個無界隊列(因為不能限制隊列的大小和任務的到達率), 并設置信號量的上界設置為線程池的大小加上可排隊任務的數量, 這是因為信號量需要控制正在執行的和等待執行的任務數量。

線程工廠

? ??????每當線程池需要創建一個線程時, 都是通過線程工廠方法(請參見程序清單8-5)來完成的。默認的線程工廠方法將創建一個新的、非守護的線程, 并且不包含特殊的配置信息。通過指定一個線程工廠方法, 可以定制線程池的配置信息。在ThreadFactoi;y 中只定義了一個方法newThread, 每當線程池需要創建一個新線程時都會調用這個方法。

????????然而, 在許多情況下都需要使用定制的線程工廠方法。例如, 你希望為線程池中的線程指定一個UncaughtExceptionHandler, 或者實例化一個定制的Thread 類用于執行調試信息的記錄。你還可能希望修改線程的優先級(這通常并不是一個好主意。請參見10.3.1節)或者守護狀態(同樣, 這也不是一個好主意。請參見7.4.2節)。或許你只是希望給線程取一個更有意義的名稱, 用來解釋線程的轉儲信息和錯誤日志。


????????在程序清單8-6 的MyThreadFactory 中給出了一個自定義的線程工廠。它創建了一個新的My App Thread 實例, 并將一個特定千線程池的名字傳遞給MyAppThread 的構造函數, 從而可以在線程轉儲和錯誤日志信息中區分來自不同線程池的線程. 在應用程序的其他地方也可以使用MyAppThread, 以便所有線程都能使用它的調試

????????在MyApp Thread 中還可以定制其他行為, 如程序清單8-7所示,包括: 為線程指定名字,設置自定義UncaughtExceptionHandler 向Logger 中寫入信息, 維護一些統計信息(包括有多少個線程被創建和銷毀), 以及在線程被創建或者終止時把調試消息寫入日志。

????????如果在應用程序中需要利用安全策略來控制對某些特殊代碼庫的訪問權限, 那么可以通過 Executor 中的 privilegedThreadFactory 工廠來定制自己的線程工廠。 通過這種方式創建出來的 線程, 將與創建 privilegedThreadFactory 的線程擁有相同的訪問權限、 AccessControlContext 和 contextClassLoader。 如果不使用 privilegedThreadFactory, 線程池創建的線程將從在需要新 線程時調用 execute 或 submit 的客戶程序中繼承訪問權限, 從而導致令人困惑的安全性異常。

在調用構造函數后再定制 ThreadPoolExecutor

????????在調用完 ThreadPoolExecutor 的構造函數后, 仍然可以通過設置函數 (Setter) 來修改大多數傳遞給它的構造函數的參數(例如線程池的基本大小、 最大大小、 存活時間、 線程工廠以及拒絕執行處理器 (Rejected Execution Handler)) 。 如果Executor 是通過 Executors 中 的某個 (newSingleTbreadExecutor 除外)工廠方法創建的, 那么可以將結果的類型轉換為 ThreadPoolExecutor 以訪問設置器, 如程序清單 8-8 所示。

????????在 Executors中包含一個 unconfiurableExecutorService 工廠方法, 該方法對一個現有的 ExecutorService 進行包裝, 使其只暴露出 ExecutorService 的方法, 因此不能對它進行配置。 newSingleThreadExecutor 返回按這種方式封裝的 ExecutorService, 而不是最初的 ThreadPoolExecutor。雖然單線程的 Executor 實際上被實現為一個只包含唯一線程的線程池,但它同樣確保了不會并發地執行任務。如果在代碼中增加單線程 Executor 的線程池大小, 那么將破壞它的執行語義。

????????你可以在自己的 Executor 中使用這項技術以防止執行策略被修改。如果將 ExecutorService 暴露給不信任的代碼, 又不希望對其進行修改,就可以通過 unconfigurableExecutorService 來 包裝它。·

展ThreadPoolExecutor

????????ThreadPoolExecutor 是可擴展的, 它提供了幾個可以在子類化中改寫的方法: beforeExecute、 afteExecute 和 terminated, 這些方法可以用于擴展 ThreadPoolExecutor 的行為。

????????在執行任務的線程中將調用 beforeExecute 和 afterExecute 等方法, 在 這些方法中還可以添加日志、計時、監視或統計信息收集的功能。無論任務是從 run 中正常返回,還是拋出一個 異常而返回, afterExecute 都會被調用。(如果任務在完成后帶有一個 Error, 那么就不會調用 after Execute。)如果 beforeExecute 拋出一個 RuntimeException, 那么任務將不被執行, 并且 afterExecute 也不會被調用。

????????在線程池完成關閉操作時調用 terminated, 也就是在所有任務都已經完成并且所有工作者 線程也巳經關閉后。 terminated 可以用來釋放 Executor 在其生命周期里分配的各種資源, 此外還可以執行發送通知、 記錄日志或者收集 finalize 統計信息等操作。、

遞歸算法的并行化

????????我們對6.3節的頁面繪制程序進行了一系列的改進以便不斷發掘可利用的并行性。 第一次是使程序完全串行執行, 第二次雖然使用了兩個線程, 但仍然是串行地下載所有圖像:在最后一次實現中將每個圖像的下載操作視為一個獨立任務, 從而實現了更高的并行性。 如果在循環體中包含了 些密集計算, 或者需要執行可能阻塞的I/0操作, 那么只要每次迭代是獨立的, 都可以對其進行并行化。

????????如果循環中的迭代操作都是獨立的, 并且不需要等待所有的迭代操作都完成再繼續執行,那么就可以使用Executor將串行循環轉化為并行循環, 在程序清單8-10的processSequentially 和processlnParallel中給出了這種方法。

????????調用 processlnParallel 比調用 processSequentially 能更快地返回, 因為 processinParallel 會 在所有下載任務都進入了Executor 的隊列后就立即返回, 而不會等待這些任務全部完成。 如果需要提交一個任務集并等待它們完成, 那么可以使用 ExecutorService.invokeAll, 并且在所有任 務都執行完成后調用 CompletionService 來獲取結果,如第 6 章的 Renderer 所示。

????????當串行循環中的各個迭代操作之間彼此獨立, 并且每個迭代操作執行的工作籃比管理一個 新任務時帶來的開銷更多, 那么這個串行循環就適合并行化。

小結

????????對于井發執行的任務,Executor框架是一種強大且靈活的框架。 它提供了大量可調節的選項, 例如創建線程和關閉線程的策略, 處理隊列任務的策略, 處理過多任務的策略, 井且提供了幾個釣子方法來擴展它的行為。 然而, 與大多數功能強大的框架一樣, 其中有些設置參數并不能很好地工作,某些類型的任務需要特定的執行策略, 而 些參數組合則可能產生奇怪的結果。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容