多執行緒:執行緒池理解和使用總結

無名氏生發表於2020-08-04

建立和銷燬執行緒非常損耗效能,那有沒有可能複用一些已經被建立好的執行緒呢?答案是肯定的,那就是執行緒池。

另外,執行緒的建立需要開闢虛擬機器棧、本地方法棧、程式計數器等執行緒私有的記憶體空間,線上程銷燬時需要回收這些系統資源,頻繁地建立銷燬執行緒會浪費大量資源,而通過複用已有執行緒可以更好地管理和協調執行緒的工作。

執行緒池主要解決兩個問題:
1、當執行大量非同步任務時執行緒池能夠提供很好的效能。
2、執行緒池提供了一種資源限制和管理的手段,比如可以限制執行緒的個數,動態新增執行緒。

建立執行緒池
為了更方便的使用執行緒池,JDK 中給我們提供了一個執行緒池的工廠類Executors。在 Executors 中定義了多個靜態方法,用來建立不同配置的執行緒池。常見有以下幾種:

newSingleThreadExecutor
建立一個單執行緒化的執行緒池,它只會用唯一的工作執行緒來執行任務,保證所有任務按先進先出的順序執行。

 

執行上述程式碼結果如下,可以看出所有的 task 始終是在同一個執行緒中被執行的。

 

 

 

newCachedThreadPool
建立一個可快取執行緒池,如果執行緒池長度超過處理需要,可靈活回收空閒執行緒,若無可回收,則新建執行緒。

 

 

 

執行效果如下:

 

 

 

從上面日誌中可以看出,快取執行緒池會建立新的執行緒來執行任務。但是如果將程式碼修改一下,在提交任務之前休眠 1 秒鐘,如下:

 

再次執行則列印日誌同 SingleThreadPool 一模一樣,原因是提交的任務只需要 500 毫秒即可執行完畢,休眠 1 秒導致在新的任務提交之前,執行緒 “pool-1-thread-1” 已經處於空閒狀態,可以被複用執行任務。

newFixedThreadPool
建立一個固定數目的、可重用的執行緒池。

 

 

 上述程式碼建立了一個固定數量 3 的執行緒池,因此雖然向執行緒池提交了 10 個任務,但是這 10 個任務只會被 3 個執行緒分配執行,執行效果如下:

 

 

 

 newScheduledThreadPool

建立一個定時執行緒池,支援定時及週期性任務執行。

 

 

 上面程式碼建立了一個執行緒數量為 2 的定時任務執行緒池,通過 scheduleAtFixedRate 方法,指定每隔 500 毫秒執行一次任務,並且在 5 秒鐘之後通過 shutdown 方法關閉定時任務。執行效果如下:

 

 

 上面這幾種就是常用到的執行緒池使用方式,但是!在阿里Java開發手冊中已經嚴禁使用 Executors 來建立執行緒池,這是為什麼?要回答這個問題需要先了解執行緒池的工作原理。

執行緒池工作原理分析

 

 

 

執行緒池的構造器如下:

 

 

 

  • corePoolSize:表示核心執行緒數量
  • maximumPoolSize:表示執行緒池最大能夠容納同時執行的執行緒數,必須大於或等於 1。如果和 corePoolSize 相等即是固定大小執行緒池
  • keepAliveTime:表示執行緒池中的執行緒空閒時間,當空閒時間達到此值時,執行緒會被銷燬直到剩下 corePoolSize 個執行緒
  • unit:用來指定 keepAliveTime 的時間單位,有 MILLISECONDS、SECONDS、MINUTES、HOURS 等
  • workQueue:等待佇列,BlockingQueue 型別。當請求任務數大於 corePoolSize 時,任務將被快取在此 BlockingQueue 中
  • threadFactory:執行緒工廠,執行緒池中使用它來建立執行緒,如果傳入的是 null,則使用預設工廠類 DefaultThreadFactory
  • handler:執行拒絕策略的物件。當 workQueue 滿了之後並且活動執行緒數大於 maximumPoolSize 的時候,執行緒池通過該策略處理請求

需要注意的是當ThreadPoolExecutor 的 allowCoreThreadTimeOut 設定為 true 時,核心執行緒超時後也會被銷燬。

流程解析

當我們呼叫 execute 或者 submit,將一個任務提交給執行緒池,執行緒池收到這個任務請求後,有以下幾種處理情況:

1、當前執行緒池中執行的執行緒數量還沒有達到 corePoolSize 大小時,執行緒池會建立一個新執行緒執行提交的任務,無論之前建立的執行緒是否處於空閒狀態。

 

 上面程式碼建立了 3 個固定數量的執行緒池,每次提交的任務耗時 100 毫秒。每次提交任務之前都會延遲2秒,保證執行緒池中的工作執行緒都已經執行完畢,但是執行效果如下:

 

 可以看出雖然執行緒 1 和執行緒 2 都已執行完畢並且處於空閒狀態,但是執行緒池還是會嘗試建立新的執行緒去執行新提交的任務,直到執行緒數量達到 corePoolSize。

2、當前執行緒池中執行的執行緒數量已經達到 corePoolSize 大小時,執行緒池會把任務加入到等待佇列中,直到某一個執行緒空閒了,執行緒池會根據我們設定的等待佇列規則,從佇列中取出一個新的任務執行。

 

 上述程式碼提交的任務耗時 4 秒,因此前 2 個任務會佔用執行緒池中的 2 個核心執行緒。此時有新的任務提交給執行緒池時,任務會被快取到等待佇列中,結果如下:

 

 

可以看到紅框 1 中通過 2 個核心執行緒直接執行提交的任務,因此等待佇列中的數量為 0;而紅框 2 中表明,此時核心執行緒都已經被佔用,新提交的任務都被放入等待佇列中。

3、如果執行緒數大於 corePoolSize 數量但是還沒有達到最大執行緒數 maximumPoolSize,並且等待佇列已滿,則執行緒池會建立新的執行緒來執行任務。

 

 上述程式碼建立了一個核心執行緒數為 2,最大執行緒數為 10,等待佇列長度為 2 的執行緒池。執行效果如下:

解釋說明:

  • 1 處表示執行緒數量已經達到 corePoolSize
  • 2 處表明等待佇列已滿
  • 3 處會建立新的執行緒執行任務

4、最後如果提交的任務,無法被核心執行緒直接執行,又無法加入等待佇列,又無法建立“非核心執行緒”直接執行,執行緒池將根據拒絕處理器定義的策略處理這個任務。比如在 ThreadPoolExecutor 中,如果你沒有為執行緒池設定 RejectedExecutionHandler。這時執行緒池會丟擲 RejectedExecutionException 異常,即執行緒池拒絕接受這個任務。

 

 修改最大執行緒數為 3,並提交 6 次任務給執行緒池,執行效果如下:

 

 程式會報異常 RejectedExecutionException,拒絕策略是執行緒池的一種保護機制,目的就是當這種無節制的執行緒資源申請發生時,拒絕新的任務保護執行緒池。

為何禁止使用 Executors
現在再回頭看一下為何在阿里 Java 開發手冊中嚴禁使用 Executors 工具類來建立執行緒池。尤其是 newFixedThreadPool 和 newCachedThreadPool 這兩個方法。

比如如下使用 newFixedThreadPool 方法建立執行緒的案例:

 

 上述程式碼建立了一個固定數量為 2 的執行緒池,並通過 for 迴圈向執行緒池中提交 100 萬個任務。通過 java -Xms4m -Xmx4m FixedThreadPoolOOM 執行上述程式碼:

 

 可以發現當任務新增到 7 萬多個時,程式發生 OOM。 看一下newSingleThreadExecutor 和 newFixedThreadPool() 的具體實現,如下:

 

 

可以看到傳入的是一個無界的阻塞佇列,理論上可以無限新增任務到執行緒池。當核心執行緒執行時間很長(比如 sleep10s),則新提交的任務還在不斷地插入到阻塞佇列中,最終造成
OOM。

再看下 newCachedThreadPool 會有什麼問題。

 

 同樣會報 OOM,只是錯誤的 log 資訊有點區別:無法建立新的執行緒。

 

 看一下 newCachedThreadPool 的實現:

 

 可以看到,快取執行緒池的最大執行緒數為 Integer 最大值。當核心執行緒耗時很久,執行緒池會嘗試建立新的執行緒來執行提交的任務,當記憶體不足時就會報無法建立執行緒的錯誤。

總結
執行緒池是一把雙刃劍,使用得當會使程式碼如虎添翼;但是使用不當將會造成重大性災難。而劍柄是握在開發者手中,只有理解執行緒池的執行原理,熟知它的工作機制與使用場景,才會使這把雙刃劍發揮更好的作用。

相關文章