如何優雅的使用執行緒池

不學無數的程式設計師發表於2019-12-17

執行緒池不僅在專案中是非常常用的一項技術而且在面試中基本上也是必問的知識點,接下來跟著我一起來鞏固一下執行緒池的相關知識。在瞭解執行緒池之前我們先了解一下什麼是程式什麼是執行緒

程式

  • 程式:一般是一組CPU指令的集合構成的檔案,靜態儲存在諸如硬碟之類的儲存裝置上
  • 程式:當一個程式要被計算機執行時,就是在記憶體中產生該程式的一個執行時例項,我們就把這個例項叫做程式

使用者下達執行程式的命令以後,就會產生一個程式,同一個程式可以產生多個程式(一對多的關係),以允許同時有多個使用者執行同一個程式,卻不會相沖突。

程式需要一些資源才能工作,如CPU的使用時間、儲存器、檔案、以及I/O裝置,且為依序逐一執行,也就是每個CPU核心任何時間內僅能執行一項程式。但是在一個應用程式中一般不會是隻有一個任務單條線執行下去,肯定會有多個任務,而建立程式又是耗費時間和資源的,稱之為重量級操作。

  1. 建立程式佔用資源太多
  2. 程式之間的通訊需要資料在不同的記憶體空間傳來傳去,所以程式間通訊會更加耗費時間和資源

執行緒

執行緒是作業系統能夠進行運算排程的最小單位,大部分情況下它被包含在程式之中,是程式中實際的運作單位。一個程式可以併發多個執行緒,每個執行緒執行不同的任務。同一個程式中的多條執行緒共享該程式中的全部虛擬資源,例如虛擬地址空間、檔案描述符、訊號處理等等。但是同一個程式中的多個執行緒各自有各自的呼叫棧。

一個程式可以有很多執行緒,每條執行緒並行執行不同的任務。

執行緒中的資料

  1. 執行緒棧上的本地資料:比如函式執行過程的區域性變數,我們知道在Java中執行緒模型是使用棧的模型。每個執行緒都有自己的棧空間。
  2. 在整個程式裡共享的全域性資料:我們知道在Java程式中,Java就是一個程式,我們可以通過ps -ef | grep java可以看到在程式中執行了多少個Java程式,例如我們Java中的全域性變數,在不同程式之間是隔離的,但是線上程之間是共享的。
  3. 執行緒的私有資料:在Java中我們可以通過ThreadLocal來建立執行緒間私有的資料變數。

執行緒棧上的本地資料只能在本方法內有效,而執行緒的私有資料是線上程間多個函式共享的。

CPU密集型和IO密集型

理解是伺服器是CPU密集型還是IO密集型能夠幫助我們更好的設定執行緒池中的引數。具體如何設定我們在後面講到執行緒池的時候再分析,這裡大家先知道這兩個概念。

  • IO密集型:大部分時間CPU閒著,在等待磁碟的IO操作
  • CPU(計算)密集型:大部分時間磁碟IO閒著,等著CPU的計算操作

執行緒池

執行緒池其實是池化技術的應用一種,常見的池化技術還有很多,例如資料庫的連線池、Java中的記憶體池、常量池等等。而為什麼會有池化技術呢?程式的執行本質,就是通過使用系統資源(CPU、記憶體、網路、磁碟等等)來完成資訊的處理,比如在JVM中建立一個物件例項需要消耗CPU的和記憶體資源,如果你的程式需要頻繁建立大量的物件,並且這些物件的存活時間短就意味著需要進行頻繁銷燬,那麼很有可能這段程式碼就成為了效能的瓶頸。總結下來其實就以下幾點。

  • 複用相同的資源,減少浪費,減少新建和銷燬的成本;
  • 減少單獨管理的成本,統一交由"池";
  • 集中管理,減少"碎片";
  • 提高系統響應速度,因為池中有現成的資源,不用重新去建立;

所以池化技術就是為了解決我們這些問題的,簡單來說,執行緒池就是將用過的物件儲存起來,等下一次需要這個物件的時候,直接從物件池中拿出來重複使用,避免頻繁的建立和銷燬。在Java中萬物皆物件,那麼執行緒也是一個物件,Java執行緒是對於作業系統執行緒的封裝,建立Java執行緒也需要消耗作業系統的資源,因此就有了執行緒池。但是我們該如何建立呢?

Java提供的四種執行緒池

Java為我們提供了四種建立執行緒池的方法。

  • Executors.newCachedThreadPool:建立可快取無限制數量的執行緒池,如果執行緒中沒有空閒執行緒池的話此時再來任務會新建執行緒,如果超過60秒此執行緒無用,那麼就會將此執行緒銷燬。簡單來說就是忙不來的時候無限制建立臨時執行緒,閒下來再回收

    1public static ExecutorService newCachedThreadPool() {
    2    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
    3                                  60L, TimeUnit.SECONDS,
    4                                  new SynchronousQueue<Runnable>());
    5}
    複製程式碼
  • Executors.newFixedThreadPool:建立固定大小的執行緒池,可控制執行緒最大的併發數,超出的執行緒會在佇列中等待。簡單來說就是忙不來的時候會將任務放到無限長度的佇列裡。

    1   public static ExecutorService newFixedThreadPool(int nThreads) {
    2    return new ThreadPoolExecutor(nThreads, nThreads,
    3                                  0L, TimeUnit.MILLISECONDS,
    4                                  new LinkedBlockingQueue<Runnable>());
    5}
    複製程式碼
  • Executors.newSingleThreadExecutor:建立執行緒池中執行緒數量為1的執行緒池,用唯一執行緒來執行任務,保證任務是按照指定順序執行

    1public static ExecutorService newSingleThreadExecutor() {
    2    return new FinalizableDelegatedExecutorService
    3        (new ThreadPoolExecutor(11,
    4                                0L, TimeUnit.MILLISECONDS,
    5                                new LinkedBlockingQueue<Runnable>()));
    6}
    複製程式碼
  • Executors.newScheduledThreadPool:建立固定大小的執行緒池,支援定時及週期性的任務執行

    1public ScheduledThreadPoolExecutor(int corePoolSize) {
    2    super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
    3          new DelayedWorkQueue());
    4}
    複製程式碼

執行緒池的建立原理

我們點選去這四種實現方式的原始碼中我們可以看到其實它們的底層建立原理都是一樣的,只不過是所傳的引數不同組成的四個不同型別的執行緒池。都是使用了ThreadPoolExecutor來建立的。我們可以看一下ThreadPoolExecutor建立所傳的引數。

1public ThreadPoolExecutor(int corePoolSize,
2                              int maximumPoolSize,
3                              long keepAliveTime,
4                              TimeUnit unit,
5                              BlockingQueue<Runnable> workQueue,
6                              ThreadFactory threadFactory,
7                              RejectedExecutionHandler handler)

8

複製程式碼

那麼這些引數都具體代表什麼意思呢?

  • corePoolSize:執行緒池中核心執行緒數的數量
  • maximumPoolSize:線上程池中允許存在的最大執行緒數
  • keepAliveTime:當存在的執行緒數大於corePoolSize,那麼會找到空閒執行緒去銷燬,此引數是設定空閒多久的執行緒才被銷燬。
  • unit:時間單位
  • workQueue:工作佇列,執行緒池中的當前執行緒數大於核心執行緒的話,那麼接下來的任務會放入到佇列中
  • threadFactory:在建立執行緒的時候,通過工廠模式來生產執行緒。這個引數就是設定我們自定義的執行緒建立工廠。
  • handler:如果超過了最大執行緒數,那麼就會執行我們設定的拒絕策略

接下來我們將這些引數合起來看一下他們的處理邏輯是什麼。

  1. corePoolSize個任務時,來一個任務就建立一個執行緒
  2. 如果當前執行緒池的執行緒數大於了corePoolSize那麼接下來再來的任務就會放入到我們上面設定的workQueue佇列中
  3. 如果此時workQueue也滿了,那麼再來任務時,就會新建臨時執行緒,那麼此時如果我們設定了keepAliveTime或者設定了allowCoreThreadTimeOut,那麼系統就會進行執行緒的活性檢查,一旦超時便銷燬執行緒
  4. 如果此時執行緒池中的當前執行緒大於了maximumPoolSize最大執行緒數,那麼就會執行我們剛才設定的handler拒絕策略

為什麼建議不用Java提供的執行緒池建立方法

理解了上面設定的幾個引數以後,我們再來看一下為什麼在《阿里巴巴Java手冊》中有一條這樣規定。

相信大家看到上面提供四種建立執行緒池的實現原理,應該知道為什麼阿里巴巴會有這麼規定了。

  • FixedThreadPoolSingleThreadExecutor:這兩個執行緒池的實現方式,我們可以看到它設定的工作佇列都是LinkedBlockingQueue,我們知道此佇列是一個連結串列形式的佇列,此佇列是沒有長度限制的,是一個無界佇列,那麼此時如果有大量請求,就有可能造成OOM
  • CachedThreadPoolScheduledThreadPool:這兩個執行緒池的實現方式,我們可以看到它設定的最大執行緒數都是Integer.MAX_VALUE,那麼就相當於允許建立的執行緒數量為Integer.MAX_VALUE。此時如果有大量請求來的時候也有可能造成OOM

如何設定引數

所以我們在專案中如果要使用執行緒池的話,那麼就推薦根據自己專案和機器的情況進行個性化建立執行緒池。那麼這些引數如何設定呢?為了正確的定製執行緒池的長度,需要理解你的計算機配置、所需資源的情況以及任務的特性。比如部署的計算機安裝了多少個CPU?多少的記憶體?任務主要執行是IO密集型還是CPU密集型?所執行任務是否需要資料庫連線這樣的稀缺資源?

如果你有多個不同類別的任務,它們的行為有很大的差別,那麼應該考慮使用多個執行緒池。這樣也能根據每個任務不同定製不同的執行緒池,也不至於因為一種型別的任務失敗而託垮另一個任務。

  • CPU密集型任務:說明包含了大量的運算操作,比如有N個CPU,那麼就配置執行緒池的容量大小為N+1,這樣能獲得最優的利用率。因為CPU密集型的執行緒恰好在某時因為發生一個頁錯誤或者因為其他的原因而暫停,剛好有一個額外的執行緒,可以確保在這種情況下CPU週期不會中斷工作。

  • IO密集任務:說明CPU大部分時間都是在等待IO的阻塞操作,那麼此時就可以將執行緒池的容量大小配置的大一些。此時可以根據一些引數進行計算大概你的執行緒池的數量多少合適。

    • N:CPU的數量
    • U:目標CPU的使用率,0<=U<=1
    • W/C:等待時間與計算時間的比率
    • 那麼最優的池的大小就是NU(1+W/C)

頁缺失(英語:Page fault,又名硬錯誤、硬中斷、分頁錯誤、尋頁缺失、缺頁中斷、頁故障等)指的是當軟體試圖訪問已對映在虛擬地址空間中,但是當前並未被載入在實體記憶體中的一個分頁時,由中央處理器的記憶體管理單元所發出的中斷

其實執行緒池大小的設定還是要根據自己業務型別來設定,比如當前任務需要池化的資源的時候,比如資料庫的連線池,俺麼執行緒池的長度和資源池的長度會相互的影響。如果每一個任務都需要一個資料庫連線,那麼連線池的大小就會限制了執行緒池的有效大小,類似的,當執行緒池中的任務是連線池的唯一消費者時,那麼執行緒池的大小反而又會限制了連線池的有效大小。

執行緒池中的執行緒銷燬

執行緒池的核心執行緒數(corePoolSize)、最大執行緒數(maximumPoolSize)、執行緒的存活時間(keepAliveTime)共同管理的執行緒的建立與銷燬。接下來我們再複習一下執行緒池是如何建立和銷燬執行緒的

  • 當前執行緒數 < 核心執行緒數:來一個任務建立一個執行緒
  • 當前執行緒數 = 核心執行緒數:來一個任務就會將其加入到佇列中
  • 當前執行緒數 > 核心執行緒數:此時有一個前提條件就是佇列已滿,才會新建執行緒,此時就會開啟執行緒的活性檢查,對於設定為keepAliveTime時間沒有活動的執行緒將會被回收

那麼這裡可能有人會想到將corePoolSize核心執行緒數設定為0(如果大家還記得上面講的CachedThreadPool的話應該還會記得它的核心執行緒數就是0),因為這樣設定的話執行緒就會動態的進行建立了,閒的時候沒有執行緒,忙的時候再線上程池中建立執行緒。這樣想法固然是好,但是如果我們自定義引數設定了此引數為0,而正好又設定了等待佇列不是SynchronousQueue,那麼其實就會有問題,因為只有在佇列滿的情況下才會新建執行緒。下面程式碼我使用了無界佇列LinkedBlockingQueue,其實大家看一下輸出

 1ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(0,Integer.MAX_VALUE,1, TimeUnit.SECONDS,new LinkedBlockingQueue<>());
2for (int i = 0; i < 10; i++) {
3    threadPoolExecutor.execute(new Runnable() {
4        @Override
5        public void run(
{
6            try {
7                Thread.sleep(1000);
8            } catch (InterruptedException e) {
9                e.printStackTrace();
10            }
11            System.out.printf("1");
12        }
13    });
14}
複製程式碼

大家可以看一下演示的效果,其實1是每隔一秒列印一次,其實這就和我們使用執行緒池初衷相悖了,因為我們這個相當於是單執行緒在執行。

但是如果我們將工作佇列換成SynchronousQueue呢,我們發現這些1是一塊輸出出來的。

SynchronousQueue並不是一個真正的佇列,而是一種管理直接線上程間移交資訊的機制,這裡可以簡單將其想象成一個生產者生產訊息交給SynchronousQueue,而消費者這邊如果有執行緒來接收,那麼此訊息就會直接交給消費者,反之會阻塞。

所以我們在設定執行緒池中某些引數的時候應該想想其建立和銷燬執行緒流程,不然我們自定義的執行緒池還不如使用Java提供的四種執行緒池了。

執行緒池中的拒絕策略

ThreadPoolExecutor為我們提供了四種拒絕策略,我們可以看下Java提供的四種執行緒池建立所提供的拒絕策略都是其定義的預設的拒絕策略。那麼除了這個拒絕策略其他的拒絕策略都是什麼呢?

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

我們可以到拒絕策略是一個介面RejectedExecutionHandler,這也就意味我著我們可以自己訂自己的拒絕策略,我們先看一下Java提供四種拒絕策略是什麼。

 1public interface RejectedExecutionHandler {
2
3    /**
4     * Method that may be invoked by a {@link ThreadPoolExecutor} when
5     * {@link ThreadPoolExecutor#execute execute} cannot accept a
6     * task.  This may occur when no more threads or queue slots are
7     * available because their bounds would be exceeded, or upon
8     * shutdown of the Executor.
9     *
10     * <p>In the absence of other alternatives, the method may throw
11     * an unchecked {@link RejectedExecutionException}, which will be
12     * propagated to the caller of {@code execute}.
13     *
14     * @param r the runnable task requested to be executed
15     * @param executor the executor attempting to execute this task
16     * @throws RejectedExecutionException if there is no remedy
17     */

18    void rejectedExecution(Runnable r, ThreadPoolExecutor executor);
19}
複製程式碼

AbortPolicy

這個拒絕策略就是Java提供的四種執行緒池建立方法提供的預設拒絕策略。我們可以看下它的實現。

 1public static class AbortPolicy implements RejectedExecutionHandler {
2
3    public AbortPolicy() { }
4
5    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
6        throw new RejectedExecutionException("Task " + r.toString() +
7                                             " rejected from " +
8                                             e.toString());
9    }
10}
複製程式碼

所以此拒絕策略就是拋RejectedExecutionException異常

CallerRunsPolicy

此拒絕策略簡單來說就是將此任務交給呼叫者直接執行。

 1public static class CallerRunsPolicy implements RejectedExecutionHandler {
2
3    public CallerRunsPolicy() { }
4
5    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
6        if (!e.isShutdown()) {
7            r.run();
8        }
9    }
10}
複製程式碼

這裡為什麼是交給了呼叫者來執行呢?我們可以看到它是呼叫了run()方法,而不是start()方法。

DiscardOldestPolicy

從原始碼中應該能看出來,此拒絕策略是丟棄佇列中最老的任務,然後再執行。

 1public static class DiscardOldestPolicy implements RejectedExecutionHandler {
2
3        public DiscardOldestPolicy() { }
4
5        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
6            if (!e.isShutdown()) {
7                e.getQueue().poll();
8                e.execute(r);
9            }
10        }
11    }
複製程式碼

DiscardPolicy

從原始碼中應該能看出來,此拒絕策略是對於當前任務不做任何操作,簡單來說就是直接丟棄了當前任務不執行。

1public static class DiscardPolicy implements RejectedExecutionHandler {
2
3    public DiscardPolicy() { }
4
5    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
6    }
7}
複製程式碼

執行緒池的拒絕策略給我們預設提供了這四種的實現方式,當然我們也能夠自定義拒絕策略使執行緒池更加符合我們當前的業務,在後面講解Tomcat自定義自己的執行緒池時也會講解它自己實現的拒絕策略。

執行緒飢餓死鎖

執行緒池為“死鎖”這一概念帶來了一種新的可能:執行緒飢餓死鎖。線上程池中,如果一個任務另一個任務提交到同一個Executor,那麼通常會引發死鎖。第二個執行緒停留在工作佇列中等待第一個提交的任務執行完成,但是第一個任務又無法執行完成,因為它在等待第二個任務執行完成。用圖表示如下

用程式碼表示的話如下,這裡注意我們這裡定義的執行緒池是SingleThreadExecutor,執行緒池中只有一個執行緒,這樣好模擬出這樣的情況,如果在更大的執行緒池中,如果所有執行緒都在等待其他仍處於工作佇列的任務而阻塞,那麼這種情況被稱為執行緒飢餓死鎖。所以儘量避免在同一個執行緒池中處理兩種不同型別的任務。

 1public class AboutThread {
2    ExecutorService executorService = Executors.newSingleThreadExecutor();
3    public static void main(String[] args) {
4        AboutThread aboutThread = new AboutThread();
5        aboutThread.threadDeadLock();
6    }
7
8    public void threadDeadLock(){
9        Future<String> taskOne  = executorService.submit(new TaskOne());
10        try {
11            System.out.printf(taskOne.get());
12        } catch (InterruptedException e) {
13            e.printStackTrace();
14        } catch (ExecutionException e) {
15            e.printStackTrace();
16        }
17    }
18
19    public class TaskOne implements Callable{
20
21        @Override
22        public Object call() throws Exception {
23            Future<String> taskTow = executorService.submit(new TaskTwo());
24            return "TaskOne" + taskTow.get();
25        }
26    }
27
28    public class TaskTwo implements Callable{
29
30        @Override
31        public Object call() throws Exception {
32            return "TaskTwo";
33        }
34    }
35}
複製程式碼

擴充ThreadPoolExecutor

如果我們想要對執行緒池進行一些擴充套件,那麼可以使用ThreadPoolExecutor給我預留的一些介面可以使我們進行更深層次話的定製執行緒池。

執行緒工廠

如果我們想要給我們的執行緒池中的每個執行緒自定義一些名稱,那麼我們就可以使用執行緒工廠來實現一些自定義化的一些操作。只要我們將我們自定義的工廠傳給ThreadPoolExecutor,那麼無論何時執行緒池需要建立一個執行緒,都要通過我們定義的工廠來進行建立。接下來我們看一下介面ThreadFactory,只要我們實現了此介面就能自定義自己執行緒獨有的資訊。

 1public interface ThreadFactory {
2
3    /**
4     * Constructs a new {@code Thread}.  Implementations may also initialize
5     * priority, name, daemon status, {@code ThreadGroup}, etc.
6     *
7     * @param r a runnable to be executed by new thread instance
8     * @return constructed thread, or {@code null} if the request to
9     *         create a thread is rejected
10     */

11    Thread newThread(Runnable r);
12}
複製程式碼

接下來我們可以看我們自己寫的執行緒池工廠類

 1class CustomerThreadFactory implements ThreadFactory{
2
3    private String name;
4    private final AtomicInteger threadNumber = new AtomicInteger(1);
5    CustomerThreadFactory(String name){
6        this.name = name;
7    }
8
9    @Override
10    public Thread newThread(Runnable r) {
11        Thread thread = new Thread(r,name+threadNumber.getAndIncrement());
12        return thread;
13    }
14}
複製程式碼

只需要在進行執行緒池例項化的時候將此工廠類加上去即可

 1   public static void customerThread(){
2        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(0,Integer.MAX_VALUE,1, TimeUnit.SECONDS,new SynchronousQueue<>(),
3                new CustomerThreadFactory("customerThread"));
4
5        for (int i = 0; i < 10; i++) {
6            threadPoolExecutor.execute(new Runnable() {
7                @Override
8                public void run(
{
9                    System.out.printf(Thread.currentThread().getName());
10                    System.out.printf("\n");
11                }
12            });
13        }
14    }
複製程式碼

接下來我們執行此語句,發現每個執行緒的名字已經變了

 1customerThread1
2customerThread10
3customerThread9
4customerThread8
5customerThread7
6customerThread6
7customerThread5
8customerThread4
9customerThread3
10customerThread2
複製程式碼

通過繼承ThreadPoolExecutor擴充套件

我們檢視ThreadPoolExecutor原始碼可以發現原始碼中有三個方法都是protected

1protected void beforeExecute(Thread t, Runnable r) { }
2protected void afterExecute(Runnable r, Throwable t) { }
3protected void terminated() { }
複製程式碼

被protected修飾的成員對於本包和其子類可見

我們可以通過繼承來覆寫這些方法,那麼就可以進行我們獨有的擴充套件了。執行任務的執行緒會呼叫beforeExecuteafterExecute方法,可以通過它們新增日誌、時序、監視器或者同級資訊收集的功能。無論任務是正常從run中返回,還是丟擲一個異常,afterExecute都會被呼叫(如果任務完成後丟擲一個Error,則afterExecute不會被呼叫)。如果beforeExecute丟擲一個RuntimeException,任務將不會被執行,afterExecute也不會被呼叫。

線上程池完成關閉時呼叫terminated,也就是在所有任務都已經完成並且所有工作者執行緒也已經關閉後,terminated可以用來釋放Executor在其生命週期裡分配的各種資源,此外還可以執行傳送通知、記錄日誌或者手機finalize統計等操作。

本篇文章程式碼地址

有感興趣的可以關注一下我新建的公眾號,搜尋[程式猿的百寶袋]。或者直接掃下面的碼也行。

參考

相關文章