關於執行緒池的五種實現方式,七大引數,四種拒絕策略

CryFace發表於2020-08-05

1 池化技術之執行緒池

什麼是池化技術?簡單來說就是優化資源的使用,我準備好了一些資源,有人要用就到我這裡拿,用完了就還給我。而一個比較重要的的實現就是執行緒池。那麼執行緒池用到了池化技術有什麼好處呢?

  • 降低資源的消耗
  • 提高響應的速度
  • 方便管理

也就是 執行緒複用、可以控制最大併發數、管理執行緒

2 執行緒池的五種實現方式

其實執行緒池我更願意說成四種封裝實現方式,一種原始實現方式。這四種封裝的實現方式都是依賴於最原始的的實現方式。所以這裡我們先介紹四種封裝的實現方式

2.1 Executors.newSingleThreadExecutor()

這個執行緒池很有意思,說是執行緒池,但是池子裡面只有一條執行緒。如果執行緒因為異常而停止,會自動新建一個執行緒補充。
我們可以測試一下:
我們對執行緒池執行十條列印任務,可以發現它們用的都是同一條執行緒

    public static void test01() {
        ExecutorService threadPool = Executors.newSingleThreadExecutor();
        try {
            //對執行緒進行執行十條列印任務
            for(int i = 1; i <= 10; i++){
                threadPool.execute(()->{
                    System.out.println(Thread.currentThread().getName()+"=>執行完畢!");
                });
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            //用完執行緒池一定要記得關閉
            threadPool.shutdown();
        }
    }
pool-1-thread-1=>執行完畢!
pool-1-thread-1=>執行完畢!
pool-1-thread-1=>執行完畢!
pool-1-thread-1=>執行完畢!
pool-1-thread-1=>執行完畢!
pool-1-thread-1=>執行完畢!
pool-1-thread-1=>執行完畢!
pool-1-thread-1=>執行完畢!
pool-1-thread-1=>執行完畢!

2.2 Executors.newFixedThreadPool(指定執行緒數量)

這個執行緒池是可以指定我們的執行緒池大小的,可以針對我們具體的業務和情況來分配大小。它是建立一個核心執行緒數跟最大執行緒數相同的執行緒池,因此池中的執行緒數量既不會增加也不會變少,如果有空閒執行緒任務就會被執行,如果沒有就放入任務佇列,等待空閒執行緒。
我們同樣來測試一下:

    public static void test02() {
        ExecutorService threadPool = Executors.newFixedThreadPool(5);
        try {
            //對執行緒進行執行十條列印任務
            for(int i = 1; i <= 10; i++){
                threadPool.execute(()->{
                    System.out.println(Thread.currentThread().getName()+"=>執行完畢!");
                });
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            //用完執行緒池一定要記得關閉
            threadPool.shutdown();
        }
    }

我們建立了五條執行緒的執行緒池,在列印任務的時候,可以發現執行緒都有進行工作

pool-1-thread-1=>執行完畢!
pool-1-thread-1=>執行完畢!
pool-1-thread-1=>執行完畢!
pool-1-thread-1=>執行完畢!
pool-1-thread-1=>執行完畢!
pool-1-thread-1=>執行完畢!
pool-1-thread-2=>執行完畢!
pool-1-thread-3=>執行完畢!
pool-1-thread-5=>執行完畢!
pool-1-thread-4=>執行完畢!

2.3 Executors.newCachedThreadPool()

這個執行緒池是建立一個核心執行緒數為0,最大執行緒為Inter.MAX_VALUE的執行緒池,也就是說沒有限制,執行緒池中的執行緒數量不確定,但如果有空閒執行緒可以複用,則優先使用,如果沒有空閒執行緒,則建立新執行緒處理任務,處理完放入執行緒池。
我們同樣來測試一下

2.4 Executors.newScheduledThreadPool(指定最大執行緒數量)

建立一個沒有最大執行緒數限制的可以定時執行執行緒池
在這裡,還有建立一個只有單個執行緒的可以定時執行執行緒池(Executors.newSingleThreadScheduledExecutor())這些都是上面的執行緒池擴充套件開來了,不詳細介紹了。

3 介紹執行緒池的七大引數

上面我們也說到了執行緒池有五種實現方式,但是實際上我們就介紹了四種。那麼最後一種是什麼呢?不急,我們可以點開我們上面執行緒池實現方式的原始碼進行檢視,可以發現

  • newSingleThreadExecutor()的實現原始碼

而點開其他幾個執行緒池到最後都可以發現,他們實際上用的就是這個ThreadPoolExecutor。我們把原始碼粘過來分析,其實也就是這七大引數

    /**
     * Creates a new {@code ThreadPoolExecutor} with the given initial
     * parameters.
     *
     * @param corePoolSize the number of threads to keep in the pool, even
     *        if they are idle, unless {@code allowCoreThreadTimeOut} is set
     * @param maximumPoolSize the maximum number of threads to allow in the
     *        pool
     * @param keepAliveTime when the number of threads is greater than
     *        the core, this is the maximum time that excess idle threads
     *        will wait for new tasks before terminating.
     * @param unit the time unit for the {@code keepAliveTime} argument
     * @param workQueue the queue to use for holding tasks before they are
     *        executed.  This queue will hold only the {@code Runnable}
     *        tasks submitted by the {@code execute} method.
     * @param threadFactory the factory to use when the executor
     *        creates a new thread
     * @param handler the handler to use when execution is blocked
     *        because the thread bounds and queue capacities are reached
     * @throws IllegalArgumentException if one of the following holds:<br>
     *         {@code corePoolSize < 0}<br>
     *         {@code keepAliveTime < 0}<br>
     *         {@code maximumPoolSize <= 0}<br>
     *         {@code maximumPoolSize < corePoolSize}
     * @throws NullPointerException if {@code workQueue}
     *         or {@code threadFactory} or {@code handler} is null
     */
    public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler) {
        if (corePoolSize < 0 ||
            maximumPoolSize <= 0 ||
            maximumPoolSize < corePoolSize ||
            keepAliveTime < 0)
            throw new IllegalArgumentException();
        if (workQueue == null || threadFactory == null || handler == null)
            throw new NullPointerException();
        this.acc = System.getSecurityManager() == null ?
                null :
                AccessController.getContext();
        this.corePoolSize = corePoolSize;
        this.maximumPoolSize = maximumPoolSize;
        this.workQueue = workQueue;
        this.keepAliveTime = unit.toNanos(keepAliveTime);
        this.threadFactory = threadFactory;
        this.handler = handler;
    }

毫無懸念,這就是最後一種方式,也是其他實現方式的基礎。而用這種方式也是最容易控制,因為我們可以自由的設定引數。在阿里巴巴開發手冊中也提到了

所以我們更需要去了解這七大引數,在平時用執行緒池的時候儘量去用ThreadPoolExecutor。而關於這七大引數我們簡單概括就是

  • corePoolSize: 執行緒池核心執行緒個數
  • workQueue: 用於儲存等待執行任務的阻塞佇列
  • maximunPoolSize: 執行緒池最大執行緒數量
  • ThreadFactory: 建立執行緒的工廠
  • RejectedExecutionHandler: 佇列滿,並且執行緒達到最大執行緒數量的時候,對新任務的處理策略
  • keeyAliveTime: 空閒執行緒存活時間
  • TimeUnit: 存活時間單位

3.1 而關於執行緒池最大執行緒數量,我們也有兩種設定方式

  1. CPU密集型
    獲得cpu的核數,不同的硬體不一樣,設定核數的的執行緒數量。
    我們可以通過程式碼 Runtime.getRuntime().availableProcessors();獲取,然後設定。
  2. IO密集型
    IO非常消耗資源,所有我們需要計算大型的IO程式任務有多少個。
    一般來說,執行緒池最大值 > 大型任務的數量即可
    一般設定大型任務的數量*2

這裡我們用一個例子可以更好理解這些引數線上程池裡面的位置和作用。
如圖1.0,我們這是一個銀行
圖1.0
我們一共有五個櫃檯,可以理解為執行緒池的最大執行緒數量,而其中有兩個是在營業中,可以理解為執行緒池核心執行緒個數。而下面的等待廳可以理解為用於儲存等待執行任務的阻塞佇列。銀行就是建立執行緒的工廠。
而關於空閒執行緒存活時間,我們可以理解為如圖1.1這種情況,當五個營業中,卻只有兩個人需要被服務,而其他三個人一直處於等待的情況下,等了一個小時了,他們被通知下班了。這一個小時時間就可以說是空閒執行緒存活時間,而存活時間單位,顧名思義。
圖1.1
到現在我們就剩一個拒絕策略還沒介紹,什麼是拒絕策略呢?我們可以假設當銀行五個櫃檯都有人在被服務,如圖1.2。而等待廳這個時候也是充滿了人,銀行實在容不下人了。
圖1.2
這個時候對銀行外面那個等待的人的處理策略就是拒絕策略。
我們同樣瞭解之後用程式碼來測試一下:

    public static  void test05(){
        ExecutorService threadPool = new ThreadPoolExecutor(
                //核心執行緒數量
                2,
                //最大執行緒數量
                5,
                //空閒執行緒存活時間
                3,
                //存活單位
                TimeUnit.SECONDS,
                //這裡我們使用大多數執行緒池都預設使用的阻塞佇列,並使容量為3
                new LinkedBlockingDeque<>(3),
                Executors.defaultThreadFactory(),
                //我們使用預設的執行緒池都預設用的拒絕策略
                new ThreadPoolExecutor.AbortPolicy()

        );
        try {
            //對執行緒進行執行十條列印任務
            for(int i = 1; i <= 2; i++){
                threadPool.execute(()->{
                    System.out.println(Thread.currentThread().getName()+"=>執行完畢!");
                });
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            //用完執行緒池一定要記得關閉
            threadPool.shutdown();
        }

    }

我們執行列印兩條任務,可以發現執行緒池只用到了我們的核心兩條執行緒,相當於只有兩個人需要被服務,所以我們就開了兩個櫃檯。

pool-1-thread-1=>執行完畢!
pool-1-thread-2=>執行完畢!

但是在我們將列印任務改到大於5的時候,(我們改成8)我們可以發現執行緒池的五條執行緒都在使用了,人太多了,我們的銀行需要都開放了來服務。

for(int i = 1; i <= 8; i++)
pool-1-thread-1=>執行完畢!
pool-1-thread-1=>執行完畢!
pool-1-thread-1=>執行完畢!
pool-1-thread-1=>執行完畢!
pool-1-thread-2=>執行完畢!
pool-1-thread-3=>執行完畢!
pool-1-thread-4=>執行完畢!
pool-1-thread-5=>執行完畢!

在我們改成大於8的時候,可以發現拒絕策略觸發了。銀行實在容納不下了,所以我們把外面那個人用策略打發了。

for(int i = 1; i <= 9; i++)


在這裡我們也可以得出一個結論:
執行緒池大小= 最大執行緒數 + 阻塞佇列大小

在上面我們在使用的阻塞佇列是大多數的執行緒池都使用的阻塞佇列,所以就引發思考下面這個問題。

3.2 為什麼大部分的執行緒池都用LinkedBlockingQueue?

  • LinkedBlockingQueue 使用單向連結串列實現,在宣告LinkedBlockingQueue的時候,可以不指定佇列長度,長度為Integer.MAX_VALUE, 並且新建了一個Node物件,Node物件具有item,next變數,item用於儲存元素,next指向連結串列下一個Node物件,在剛開始的時候連結串列的head,last都指向該Node物件,item、next都為null,新元素放在連結串列的尾部,並從頭部取元素。取元素的時候只是一些指標的變化,LinkedBlockingQueue給put(放入元素),take(取元素)都宣告瞭一把鎖,放入和取互不影響,效率更高。
  • ArrayBlockingQueue 使用陣列實現,在宣告的時候必須指定長度,如果長度太大,造成記憶體浪費,長度太小,併發效能不高,如果陣列滿了,就無法放入元素,除非有其他執行緒取出元素,放入和取出都使用同一把鎖,因此存在競爭,效率比LinkedBlockingQueue低。

4 四種策略

我們在使用ThreadPoolExecutor的時候是可以自己選擇拒絕策略的,而拒絕策略我們所知道的有四種。

  • AbortPolicy(被拒絕了丟擲異常)
  • CallerRunsPolicy(使用呼叫者所線上程執行,就是哪裡來的回哪裡去)
  • DiscardOldestPolicy(嘗試去競爭第一個,失敗了也不拋異常)
  • DiscardPolicy(默默丟棄、不拋異常)

4.1 AbortPolicy

我們在上面使用的就是AbortPolicy拒絕策略,在執行列印任務超出執行緒池大小的時候,丟擲了異常。

4.2 CallerRunsPolicy

我們將拒絕策略修改為CallerRunsPolicy,執行後可以發現,因為第九個列印任務被拒絕了,所以它被呼叫者所在的執行緒執行了,也就是我們的main執行緒。(因為它從main執行緒來的,現在又回到了main執行緒。所以我們說它從哪裡來回哪裡去)

        ExecutorService threadPool = new ThreadPoolExecutor(
                //核心執行緒數量
                2,
                //最大執行緒數量
                5,
                //空閒執行緒存活時間
                3,
                //存活單位
                TimeUnit.SECONDS,
                //這裡我們使用大多數執行緒池都預設使用的阻塞佇列,並使容量為3
                new LinkedBlockingDeque<>(3),
                Executors.defaultThreadFactory(),
                //我們使用預設的執行緒池都預設用的拒絕策略
                new ThreadPoolExecutor.CallerRunsPolicy()

        );
pool-1-thread-2=>執行完畢!
main=>執行完畢!
pool-1-thread-2=>執行完畢!
pool-1-thread-1=>執行完畢!
pool-1-thread-2=>執行完畢!
pool-1-thread-1=>執行完畢!
pool-1-thread-3=>執行完畢!
pool-1-thread-4=>執行完畢!
pool-1-thread-5=>執行完畢!

4.3 DiscardOldestPolicy

嘗試去競爭第一個任務,但是失敗了。這裡就沒顯示了,也不丟擲異常。

pool-1-thread-1=>執行完畢!
pool-1-thread-1=>執行完畢!
pool-1-thread-1=>執行完畢!
pool-1-thread-1=>執行完畢!
pool-1-thread-2=>執行完畢!
pool-1-thread-3=>執行完畢!
pool-1-thread-4=>執行完畢!
pool-1-thread-5=>執行完畢!

4.4 DiscardPolicy

多出來的任務,默默拋棄掉,也不丟擲異常。

pool-1-thread-1=>執行完畢!
pool-1-thread-1=>執行完畢!
pool-1-thread-1=>執行完畢!
pool-1-thread-1=>執行完畢!
pool-1-thread-2=>執行完畢!
pool-1-thread-3=>執行完畢!
pool-1-thread-4=>執行完畢!
pool-1-thread-5=>執行完畢!

可以看到我們的DiscardOldestPolicy與DiscardPolicy一樣的結果,但是它們其實是不一樣,正如我們最開始總結的那樣,DiscardOldestPolicy在多出的列印任務的時候會嘗試去競爭,而不是直接拋棄掉,但是很顯然競爭失敗不然也不會和DiscardPolicy一樣的執行結果。但是如果線上程比較多的時候就可以很看出來。

相關文章