【進階之路】執行緒池配置與調優的一些高階選項(一)

南橘ryc發表於2020-12-08

這一篇的內容主要來自於《java併發程式設計實戰》,有一說一,看這種寫的很專業的書不是很輕鬆,也沒辦法直接提高多少開發的能力,但是卻能更加夯實基礎,就像玩war3,熟練的基本功並不能讓你快速地與對方拉開差距,但是卻能再每一次團戰中積累優勢。

一、執行緒池的基礎

1、執行緒池的相關屬性:

  • corePoolSize(執行緒池的基本大小):當提交一個任務到執行緒池時,執行緒池會建立一個執行緒來執行任務,即使其他空閒的基本執行緒能夠執行新任務也會建立執行緒,等到需要執行的任務數大於執行緒池基本大小時就不再建立。如果呼叫了執行緒池的prestartAllCoreThreads方法,執行緒池會提前建立並啟動所有基本執行緒。

  • workQueue任務佇列):用於儲存等待執行的任務的阻塞佇列。可以選擇以下幾個阻塞佇列。

    • ArrayBlockingQueue:是一個基於陣列結構的有界阻塞佇列,此佇列按 FIFO(先進先出)原則對元素進行排序。
    • LinkedBlockingQueue:一個基於連結串列結構的阻塞佇列,此佇列按FIFO (先進先出) 排序元素,吞吐量通常要高於ArrayBlockingQueue。靜態工廠方法Executors.newFixedThreadPool()使用了這個佇列
    • SynchronousQueue:一個不儲存元素的阻塞佇列。每個插入操作必須等到另一個執行緒呼叫移除操作,否則插入操作一直處於阻塞狀態,吞吐量通常要高於LinkedBlockingQueue,靜態工廠方法Executors.newCachedThreadPool使用了這個佇列。
    • PriorityBlockingQueue:一個具有優先順序的無限阻塞佇列。
  • maximumPoolSize(執行緒池最大大小):執行緒池允許建立的最大執行緒數。如果佇列滿了,並且已建立的執行緒數小於最大執行緒數,則執行緒池會再建立新的執行緒執行任務。值得注意的是如果使用了無界的任務佇列這個引數就沒什麼效果。

  • ThreadFactory:用於設定建立執行緒的工廠,可以通過執行緒工廠給每個建立出來的執行緒做些更有意義的事情,比如設定daemon和優先順序等等

  • RejectedExecutionHandler(飽和策略):當佇列和執行緒池都滿了,說明執行緒池處於飽和狀態,那麼必須採取一種策略處理提交的新任務。這個策略預設情況下是AbortPolicy,表示無法處理新任務時丟擲異常。以下是JDK1.5提供的四種策略。

    • AbortPolicy:直接丟擲異常。
    • CallerRunsPolicy:只用呼叫者所線上程來執行任務。
    • DiscardOldestPolicy:丟棄佇列裡最近的一個任務,並執行當前任務。
    • DiscardPolicy:不處理,丟棄掉。

也可以根據應用場景需要來實現RejectedExecutionHandler介面自定義策略。如記錄日誌或持久化不能處理的任務。

  • keepAliveTime(執行緒活動保持時間):執行緒池的工作執行緒空閒後,保持存活的時間。所以如果任務很多,並且每個任務執行的時間比較短,可以調大這個時間,提高執行緒的利用率。
  • TimeUnit(執行緒活動保持時間的單位):可選的單位有天(DAYS),小時(HOURS),分鐘(MINUTES),毫秒(MILLISECONDS),微秒(MICROSECONDS, 千分之一毫秒)和毫微秒(NANOSECONDS, 千分之一微秒)。

2、執行緒池的執行流程

  • 1 如果執行的執行緒少於corePoolSize,則會新增新的執行緒,而不進行排隊。
  • 2 如果執行的執行緒等於或多於 corePoolSize,則 Executor 始終首選將請求加入佇列,而不新增新的執行緒。
  • 3 如果無法將請求加入佇列(佇列已滿),則建立新的執行緒,除非建立此執行緒超出 maximumPoolSize,如果超過,在這種情況下,新的任務將被拒絕。

3、執行緒池排隊有三種通用策略

  1. 同步移交。佇列的預設選項是同步移交,它將任務直接提交給執行緒而不保持它們。在此,如果不存在可用於立即執行任務的執行緒,會構造一個新的執行緒。此策略可以避免在處理可能具有內部依賴性的請求集時出現鎖。同步移交通常要求無界的maximumPoolSizes以避免拒絕新提交的任務。

但是,同步移交不適合管理資源的分配,除非特殊情況不推薦使用

  1. 無界佇列。使用的是LinkedBlockingQueue類實現,不需要事先制定大小,也是按照“先進先出”演算法處理任務。無界佇列很好理解,就是和有界佇列相反,使用無界佇列的執行緒池,當有新任務提交時,如果執行緒池裡有空閒執行緒,就分配執行緒立刻執行任務,否則就把任務放到無界任務佇列中等待,如果執行緒池中一直沒有空閒執行緒,但是新的任務又一直不停的提交上來,那麼這些任務全部會被掛到等待佇列中,一直到記憶體全部消耗完
  2. 有界佇列。當使用有限的 maximumPoolSizes 時,有界佇列(如 ArrayBlockingQueue)有助於防止資源耗盡,但是可能較難調整和控制。佇列大小和最大池大小可能需要相互折衷:使用大型佇列和小型池可以最大限度地降低CPU使用率、作業系統資源和上下文切換開銷,但是可能導致人工降低吞吐量。使用小型佇列通常要求較大的池大小,CPU 使用率較高,但是可能遇到不可接受的排程開銷,這樣也會降低吞吐量。

我們最後可以這樣理解:

使用無界佇列可能會耗盡系統資源
使用有界佇列可能不能很好的滿足效能,需要調節執行緒數和佇列大小
執行緒數也有開銷,所以需要根據不同應用進行調節

二、設定執行緒池的大小

執行緒池的大小一直是大家很關心的問題,理想的大小取決於被提交任務的型別以及所部署的系統,程式碼中通常不會固定執行緒池的大小,而通過某種配置,或者Runtime.getRuntime().availableProcessors() 來動態計算。

Runtime.getRuntime().availableProcessors()這個程式碼大家可能不算太熟悉,這個方法可以獲取CPU的數目。

  • 如果是CPU密集型應用,則執行緒池大小設定為N+1(或者是N),執行緒的應用場景:主要是複雜演算法
  • 如果是IO密集型應用,則執行緒池大小設定為2N+1(或者是2N),執行緒的應用場景:主要是:資料庫資料的互動,檔案上傳下載,網路資料傳輸等等。
    綜合起來最佳執行緒數目 = ((執行緒等待時間+執行緒CPU時間)/執行緒CPU時間 +1)* CPU數目
    至於+1的原因,則是當執行緒偶爾由於缺失故障或者其他原因而暫停時,這個額外的執行緒也能確保CPU的時鐘週期不會被浪費(剩餘價值壓榨的滿滿的)。
    當然,CPU並不是唯一影響執行緒池大小的資源,還應該考慮記憶體、檔案控制程式碼、套接字控制程式碼、資料庫連線等原因。

舉個例子,比如平均每個執行緒CPU執行時間為0.5s,而執行緒等待時間(非CPU執行時間,比如IO)為1.5s,CPU核心數為12,那麼根據上面這個公式估算得到: ((0.5+1.5)/0.5+1)12=60

除了執行緒池大小上的顯示設定以外,還可能由於其他資源上的約束而存在一些隱式限制,如應用程式使用一個包含10個連線的JDBC連線池,並且每個任務需要一個資料庫連線,那麼執行緒池就最好只有10個連線,因為當超過10個任務時,新的任務就需要其他任務釋放連線。

三、避免執行緒池的飢餓死鎖

1、執行緒的飢餓死鎖

執行緒池中,如果任務依賴於其他任務,那麼就有可能產生死鎖。在單執行緒的Executor中,如果一個任務將另一個任務提交到同一個Executor,並且等待這個被提交的任務的結果,那麼就通常會產生死鎖,這種情況被稱為執行緒的飢餓死鎖

對於執行緒池來說,只要池任務開始了無限期阻塞,例如某個任務的目的是等待一些資源或條件,但是隻有另一個池任務的執行才能使那些條件成立。除非能保證執行緒池足夠大,否則會發生執行緒飢餓死鎖

下文清晰地展示了執行緒飢餓死鎖的示例,佇列為阻塞佇列,因為執行緒池為是單執行緒的,當佇列為空時,getHeader 將會一直阻塞等待 putHeader 執行。這就是任務之間相互依賴的飢餓死鎖。

public class ThreadDeadlock {
    //建立一個佇列、假設是存放標頭檔案的地方
    private static BlockingQueue root = new ArrayBlockingQueue(10);
    public static void main(String[] args) {
        //建立一個固定執行緒的執行緒池
        ExecutorService service = Executors.newSingleThreadExecutor();
        service.submit(new getHeader());
        service.submit(new putHeader(1));
        service.shutdown();
    }
    static class putHeader implements Callable {
        private int val;
        public putHeader(int value) {
            val = value;
        }
        @Override
        public Object call() throws Exception {
            System.out.println("放置標頭檔案");
            //往阻塞佇列增加元素
            root.put(1);
            return "標頭檔案";
        }
    }
    static class getHeader implements Callable {
        @Override
        public Object call() throws Exception {
            System.out.println("獲取標頭檔案");
            //取出阻塞佇列的值,如果沒有則會阻塞
            int value = (int) root.take();
            return "標頭檔案";
        }
    }
}

2、執行時間較長的任務

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

可以通過限定任務等待資源的時間,不要去無限制地等待。在平臺類庫的大多數可阻塞方法中,都同時定義了限時版本和無限時版本,列如Thread.join、BlockingQueue.out、CountDownLatch.await以及Selector.select等。如果等待超時,那麼可以把任務標識為失敗,然後中止任務或者將任務重新放回佇列。這樣,無論任務的最終結果是否是成功,這種辦法都能保證任務可以順利執行而不會被阻塞住,並將執行緒釋放出來執行一些能更快完成的任務。

當然了,如果執行緒池中總是充滿了被阻塞的任務,也說明執行緒池設計的規模小了。

我是南橘,一名學習時長兩年的程式設計師,希望這篇文章能幫助到大家。下面是我的微信二維碼,有興趣的朋友可以一起交流學習。

在這裡插入圖片描述

相關文章