面試官:如果不允許執行緒池丟棄任務,應該選擇哪個拒絕策略?

JavaGuide發表於2024-05-30

執行緒池的拒絕策略有哪些?

如果當前同時執行的執行緒數量達到最大執行緒數量並且佇列也已經被放滿了任務時,ThreadPoolExecutor 定義一些策略:

  • ThreadPoolExecutor.AbortPolicy:丟擲 RejectedExecutionException來拒絕新任務的處理。
  • ThreadPoolExecutor.CallerRunsPolicy:呼叫執行自己的執行緒執行任務,也就是直接在呼叫execute方法的執行緒中執行(run)被拒絕的任務,如果執行程式已關閉,則會丟棄該任務。因此這種策略會降低對於新任務提交速度,影響程式的整體效能。如果你的應用程式可以承受此延遲並且你要求任何一個任務請求都要被執行的話,你可以選擇這個策略。
  • ThreadPoolExecutor.DiscardPolicy:不處理新任務,直接丟棄掉。
  • ThreadPoolExecutor.DiscardOldestPolicy:此策略將丟棄最早的未處理的任務請求。

舉個例子:Spring 透過 ThreadPoolTaskExecutor 或者我們直接透過 ThreadPoolExecutor 的建構函式建立執行緒池的時候,當我們不指定 RejectedExecutionHandler 拒絕策略來配置執行緒池的時候,預設使用的是 AbortPolicy。在這種拒絕策略下,如果佇列滿了,ThreadPoolExecutor 將丟擲 RejectedExecutionException 異常來拒絕新來的任務 ,這代表你將丟失對這個任務的處理。如果不想丟棄任務的話,可以使用CallerRunsPolicyCallerRunsPolicy 和其他的幾個策略不同,它既不會拋棄任務,也不會丟擲異常,而是將任務回退給呼叫者,使用呼叫者的執行緒來執行任務。

public static class CallerRunsPolicy implements RejectedExecutionHandler {

        public CallerRunsPolicy() { }

        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
            if (!e.isShutdown()) {
                // 直接主執行緒執行,而不是執行緒池中的執行緒執行
                r.run();
            }
        }
    }

如果不允許丟棄任務任務,應該選擇哪個拒絕策略?

根據上面對執行緒池拒絕策略的介紹,相信大家很容易能夠得出答案是:CallerRunsPolicy

這裡我們再來結合CallerRunsPolicy 的原始碼來看看:

public static class CallerRunsPolicy implements RejectedExecutionHandler {

        public CallerRunsPolicy() { }


        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
            //只要當前程式沒有關閉,就用執行execute方法的執行緒執行該任務
            if (!e.isShutdown()) {

                r.run();
            }
        }
    }

從原始碼可以看出,只要當前程式不關閉就會使用執行execute方法的執行緒執行該任務。

CallerRunsPolicy 拒絕策略有什麼風險?如何解決?

我們上面也提到了:如果想要保證任何一個任務請求都要被執行的話,那選擇 CallerRunsPolicy 拒絕策略更合適一些。

不過,如果走到CallerRunsPolicy的任務是個非常耗時的任務,且處理提交任務的執行緒是主執行緒,可能會導致主執行緒阻塞,影響程式的正常執行。

這裡簡單舉一個例子,該執行緒池限定了最大執行緒數為 2,阻塞佇列大小為 1(這意味著第 4 個任務就會走到拒絕策略),ThreadUtil為 Hutool 提供的工具類:

public class ThreadPoolTest {

    private static final Logger log = LoggerFactory.getLogger(ThreadPoolTest.class);

    public static void main(String[] args) {
        // 建立一個執行緒池,核心執行緒數為1,最大執行緒數為2
        // 當執行緒數大於核心執行緒數時,多餘的空閒執行緒存活的最長時間為60秒,
        // 任務佇列為容量為1的ArrayBlockingQueue,飽和策略為CallerRunsPolicy。
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(1,
                2,
                60,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(1),
                new ThreadPoolExecutor.CallerRunsPolicy());

        // 提交第一個任務,由核心執行緒執行
        threadPoolExecutor.execute(() -> {
            log.info("核心執行緒執行第一個任務");
            ThreadUtil.sleep(1, TimeUnit.MINUTES);
        });

        // 提交第二個任務,由於核心執行緒被佔用,任務將進入佇列等待
        threadPoolExecutor.execute(() -> {
            log.info("非核心執行緒處理入隊的第二個任務");
            ThreadUtil.sleep(1, TimeUnit.MINUTES);
        });

        // 提交第三個任務,由於核心執行緒被佔用且佇列已滿,建立非核心執行緒處理
        threadPoolExecutor.execute(() -> {
            log.info("非核心執行緒處理第三個任務");
            ThreadUtil.sleep(1, TimeUnit.MINUTES);
        });

        // 提交第四個任務,由於核心執行緒和非核心執行緒都被佔用,佇列也滿了,根據CallerRunsPolicy策略,任務將由提交任務的執行緒(即主執行緒)來執行
        threadPoolExecutor.execute(() -> {
            log.info("主執行緒處理第四個任務");
            ThreadUtil.sleep(2, TimeUnit.MINUTES);
        });

        // 提交第五個任務,主執行緒被第四個任務卡住,該任務必須等到主執行緒執行完才能提交
        threadPoolExecutor.execute(() -> {
            log.info("核心執行緒執行第五個任務");
        });

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

輸出:

18:19:48.203 INFO  [pool-1-thread-1] c.j.concurrent.ThreadPoolTest - 核心執行緒執行第一個任務
18:19:48.203 INFO  [pool-1-thread-2] c.j.concurrent.ThreadPoolTest - 非核心執行緒處理第三個任務
18:19:48.203 INFO  [main] c.j.concurrent.ThreadPoolTest - 主執行緒處理第四個任務
18:20:48.212 INFO  [pool-1-thread-2] c.j.concurrent.ThreadPoolTest - 非核心執行緒處理入隊的第二個任務
18:21:48.219 INFO  [pool-1-thread-2] c.j.concurrent.ThreadPoolTest - 核心執行緒執行第五個任務

從輸出結果可以看出,因為CallerRunsPolicy這個拒絕策略,導致耗時的任務用了主執行緒執行,導致執行緒池阻塞,進而導致後續任務無法及時執行,嚴重的情況下很可能導致 OOM。

我們從問題的本質入手,呼叫者採用CallerRunsPolicy是希望所有的任務都能夠被執行,暫時無法處理的任務又被儲存在阻塞佇列BlockingQueue中。這樣的話,在記憶體允許的情況下,我們可以增加阻塞佇列BlockingQueue的大小並調整堆記憶體以容納更多的任務,確保任務能夠被準確執行。

為了充分利用 CPU,我們還可以調整執行緒池的maximumPoolSize (最大執行緒數)引數,這樣可以提高任務處理速度,避免累計在 BlockingQueue的任務過多導致記憶體用完。

調整阻塞佇列大小和最大執行緒數

如果伺服器資源以達到可利用的極限,這就意味我們要在設計策略上改變執行緒池的排程了,我們都知道,導致主執行緒卡死的本質就是因為我們不希望任何一個任務被丟棄。換個思路,有沒有辦法既能保證任務不被丟棄且在伺服器有餘力時及時處理呢?

這裡提供的一種任務持久化的思路,這裡所謂的任務持久化,包括但不限於:

  1. 設計一張任務表間任務儲存到 MySQL 資料庫中。
  2. Redis 快取任務。
  3. 將任務提交到訊息佇列中。

這裡以方案一為例,簡單介紹一下實現邏輯:

  1. 實現RejectedExecutionHandler介面自定義拒絕策略,自定義拒絕策略負責將執行緒池暫時無法處理(此時阻塞佇列已滿)的任務入庫(儲存到 MySQL 中)。注意:執行緒池暫時無法處理的任務會先被放在阻塞佇列中,阻塞佇列滿了才會觸發拒絕策略。
  2. 繼承BlockingQueue實現一個混合式阻塞佇列,該佇列包含 JDK 自帶的ArrayBlockingQueue。另外,該混合式阻塞佇列需要修改取任務處理的邏輯,也就是重寫take()方法,取任務時優先從資料庫中讀取最早的任務,資料庫中無任務時再從 ArrayBlockingQueue中去取任務。

將一部分任務儲存到MySQL中

整個實現邏輯還是比較簡單的,核心在於自定義拒絕策略和阻塞佇列。如此一來,一旦我們的執行緒池中執行緒以達到滿載時,我們就可以透過拒絕策略將最新任務持久化到 MySQL 資料庫中,等到執行緒池有了有餘力處理所有任務時,讓其優先處理資料庫中的任務以避免"飢餓"問題。

當然,對於這個問題,我們也可以參考其他主流框架的做法,以 Netty 為例,它的拒絕策略則是直接建立一個執行緒池以外的執行緒處理這些任務,為了保證任務的實時處理,這種做法可能需要良好的硬體裝置且臨時建立的執行緒無法做到準確的監控:

private static final class NewThreadRunsPolicy implements RejectedExecutionHandler {
    NewThreadRunsPolicy() {
        super();
    }
    public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
        try {
            //建立一個臨時執行緒處理任務
            final Thread t = new Thread(r, "Temporary task executor");
            t.start();
        } catch (Throwable e) {
            throw new RejectedExecutionException(
                    "Failed to start a new thread", e);
        }
    }
}

ActiveMQ 則是嘗試在指定的時效內儘可能的爭取將任務入隊,以保證最大交付:

new RejectedExecutionHandler() {
                @Override
                public void rejectedExecution(final Runnable r, final ThreadPoolExecutor executor) {
                    try {
                        //限時阻塞等待,實現儘可能交付
                        executor.getQueue().offer(r, 60, TimeUnit.SECONDS);
                    } catch (InterruptedException e) {
                        throw new RejectedExecutionException("Interrupted waiting for BrokerService.worker");
                    }
                    throw new RejectedExecutionException("Timed Out while attempting to enqueue Task.");
                }
            });

相關文章