【進階之路】線程池配置與調優的一些高級選項(一)


這一篇的內容主要來自於《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等。如果等待超時,那么可以把任務標識為失敗,然后中止任務或者將任務重新放回隊列。這樣,無論任務的最終結果是否是成功,這種辦法都能保證任務可以順利執行而不會被阻塞住,並將線程釋放出來執行一些能更快完成的任務。

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

我是南橘,一名學習時長兩年的程序員,希望這篇文章能幫助到大家。下面是我的微信二維碼,有興趣的朋友可以一起交流學習。
微信公眾號,有興趣的小伙伴來關注一下吧~我最新的內容都會在這里發布


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM