計算機程式的思維邏輯 (78) - 執行緒池

swiftma發表於2017-04-10

本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結

計算機程式的思維邏輯 (78) - 執行緒池

上節,我們初步探討了Java併發包中的任務執行服務,實際中,任務執行服務的主要實現機制是執行緒池,本節,我們就來探討執行緒池。

基本概念

執行緒池,顧名思義,就是一個執行緒的池子,裡面有若干執行緒,它們的目的就是執行提交給執行緒池的任務,執行完一個任務後不會退出,而是繼續等待或執行新任務。執行緒池主要由兩個概念組成,一個是任務佇列,另一個是工作者執行緒,工作者執行緒主體就是一個迴圈,迴圈從佇列中接受任務並執行,任務佇列儲存待執行的任務。

執行緒池的概念類似於生活中的一些排隊場景,比如在火車站排隊購票、在醫院排隊掛號、在銀行排隊辦理業務等,一般都由若干個視窗提供服務,這些服務視窗類似於工作者執行緒,而佇列的概念是類似的,只是,在現實場景中,每個視窗經常有一個單獨的佇列,這種排隊難以公平,隨著資訊化的發展,越來越多的排隊場合使用虛擬的統一佇列,一般都是先拿一個排隊號,然後按號依次服務。

執行緒池的優點是顯而易見的:

  • 它可以重用執行緒,避免執行緒建立的開銷
  • 在任務過多時,通過排隊避免建立過多執行緒,減少系統資源消耗和競爭,確保任務有序完成

Java併發包中執行緒池的實現類是ThreadPoolExecutor,它繼承自AbstractExecutorService,實現了ExecutorService,基本用法與上節介紹的類似,我們就不贅述了。不過,ThreadPoolExecutor有一些重要的引數,理解這些引數對於合理使用執行緒池非常重要,接來下,我們探討這些引數。

理解執行緒池

構造方法

ThreadPoolExecutor有多個構造方法,都需要一些引數,主要構造方法有:

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue)
public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler) 
複製程式碼

第二個構造方法多了兩個引數threadFactory和handler,這兩個引數一般不需要,第一個構造方法會設定預設值。

引數corePoolSize, maximumPoolSize, keepAliveTime, unit用於控制執行緒池中執行緒的個數,workQueue表示任務佇列,threadFactory用於對建立的執行緒進行一些配置,handler表示任務拒絕策略。下面我們再來詳細探討下這些引數。

執行緒池大小

執行緒池的大小主要與四個引數有關:

  • corePoolSize:核心執行緒個數
  • maximumPoolSize:最大執行緒個數
  • keepAliveTime和unit:空閒執行緒存活時間

maximumPoolSize表示執行緒池中的最多執行緒數,執行緒的個數會動態變化,但這是最大值,不管有多少任務,都不會建立比這個值大的執行緒個數。

corePoolSize表示執行緒池中的核心執行緒個數,不過,這並不是說,一開始就建立這麼多執行緒,剛建立一個執行緒池後,實際上並不會建立任何執行緒

一般情況下,有新任務到來的時候,如果當前執行緒個數小於corePoolSiz,就會建立一個新執行緒來執行該任務,需要說明的是,即使其他執行緒現在也是空閒的,也會建立新執行緒

不過,如果執行緒個數大於等於corePoolSiz,那就不會立即建立新執行緒了,它會先嚐試排隊,需要強調的是,它是"嘗試"排隊,而不是"阻塞等待"入隊,如果佇列滿了或其他原因不能立即入隊,它就不會排隊,而是檢查執行緒個數是否達到了maximumPoolSize,如果沒有,就會繼續建立執行緒,直到執行緒數達到maximumPoolSize。

keepAliveTime的目的是為了釋放多餘的執行緒資源,它表示,當執行緒池中的執行緒個數大於corePoolSize時,額外空閒執行緒的存活時間,也就是說,一個非核心執行緒,在空閒等待新任務時,會有一個最長等待時間,即keepAliveTime,如果到了時間還是沒有新任務,就會被終止。如果該值為0,表示所有執行緒都不會超時終止。

這幾個引數除了可以在構造方法中進行指定外,還可以通過getter/setter方法進行檢視和修改。

public void setCorePoolSize(int corePoolSize)
public int getCorePoolSize()
public int getMaximumPoolSize()
public void setMaximumPoolSize(int maximumPoolSize)
public long getKeepAliveTime(TimeUnit unit)
public void setKeepAliveTime(long time, TimeUnit unit)
複製程式碼

除了這些靜態引數,ThreadPoolExecutor還可以檢視關於執行緒和任務數的一些動態數字:

//返回當前執行緒個數
public int getPoolSize()
//返回執行緒池曾經達到過的最大執行緒個數
public int getLargestPoolSize()
//返回執行緒池自建立以來所有已完成的任務數
public long getCompletedTaskCount()
//返回所有任務數,包括所有已完成的加上所有排隊待執行的
public long getTaskCount()
複製程式碼

佇列

ThreadPoolExecutor要求的佇列型別是阻塞佇列BlockingQueue,我們在76節介紹過多種BlockingQueue,它們都可以用作執行緒池的佇列,比如:

  • LinkedBlockingQueue:基於連結串列的阻塞佇列,可以指定最大長度,但預設是無界的。
  • ArrayBlockingQueue:基於陣列的有界阻塞佇列
  • PriorityBlockingQueue:基於堆的無界阻塞優先順序佇列
  • SynchronousQueue:沒有實際儲存空間的同步阻塞佇列

如果用的是無界佇列,需要強調的是,執行緒個數最多隻能達到corePoolSize,到達corePoolSize後,新的任務總會排隊,引數maximumPoolSize也就沒有意義了

另一面,對於SynchronousQueue,我們知道,它沒有實際儲存元素的空間,當嘗試排隊時,只有正好有空閒執行緒在等待接受任務時,才會入隊成功,否則,總是會建立新執行緒,直到達到maximumPoolSize。

任務拒絕策略

如果佇列有界,且maximumPoolSize有限,則當佇列排滿,執行緒個數也達到了maximumPoolSize,這時,新任務來了,如何處理呢?此時,會觸發執行緒池的任務拒絕策略。

預設情況下,提交任務的方法如execute/submit/invokeAll等會丟擲異常,型別為RejectedExecutionException。

不過,拒絕策略是可以自定義的,ThreadPoolExecutor實現了四種處理方式:

  • ThreadPoolExecutor.AbortPolicy:這就是預設的方式,丟擲異常
  • ThreadPoolExecutor.DiscardPolicy:靜默處理,忽略新任務,不拋異常,也不執行
  • ThreadPoolExecutor.DiscardOldestPolicy:將等待時間最長的任務扔掉,然後自己排隊
  • ThreadPoolExecutor.CallerRunsPolicy:在任務提交者執行緒中執行任務,而不是交給執行緒池中的執行緒執行

它們都是ThreadPoolExecutor的public靜態內部類,都實現了RejectedExecutionHandler介面,這個介面的定義為:

public interface RejectedExecutionHandler {
    void rejectedExecution(Runnable r, ThreadPoolExecutor executor);
}
複製程式碼

當執行緒池不能接受任務時,呼叫其拒絕策略的rejectedExecution方法。

拒絕策略可以在構造方法中進行指定,也可以通過如下方法進行指定:

public void setRejectedExecutionHandler(RejectedExecutionHandler handler)
複製程式碼

預設的RejectedExecutionHandler是一個AbortPolicy例項,如下所示:

private static final RejectedExecutionHandler defaultHandler =
    new AbortPolicy();
複製程式碼

而AbortPolicy的rejectedExecution實現就是丟擲異常,如下所示:

public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
    throw new RejectedExecutionException("Task " + r.toString() +
                                         " rejected from " +
                                         e.toString());
}
複製程式碼

我們需要強調下,拒絕策略只有在佇列有界,且maximumPoolSize有限的情況下才會觸發。

如果佇列無界,服務不了的任務總是會排隊,但這不見得是期望的,因為請求處理佇列可能會消耗非常大的記憶體,甚至引發記憶體不夠的異常。

如果佇列有界但maximumPoolSize無限,可能會建立過多的執行緒,佔滿CPU和記憶體,使得任何任務都難以完成。

所以,在任務量非常大的場景中,讓拒絕策略有機會執行是保證系統穩定執行很重要的方面。

執行緒工廠

執行緒池還可以接受一個引數,ThreadFactory,它是一個介面,定義為:

public interface ThreadFactory {
    Thread newThread(Runnable r);
}
複製程式碼

這個介面根據Runnable建立一個Thread,ThreadPoolExecutor的預設實現是Executors類中的靜態內部類DefaultThreadFactory,主要就是建立一個執行緒,給執行緒設定一個名稱,設定daemon屬性為false,設定執行緒優先順序為標準預設優先順序,執行緒名稱的格式為: pool-<執行緒池編號>-thread-<執行緒編號>。

如果需要自定義一些執行緒的屬性,比如名稱,可以實現自定義的ThreadFactory。

關於核心執行緒的特殊配置

執行緒個數小於等於corePoolSize時,我們稱這些執行緒為核心執行緒,預設情況下:

  • 核心執行緒不會預先建立,只有當有任務時才會建立
  • 核心執行緒不會因為空閒而被終止,keepAliveTime引數不適用於它

不過,ThreadPoolExecutor有如下方法,可以改變這個預設行為。

//預先建立所有的核心執行緒
public int prestartAllCoreThreads()
//建立一個核心執行緒,如果所有核心執行緒都已建立,返回false
public boolean prestartCoreThread()
//如果引數為true,則keepAliveTime引數也適用於核心執行緒
public void allowCoreThreadTimeOut(boolean value)
複製程式碼

工廠類Executors

類Executors提供了一些靜態工廠方法,可以方便的建立一些預配置的執行緒池,主要方法有:

public static ExecutorService newSingleThreadExecutor()
public static ExecutorService newFixedThreadPool(int nThreads)
public static ExecutorService newCachedThreadPool() 
複製程式碼

newSingleThreadExecutor基本相當於呼叫:

public static ExecutorService newSingleThreadExecutor() {
    return new ThreadPoolExecutor(1, 1,
                                0L, TimeUnit.MILLISECONDS,
                                new LinkedBlockingQueue<Runnable>());
}
複製程式碼

只使用一個執行緒,使用無界佇列LinkedBlockingQueue,執行緒建立後不會超時終止,該執行緒順序執行所有任務。該執行緒池適用於需要確保所有任務被順序執行的場合。

newFixedThreadPool的程式碼為:

public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,
                                  0L, TimeUnit.MILLISECONDS,
                                  new LinkedBlockingQueue<Runnable>());
}
複製程式碼

使用固定數目的n個執行緒,使用無界佇列LinkedBlockingQueue,執行緒建立後不會超時終止。和newSingleThreadExecutor一樣,由於是無界佇列,如果排隊任務過多,可能會消耗非常大的記憶體。

newCachedThreadPool的程式碼為:

public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                  60L, TimeUnit.SECONDS,
                                  new SynchronousQueue<Runnable>());
}
複製程式碼

它的corePoolSize為0,maximumPoolSize為Integer.MAX_VALUE,keepAliveTime是60秒,佇列為SynchronousQueue。

它的含義是,當新任務到來時,如果正好有空閒執行緒在等待任務,則其中一個空閒執行緒接受該任務,否則就總是建立一個新執行緒,建立的匯流排程個數不受限制,對任一空閒執行緒,如果60秒內沒有新任務,就終止。

實際中,應該使用newFixedThreadPool還是newCachedThreadPool呢?

在系統負載很高的情況下,newFixedThreadPool可以通過佇列對新任務排隊,保證有足夠的資源處理實際的任務,而newCachedThreadPool會為每個任務建立一個執行緒,導致建立過多的執行緒競爭CPU和記憶體資源,使得任何實際任務都難以完成,這時,newFixedThreadPool更為適用

不過,如果系統負載不太高,單個任務的執行時間也比較短,newCachedThreadPool的效率可能更高,因為任務可以不經排隊,直接交給某一個空閒執行緒。

在系統負載可能極高的情況下,兩者都不是好的選擇,newFixedThreadPool的問題是佇列過長,而newCachedThreadPool的問題是執行緒過多,這時,應根據具體情況自定義ThreadPoolExecutor,傳遞合適的引數。

執行緒池的死鎖

關於提交給執行緒池的任務,我們需要特別注意一種情況,就是任務之間有依賴,這種情況可能會出現死鎖。比如任務A,在它的執行過程中,它給同樣的任務執行服務提交了一個任務B,但需要等待任務B結束。

如果任務A是提交給了一個單執行緒執行緒池,就會出現死鎖,A在等待B的結果,而B在佇列中等待被排程。

如果是提交給了一個限定執行緒個數的執行緒池,也有可能出現死鎖,我們看個簡單的例子:

public class ThreadPoolDeadLockDemo {
    private static final int THREAD_NUM = 5;
    static ExecutorService executor = Executors.newFixedThreadPool(THREAD_NUM);

    static class TaskA implements Runnable {
        @Override
        public void run() {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            Future<?> future = executor.submit(new TaskB());
            try {
                future.get();
            } catch (Exception e) {
                e.printStackTrace();
            }
            System.out.println("finished task A");
        }
    }

    static class TaskB implements Runnable {
        @Override
        public void run() {
            System.out.println("finished task B");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 5; i++) {
            executor.execute(new TaskA());
        }
        Thread.sleep(2000);
        executor.shutdown();
    }
}
複製程式碼

以上程式碼使用newFixedThreadPool建立了一個5個執行緒的執行緒池,main程式提交了5個TaskA,TaskA會提交一個TaskB,然後等待TaskB結束,而TaskB由於執行緒已被佔滿只能排隊等待,這樣,程式就會死鎖。

怎麼解決這種問題呢?

替換newFixedThreadPool為newCachedThreadPool,讓建立執行緒不再受限,這個問題就沒有了。

另一個解決方法,是使用SynchronousQueue,它可以避免死鎖,怎麼做到的呢?對於普通佇列,入隊只是把任務放到了佇列中,而對於SynchronousQueue來說,入隊成功就意味著已有執行緒接受處理,如果入隊失敗,可以建立更多執行緒直到maximumPoolSize,如果達到了maximumPoolSize,會觸發拒絕機制,不管怎麼樣,都不會死鎖。我們將建立executor的程式碼替換為:

static ExecutorService executor = new ThreadPoolExecutor(
        THREAD_NUM, THREAD_NUM, 0, TimeUnit.SECONDS,
        new SynchronousQueue<Runnable>());
複製程式碼

只是更改佇列型別,執行同樣的程式,程式不會死鎖,不過TaskA的submit呼叫會丟擲異常RejectedExecutionException,因為入隊會失敗,而執行緒個數也達到了最大值。

小結

本節介紹了執行緒池的基本概念,詳細探討了其主要引數的含義,理解這些引數對於合理使用執行緒池是非常重要的,對於相互依賴的任務,需要特別注意,避免出現死鎖。

ThreadPoolExecutor實現了生產者/消費者模式,工作者執行緒就是消費者,任務提交者就是生產者,執行緒池自己維護任務佇列。當我們碰到類似生產者/消費者問題時,應該優先考慮直接使用執行緒池,而非重新發明輪子,自己管理和維護消費者執行緒及任務佇列。

在非同步任務程式中,一種常見的場景是,主執行緒提交多個非同步任務,然後有任務完成就處理結果,並且按任務完成順序逐個處理,對於這種場景,Java併發包提供了一個方便的方法,使用CompletionService,讓我們下一節來探討它。

(與其他章節一樣,本節所有程式碼位於 github.com/swiftma/pro…)


未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),從入門到高階,深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。

計算機程式的思維邏輯 (78) - 執行緒池

相關文章