【連載 06】自定義執行緒池(下)

FunTester發表於2024-12-20

1.4.3 執行緒工廠

Java 執行緒工廠(Thread Factory)是 Java SDK 中java.util.concurrent包裡的一個介面,通常用於建立新執行緒,允許使用者定製執行緒的建立過程,包括不限於設定執行緒名稱、設定優先順序、設定執行緒組、統計執行緒資訊等等。

ThreadFactory只有一個介面,引數型別java.lang.Runnable,返回值型別java.lang.Runnable,內容如下:

Thread newThread(Runnable r);

在之前的程式碼演示當中,為了列印當前執行緒的名字,用到了Thread.currentThread().getName()程式碼,列印的資訊通常是pool-1-thread-1格式的,不知道各位是否會有疑惑,這個格式是怎麼定義的?沒錯,格式就來源於 Java 執行緒池預設的執行緒工廠,方法路徑是java.util.concurrent.Executors#defaultThreadFactory,返回DefaultThreadFactory類物件,其中構造方法如下:

        DefaultThreadFactory() {

            SecurityManager s = System.getSecurityManager();

            group = (s != null) ? s.getThreadGroup() :

                                  Thread.currentThread().getThreadGroup();

            namePrefix = "pool-" +

                          poolNumber.getAndIncrement() +

                         "-thread-";

        }

到這裡,預設執行緒名字之謎就算解開了。讓咱們一起看看它的介面方法實現:

        public Thread newThread(Runnable r) {

            Thread t = new Thread(group, r,

                                  namePrefix + threadNumber.getAndIncrement(),

                                  0);

            if (t.isDaemon())

                t.setDaemon(false);

            if (t.getPriority() != Thread.NORM_PRIORITY)

                t.setPriority(Thread.NORM_PRIORITY);

            return t;

        }

這段程式碼首先根據構造方法裡面已經完成初始化的屬性和引數建立了執行緒,然後設定非守護執行緒,又將執行緒優先順序設定為 java.lang.Thread#NORM_PRIORITY。

下面演示如何自定義執行緒工廠:

package org.funtester.performance.books.chapter01.section4;

import java.util.concurrent.*;

import java.util.concurrent.atomic.AtomicInteger;

public class ThreadFactoryDemo {

    public static void main(String[] args) {

        ThreadPoolExecutor executor = new ThreadPoolExecutor(0, 5, 60L, TimeUnit.SECONDS, new SynchronousQueue<>(), new ThreadFactory() {// 建立執行緒池

            AtomicInteger index = new AtomicInteger(1);// 執行緒索引

            @Override

            public Thread newThread(Runnable r) {

                Thread thread = new Thread(r);// 建立執行緒

                thread.setName("FunTester-" + index.getAndIncrement());// 設定執行緒名稱

                return thread;

            }

        });

        for (int i = 0; i < 3; i++) {

            executor.execute(() -> {// 提交任務

                try {

                    Thread.sleep(1000);// 休眠1秒,模擬任務執行時間

                } catch (InterruptedException e) {

                    throw new RuntimeException(e);

                }

                System.out.println(Thread.currentThread().getName() + " is running");// 輸出執行緒名稱

            });

        }

        executor.shutdown();// 關閉執行緒池

    }

}

在這個例子中,透過執行緒工廠自定義了執行緒的名字,讓我們來看看控制檯列印內容:

FunTester-2 is running

FunTester-1 is running

FunTester-3 is running

這一下可讀性提升巨大,又不失優雅。

1.4.3 拒絕策略

拒絕策略在自定義執行緒池中作用發揮關鍵作用,通常對於初學者來說,使用預設策略是一種保險的方式。為了更好展示這四種策略線上程池無法接收改任務時如果工作,我寫了下面這個演示程式碼:

package org.funtester.performance.books.chapter01.section4;

import org.apache.kafka.common.utils.ThreadUtils;

import java.util.concurrent.LinkedBlockingQueue;

import java.util.concurrent.ThreadPoolExecutor;

import java.util.concurrent.TimeUnit;

import java.util.concurrent.atomic.AtomicInteger;

/**

 * 自定義執行緒池拒絕策略示例

 */

public class RejectDemo {

    public static void main(String[] args) {

        ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 1, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1), new ThreadPoolExecutor.AbortPolicy());// 自定義執行緒池

        AtomicInteger index = new AtomicInteger();// 索引,用於標識任務

        for (int i = 0; i < 3; i++) {

            int sequence = i;// 任務序號,用於標識任務,由於lambda表示式中的變數必須是final或者等效的,所以這裡使用區域性變數

            try {

                executor.execute(() -> {// 提交任務

                    try {

                        Thread.sleep(1000);// 模擬任務執行,睡眠1秒,避免任務過快執行完畢

                    } catch (InterruptedException e) {

                        throw new RuntimeException(e);

                    }

                    System.out.println(Thread.currentThread().getName() + "  " + System.currentTimeMillis() + "  任務" + sequence + "執行完成");// 列印任務執行完成資訊

                });

                System.out.println(Thread.currentThread().getName() + "  " + System.currentTimeMillis() + "  任務" + sequence + "提交成功");// 列印任務提交成功資訊

            } catch (Exception e) {

                System.out.println(Thread.currentThread().getName() + "  " + System.currentTimeMillis() + "  任務" + sequence + "被拒絕,異常資訊:" + e.getMessage());// 列印任務被拒絕資訊

            }

        }

        executor.shutdown();// 關閉執行緒池,不再接受新任務,但會執行完佇列中的任務,並不會立即關閉

    }

}

當我們使用 AbortPolicy 策略時,控制檯列印如下:

main  1712992116303  任務0提交成功

main  1712992116303  任務1提交成功

main  1712992116303  任務2被拒絕,異常資訊:Task org.funtester.performance.books.chapter01.section3.RejectDemo$$Lambda/0x0000000301004200@2b71fc7e rejected from java.util.concurrent.ThreadPoolExecutor@2ef1e4fa[Running, pool size = 1, active threads = 1, queued tasks = 1, completed tasks = 0]

pool-1-thread-1  1712992117304  任務0執行完成

pool-1-thread-1  1712992118305  任務1執行完成

說明前兩個任務都可以被正常提交且執行成功,第三個任務提交時被執行緒池拒絕了。因為前兩個任務,一個先被執行緒池執行,一個待在等待佇列中,第三任務提交時,已經沒有空的位置了。

當我們使用 DiscardPolicy 拒絕策略時,控制檯列印如下:

main  1712992169434  任務0提交成功

main  1712992169434  任務1提交成功

main  1712992169434  任務2提交成功

pool-1-thread-1  1712992170435  任務0執行完成

pool-1-thread-1  1712992171436  任務1執行完成

說明前兩個任務被正常提交且正常執行,第三個任務雖然提交成功,但是未被執行。

當我們使用 DiscardOldestPolicy 拒絕策略時,控制檯列印如下:

main  1712992249578  任務0提交成功

main  1712992249578  任務1提交成功

main  1712992249578  任務2提交成功

pool-1-thread-1  1712992250579  任務0執行完成

pool-1-thread-1  1712992251580  任務2執行完成

說明三個任務都被提交成功,但是當第三個任務提交時,第一個任務在執行中,第二個任務在等待佇列中,此時執行緒池丟棄了等待佇列中的任務(此處並不能證明丟棄的是第一個任務),然後將第三個任務留在了佇列中等待執行。這一點可以從時間戳相差 1 秒左右看出,第三個任務還是交給執行緒池執行的,由於執行緒池只有一個執行緒,所以需要等待執行完第一個任務,執行緒變得空閒才會去執行第三個任務。

下面是java.util.concurrent.ThreadPoolExecutor.DiscardOldestPolicy這部分原始碼:

        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {

            if (!e.isShutdown()) {

                e.getQueue().poll();

                e.execute(r);

            }

        }

當我們選擇 CallerRunsPolicy 拒絕策略時,控制檯列印如下:

main  1712992627964  任務0提交成功

main  1712992627964  任務1提交成功

pool-1-thread-1  1712992628965  任務0執行完成

main  1712992628969  任務2執行完成

main  1712992628969  任務2提交成功

pool-1-thread-1  1712992629966  任務1執行完成

說明三個任務都提交成功(請注意這裡提交成功僅僅表示提交方法沒有報錯),雖然都被執行了,但只有前兩個任務是被執行緒池執行緒執行的,所以他們間隔約為 1 秒。而第三個任務實際是被 main 執行緒執行的,執行完成的時間和第一個任務幾乎同時,還有第三個任務列印的日誌是先完成,後提交,切兩行日誌同時列印。main 執行緒在提交第三個任務時,由於被執行緒池拒絕,所以自己執行了第三個任務,這才導致日誌先執行後提交。

下面是java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy這部分的原始碼:


        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {

            if (!e.isShutdown()) {

                r.run();

            }

        }

當我們選擇自定義執行緒池時,要根據實際的需要選擇合適的拒絕策略。例如我們開發一個非同步任務執行緒池時,為了保障所有非同步任務都能夠執行,除了將等待任務佇列設定合適大小以外,也可以透過使用CallerRunsPolicy拒絕策略,由當前執行緒執行非同步任務。

但這樣做帶來一個問題,本來我們設定了最大執行緒數來限制最大併發,如果由提交任務的執行緒自己執行非同步任務,會給被請求服務造成超出執行緒池設定的壓力。此時我們需要重新自定義一個拒絕策略,如果提交任務失敗,那麼重新提交,當然這裡需要一個等待間隔。自定義拒絕策略程式碼如下:

        ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 1, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1), new RejectedExecutionHandler() {

            @Override

            public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {

                System.out.println(Thread.currentThread().getName() + "  " + System.currentTimeMillis() + "  任務被拒絕,執行緒池已滿");// 列印任務被拒絕資訊

                try {

                    Thread.sleep(1000);// 等待1秒,避免遞迴呼叫導致棧溢位

                } catch (InterruptedException e) {

                    throw new RuntimeException(e);

                }

                executor.execute(r);

            }

        });// 自定義執行緒池

使用自定義拒絕策略時,控制檯列印如下:

main  1712993224466  任務0提交成功

main  1712993224466  任務1提交成功

main  1712993224466  任務被拒絕,執行緒池已滿

pool-1-thread-1  1712993225467  任務0執行完成

main  1712993225468  任務2提交成功

pool-1-thread-1  1712993226469  任務1執行完成

pool-1-thread-1  1712993227470  任務2執行完成

可以看出所有的任務均提交成功且執行成功,而且都是被執行緒池執行緒執行。

為了實現既不丟棄任務,也不使用額外執行緒執行非同步任務,除了在拒絕策略裡面增加重試,我們還是可以直接在提交任務時,選擇阻塞新增。這樣既可以免去設定拒絕策略進行重試,也可以避免使用java.lang.Thread#sleep(long)方法。

直接往執行緒池提交任務常用的兩個方法均不支援阻塞呼叫,只有等待佇列有阻塞呼叫方法,所以我們需要繞過執行緒池的 API,直接想等待佇列中提交任務。具體呼叫程式碼如下:

package org.funtester.performance.books.chapter01.section4;

import java.util.concurrent.LinkedBlockingQueue;

import java.util.concurrent.ThreadPoolExecutor;

import java.util.concurrent.TimeUnit;

/**

 * 阻塞佇列新增任務示例

 */

public class BlockAddTask {

    public static void main(String[] args) throws InterruptedException {

        ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 2, 10L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(2));// 建立執行緒池,核心執行緒數1,最大執行緒數2,執行緒空閒時間10秒,任務佇列為連結串列阻塞佇列

        executor.prestartCoreThread();// 預啟動核心執行緒

        for (int i = 0; i < 4; i++) {

            int sequence = i;// 任務序號,用於標識任務,由於lambda表示式中的變數必須是final或者等效的,所以這裡使用區域性變數

            Runnable runnable = () -> {

                try {

                    Thread.sleep(1000);// 模擬任務執行,睡眠1秒,避免任務過快執行完畢

                } catch (InterruptedException e) {

                    throw new RuntimeException(e);

                }

                System.out.println(Thread.currentThread().getName() + "  " + System.currentTimeMillis() + "  任務" + sequence + "執行完成");// 列印任務執行完成資訊

            };

            executor.getQueue().put(runnable);// 將任務放入佇列

            System.out.println(Thread.currentThread().getName() + "  " + System.currentTimeMillis() + "  任務" + sequence + "提交成功");// 列印任務提交成功資訊

        }

        executor.shutdown();// 關閉執行緒池,不再接受新任務,但會執行完佇列中的任務,並不會立即關閉

    }

}

控制檯輸出如下:

main  1713010970715  任務0提交成功

main  1713010970715  任務1提交成功

main  1713010970715  任務2提交成功

pool-1-thread-1  1713010971720  任務0執行完成

main  1713010971720  任務3提交成功

pool-1-thread-1  1713010972725  任務1執行完成

pool-1-thread-1  1713010973726  任務2執行完成

pool-1-thread-1  1713010974731  任務3執行完成

可以明顯看到第四個任務提交時,阻塞了約 1 秒,原因是第一個任務在核心執行緒中執行,第二個和第三個任務在等待佇列中,此時新增第四個任務,自然會阻塞在java.util.concurrent.BlockingQueue#PUT方法,另外我們還可以使用超時阻塞的 API 提交任務,如下:

executor.getQueue().offer(runnable, 10, TimeUnit.SECONDS);// 將任務放入佇列

不知你有沒有注意到這次用例的變化,核心執行緒數不為零,且呼叫了java.util.concurrent.ThreadPoolExecutor#prestartCoreThread方法,預啟動核心執行緒。因為我們往執行緒池的任務佇列中新增任務時,並不會走執行緒建立的邏輯,所以當執行緒池沒有執行緒時,自然也不會執行等待佇列中的任務。

1.4.4 動態執行緒數量

我們已經將執行緒池建立三個物件引數拆解,可以說非常徹底。但對於執行緒池最重要的設定執行緒數相關的兩個引數,並沒有進行過多改動,全依靠 Java 執行緒池自己的建立和管理邏輯。

是這兩個引數初始化之後無法修改嗎?答案並不是,因為在效能測試實踐當中,我們可以根據實際使用場景的併發量,預估一個執行緒池的範圍,用來設定建立執行緒池的兩個引數。但我們依然可以透過 Java 執行緒池的 API 修改這兩個引數。

假設我們的需求是建立一個執行緒池,在任務等待佇列不為空的時候,儘可能建立更多執行緒去執行任務;在等待佇列為空的時候,儘可能少地保留活躍執行緒;同時我們還要求任務等待佇列有較多的儲存容量。如果要從前面的演示過的執行緒池中選擇的話,我們會有兩個選擇:

(1)快取執行緒池,但是無法滿足等待佇列有較多的儲存容量。

(2)核心數 + 最大執行緒數執行緒池 +LinkedBlockingQueue。這類執行緒池會面臨兩個矛盾:若核心執行緒數較低,則無法在等待佇列不滿的情況下建立超過核心執行緒數的活躍執行緒;若核心執行緒數較高,當等待佇列為空時,無法回收這些活躍執行緒所佔資源。

為了解決這個需求,我們需要在 Java 執行緒池基礎上,增加動態調整執行緒池執行緒配置的功能,這個功能可以由 java.util.concurrent.ThreadPoolExecutor#setCorePoolSize 這個 API 來完成。看著名字就能猜到這個 API 是用來設定核心執行緒數和。示例程式碼如下:

        ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 2, 10L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(100));

        for (int i = 0; i < 4; i++) {

            executor.execute(() -> {// 提交任務

                try {

                    Thread.sleep(3000);

                } catch (InterruptedException e) {

                    throw new RuntimeException(e);

                }

                System.out.println(Thread.currentThread().getName() + "  Hello, FunTester!");

            });

        }

        System.out.println("設定前執行緒池中活躍執行緒數量:" + executor.getActiveCount());

        executor.setCorePoolSize(2);// 設定核心執行緒數

        System.out.println("設定後執行緒池中活躍執行緒數量:" + executor.getActiveCount());

        executor.shutdown();// 關閉執行緒池

控制檯列印內容如下:

設定前執行緒池中活躍執行緒數量:1

設定後執行緒池中活躍執行緒數量:2

pool-2-thread-2  Hello, FunTester!

pool-2-thread-1  Hello, FunTester!

pool-2-thread-1  Hello, FunTester!

pool-2-thread-2  Hello, FunTester!

可以看到,執行緒池中已經有兩個活躍執行緒在處理非同步任務了。那新的問題來了,如果想修改最大執行緒數改怎麼辦?巧了,Java 執行緒池剛好有這麼一個 API:java.util.concurrent.ThreadPoolExecutor#setMaximumPoolSize,使用語法跟設定核心執行緒數一致,此處就不再單獨演示了。下面分享一個動態調整執行緒數的功能類,其中包括以下幾點功能:

  • (1)當任務堆積,增加核心執行緒數(在最大執行緒數以內),依靠執行緒池建立執行緒邏輯,增加活躍執行緒數。
  • (2)當執行緒池空閒,減少核心執行緒數,依靠執行緒池回收策略,降低活躍執行緒數。
  • (3)當任務嚴重堆積,增加最大執行緒數(在安全值以下),並且依靠核心執行緒數調整邏輯增加活躍執行緒數。
  • (4)當任務不再嚴重堆積,減少最大執行緒數,但保證在安全值以上。
  • (5)在每一次新增任務時,執行動態調整執行緒池邏輯。
package org.funtester.performance.books.chapter01.section4;

import java.util.concurrent.LinkedBlockingQueue;

import java.util.concurrent.ThreadPoolExecutor;

import java.util.concurrent.TimeUnit;

/**

 * 動態執行緒池演示程式碼

 */

public class DynamicThreadPool {

    /**

     * 全域性執行緒池

     */

    public static ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 2, 10L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(100));

    /**

     * 封裝執行緒池的execute方法

     * @param command 需要執行的任務

     */

    public static void execute(Runnable command) {

        dynamic();// 動態調整執行緒池

        executor.execute(command);// 提交任務

    }

    /**

     * 動態調整執行緒池,並不完美,僅供參考

     */

    public static void dynamic() {

        int size = executor.getQueue().size();

        if (size > 0) {// 如果佇列中有任務

            increaseCorePoolSize();// 增加核心執行緒數

            if (size > 100) {

                increaseMaximumPoolSize();// 增加最大執行緒數

            } else {

                decreaseMaximumPoolSize();// 減少最大執行緒數

            }

        } else {

            decreaseCorePoolSize();// 減少核心執行緒數

        }

    }

    /**

     * 增加核心執行緒數,這裡暫時不做執行緒安全處理

     */

    public static void increaseCorePoolSize() {

        int maximumPoolSize = executor.getMaximumPoolSize();// 獲取最大執行緒數

        int corePoolSize = executor.getCorePoolSize();// 獲取核心執行緒數

        if (corePoolSize < maximumPoolSize) {// 如果核心執行緒數小於最大執行緒數

            corePoolSize++;// 增加核心執行緒數

            executor.setCorePoolSize(corePoolSize);// 設定核心執行緒數

            System.out.println("增加核心執行緒數為:" + corePoolSize);

        }

    }

    /**

     * 減少核心執行緒數,這裡暫時不做執行緒安全處理

     */

    public static void decreaseCorePoolSize() {

        int corePoolSize = executor.getCorePoolSize();// 獲取核心執行緒數

        if (corePoolSize > 1) {// 如果核心執行緒數大於1

            corePoolSize--;// 減少核心執行緒數

            executor.setCorePoolSize(corePoolSize);// 設定核心執行緒數

            System.out.println("減少核心執行緒數為:" + corePoolSize);

        }

    }

    /**

     * 增加最大執行緒數,這裡暫時不做執行緒安全處理

     */

    public static void increaseMaximumPoolSize() {

        int maximumPoolSize = executor.getMaximumPoolSize();// 獲取最大執行緒數

        maximumPoolSize++;// 增加最大執行緒數

        if (maximumPoolSize > 128) {

            return;// 最大執行緒數不超過128

        }

        executor.setMaximumPoolSize(maximumPoolSize);// 設定最大執行緒數

        System.out.println("增加最大執行緒數為:" + maximumPoolSize);

    }

    /**

     * 減少最大執行緒數,這裡暫時不做執行緒安全處理

     */

    public static void decreaseMaximumPoolSize() {

        int maximumPoolSize = executor.getMaximumPoolSize();// 獲取最大執行緒數

        if (maximumPoolSize <= 16) {

            return;// 最大執行緒數不小於16

        }

        maximumPoolSize--;// 減少最大執行緒數

        executor.setMaximumPoolSize(maximumPoolSize);// 設定最大執行緒數

        System.out.println("減少最大執行緒數為:" + maximumPoolSize);

    }

}

這裡動態調整方法並不完美,首先沒有考慮執行緒安全的情況,這個可以使用下一章的知識解決。其次該方法只在執行任務時執行,假設一段時間並沒有新的任務提交,我們預想的核心執行緒數降低並不會被執行。有了這些 API 加持,相信各位一定可以掌握自定義執行緒池應對各類場景。

執行緒調整時核心執行緒數和最大執行緒數的知識點:

  • (1)當我們設定的最大執行緒數小於核心執行緒數,會報錯 java.lang.IllegalArgumentException。
  • (2)當我們設定核心執行緒數大於最大執行緒數,不會報錯,執行緒池會建立超過最大執行緒數的活躍執行緒。

1.5 總結

本章我們首先從併發和並行的概念入手,理解兩者在實際執行時的差異。然後對於 Java 多執行緒實現的 3 種方式進行程式碼演示,然後快步進入 Java 執行緒池的重點學習。Java 執行緒池分了兩個部分:Java 自帶兩種執行緒池實現和自定義執行緒池。兩者循序漸進,由淺入深,最終完成了 Java 執行緒池構造方法引數及其含義的學習。然後演示環境,筆者重點講解了執行緒池建立執行緒的邏輯圖、建立自定義執行緒池三個重要的物件引數,在實踐中加深對知識點的理解。最後演示了自定義引數物件核心邏輯實戰,並且透過簡單的案例幫助大家加強了對於自定義執行緒池原始碼的認識。

透過本章的學習,相信你已經掌握了 Java 多執行緒程式設計的核心要義,對於多執行緒有了更深的理解,對於執行緒池的使用也有了一定心得。這些都是我們使用 Java 效能測試的基礎,學好基礎可以讓我們在未來的實際使用中舉一反三。

書的名字:從 Java 開始做效能測試

如果本書內容對你有所幫助,希望各位多多讚賞,讓我可以貼補家用。讚賞兩位數可以提前閱讀未公開章節。我也會嘗試製作本書的影片教程,包括必要的答疑。

FunTester 原創精華
  • 混沌工程、故障測試、Web 前端
  • 服務端功能測試
  • 效能測試專題
  • Java、Groovy、Go
  • 白盒、工具、爬蟲、UI 自動化
  • 理論、感悟、影片
如果覺得我的文章對您有用,請隨意打賞。您的支援將鼓勵我繼續創作!
打賞支援
暫無回覆。

相關文章