看起來是線程池的BUG,但是我認為是源碼設計不合理。

  • 2022 年 7 月 11 日
  • 筆記

你好呀,我是歪歪。

前幾天看到一個 JDK 線程池的 BUG,我去了解了一下,摸清楚了它的癥結所在之後,我覺得這個 BUG 是屬於一種線程池方法設計不合理的地方,而且官方在知道這個 BUG 之後表示:確實是個 BUG,但是我就不修復了吧,你就當這是一個 feature 吧。

在帶你細嗦這個 BUG 之前,我先問一個問題:

JDK 自帶的線程池拒絕策略有哪些?

這玩意,老八股文了,存在的時間比我從業的時間都長,得張口就來:

  • AbortPolicy:丟棄任務並拋出 RejectedExecutionException 異常,這是默認的策略。
  • DiscardOldestPolicy:丟棄隊列最前面的任務,執行後面的任務
  • CallerRunsPolicy:由調用線程處理該任務
  • DiscardPolicy:也是丟棄任務,但是不拋出異常,相當於靜默處理。

這次的這個 BUG 觸發條件之一,就藏着在這個 DiscardPolicy 裏面。

但是你一去看源碼,這個玩意就是個空方法啊,這能有什麼 BUG?

它錯就錯在是一個空方法,把異常給靜默處理了。

別急,等我慢慢給你擺。

啥BUG啊?

BUG 對應的鏈接是這個:

//bugs.openjdk.org/browse/JDK-8286463

標題大概就是說:噢,我的老夥計們,聽我說,我發現線程池的拒絕策略 DiscardPolicy 遇到 invokerAll 方法的時候,可能會導致線程一直阻塞哦。

然後在 BUG 的描述部分主要先注意這兩段:

這兩段透露出兩個消息:

  • 1.這個 BUG 之前有人提出來過。
  • 2.Doug 和 Martin 這兩位也知道這個 BUG,但是他們覺得用戶可以通過編碼的方式避免永遠阻塞的問題。

所以我們還得先去這個 BUG 最先出現的地方看一下。也就是這個鏈接:

//bugs.openjdk.org/browse/JDK-8160037

從標題上來看,這兩個問題非常的相似,都有 invokerAll 和 block,但是觸發的條件不一樣。

一個是 DiscardPolicy 拒絕策略,一個是 shutdownNow 方法。

所以我的策略是先帶你先把這個 shutdownNow 方法嗦明白了,這樣你就能更好的理解 DiscardPolicy 帶來的問題。

本質上,它們說的是一回事兒。

現象

在 shutdownNow 相關的這個 BUG 描述裏面,提問者給到了他的測試用例,我稍微改改,就拿來就用了。

//bugs.openjdk.org/browse/JDK-8160037

代碼貼在這裡,你也可以那到你本地跑一下:

public class MainTest {

    public static void main(String[] args) throws InterruptedException {
        
        List<Callable<Void>> tasks = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            int finalI = i;
            tasks.add(() -> {
                System.out.println("callable "+ finalI);
                Thread.sleep(500);
                return null;
            });
        }

        ExecutorService executor = Executors.newFixedThreadPool(2);
        Thread executorInvokerThread = new Thread(() -> {
            try {
                executor.invokeAll(tasks);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("invokeAll returned");
        });
        executorInvokerThread.start();
    }
}

然後給大家解釋一下測試代碼是在幹啥事兒。

首先標號為 ① 的地方,是往 list 裏面塞了 10 個 callable 類型的任務。

搞這麼多任務幹啥呢?

肯定是要往線程池裏面扔,對吧。

所以,在標號為 ② 的地方,搞了一個線程和核心線程數是 2 的線程池。在線程裏面調用了線程池的 invokerAll 方法:

這個方法是幹啥的?

Executes the given tasks, returning a list of Futures holding their status and results when all complete.

執行給定的任務集合,在所有任務完成後返回一個包含其狀態和結果的 Futures 列表。

也就是說,當線程啟動後,線程池會把 list 裏面的任務一個個的去執行,執行完成後返回一個 Futures 列表。

我們寫代碼的時候拿着這個列表就能知道這一批任務是否都執行完成了。

但是,朋友們,但是啊,注意一下,你看我的案例裏面根本就不關心 invokerAll 方法的返回值。

關心的是在 invokerAll 方法執行完成後,輸出的這一句話:

invokeAll returned

好,現在你來說這個程序跑起來有什麼毛病?

你肯定看不出來對不對?

我也看不出來,因為它根本就沒有任何毛病,程序可以正常運行結束:

接着,我把程序修改為這樣,新增標號為 ③ 的這幾行代碼:

這裡調用的是線程池的 shutdown 方法,目的是想等線程池把任務處理完成後,讓程序退出。

來,你又說說這個程序跑起來有什麼毛病?

你肯定又沒有看不來對不對?

我也沒有,因為它根本就沒有任何毛病,程序可以正常運行結束:

好,接下來,我又要開始變形了。

程序變成這樣:

注意我這裡用的是 shutdownNow 方法,意思就是我想立即關閉前面的那個線程池,然後讓整個程序退出。

那麼這個程序有什麼問題呢?

它是真的有問題,肉眼真不好看出來,但是我們可以先看一下運行結果:

結果還是很好觀察的。

沒有輸出 「invokeAll returned」,程序也沒有退出。

那麼問題就來了:你說這是不是 BUG ?

咱先不管原因是啥,從現象上看,這妥妥的是 BUG 了吧?

我都調用 shutdownNow 了,想的就是立馬關閉線程池,然後讓整個程序退出,結果任務確實是沒有執行了,但是程序也並沒有退出啊,和我們預期的不符。

所以,大膽一點,這就是一個 BUG!

再來一個關於 shutdownNow 和 shutdown 方法輸出對比圖,更直觀:

至於這兩個方法之間有什麼區別,我就不講了,你要是不知道就去網上翻翻,背一下。

反正現在 BUG 已經能穩定復現了。

接下來就是找出根因了。

根因

根因怎麼找呢?

你先想想這個問題:程序應該退出卻沒有退出,是不是說明還有線程正在運行,準確的說是還有非守護線程正在運行?

對了嘛,想到這裡就好辦了嘛。

看線程堆棧嘛。

怎麼看?

照相機啊,朋友們。我們的老夥計了,之前的文章裏面經常露面,就它:

你就這麼輕輕的一點,就能看到有個線程它不對勁:

它在 WAITING 狀態,而導致它進入這個狀態的代碼通過堆棧信息,一眼就能定位到,就是 invokeAll 方法的 244 行,也就是這一行代碼:

at java.util.concurrent.AbstractExecutorService.invokeAll(AbstractExecutorService.java:244)

既然問題出在 invokeAll 這個方法裏面,那就得理解這個方法在幹啥了。

源碼也不複雜,主要關注我框起來的這部分:

標號為 ① 的地方,是把傳入進來的任務封裝為一個 Future 對象,先放到一個 List 裏面,然後調用 execute 方法,也就是扔到線程池裏面去執行。

這個操作特別像是直接調用線程池的 submit() 方法,我給你對比一下:

標號為 ② 的地方,就是循環前面放 Future 的 List,如果 Future 沒有執行完成,就調用 Future 的 get 方法,阻塞等待結果。

從堆棧信息上看,線程就阻塞在 Future 的 get 方法這裡,說明這個 Future 一直沒有被執行。

為什麼沒有被執行?

好,我們回到測試代碼的這個地方:

10 個任務,往核心線程數是 2 的線程池裏面扔。

是不是有兩個可以被線程池裏面的線程執行,剩下的 8 個進入到隊列裏面?

好,我問你:調用 shutdownNow 之後,工作線程是不是直接就給乾沒了?剩下的 8 個是不是沒有資源去執行了?

話說回來,哪怕只有 1 個任務沒有被執行呢?invokeAll 方法裏面的 future.get() 是不是也得阻塞?

但是,朋友們,但是啊,就在 BUG 如此清晰的情況下,上面的這個案例居然被官方給推翻了。

怎麼回事呢?

帶你看一下官方大佬的回復。

哦,對不起,不是大佬,是官方巨佬 Martin 和 Doug 的回復:

Martin 說:老鐵,我看了你的代碼,感覺沒毛病啊?你聽我說,shutdownNow 方法返回了一個 List 列表,裏面放的就是還沒有被執行任務。所以你還得拿着 shutdownNow 的返回搞一些事情才行。

Doug 說:Martin 說的對。額外說一句:

that’s why they are returned。

they 指的就是這個 list。也就是說老爺子寫代碼的時候是考慮到這個情況了的,所以把沒有執行的任務都返給了調用者。

好吧,shutdownNow 方法是有返回值的,我之前居然沒有注意到這個細節:

但是你仔細看這個返回值,是個 list 裏面裝的 Runnable,它不是 Future,我就不能調用 future.cancel() 方法。

所以拿到這個返回值之後,我應該怎麼取消任務呢?

這個問題問得好啊。因為提問者也有這樣的疑問:

他在看到巨佬們說要對返回值做操作之後,一臉懵逼的回復說:哥老倌些,shutdownNow 方法返回的是一個List。至少對我來說,我不知道應該這麼去取消這些任務。是不是應該在文檔裏面描述一下哦?

Martin 老哥覺得這個返回確實有點迷惑性,他做了如下回復:

線程池提交任務有兩種方式。

如果你用 execute() 方法提交 Runnable 任務,那麼 shutdownNow 返回的是未被執行的 Runnable 的列表。

如果你用 submit() 方法提交 Runnable 任務,那麼會被封裝為一個 FutureTask 對象,所以調用 shutdownNow 方法返回的是未被執行的 FutureTask 的列表:

也就是說 shutdownNow 方法返回的 List 集合,裏面裝的既可能是 Runnable,也可能是 FutureTask,取決於你往線程池裏面扔任務的時候調用的什麼方法。

FutureTask 是 Runnable 的子類:

所以,基於 Martin 老哥的說法和他提供的代碼,我們可以把測試用例修改為這樣:

遍歷 shutdownNow 方法返回的 List 集合,然後判斷是否 Future,如果是則強轉為 Future,接着調用其 cancel 方法。

這樣,程序就能正常運行結束。

這樣看來,好像也確實不是一個 BUG,可以通過編碼來避免它。

反轉

但是,朋友們,但是啊,前面都是我的鋪墊,接下來劇情開始反轉了。

我們回到這個鏈接中:

//bugs.openjdk.org/browse/JDK-8286463

這個鏈接裏面提到了 DiscardPolicy 這個線程池拒絕策略。

只要我稍微的把我們的 Demo 程序改變一點點,觸發線程的 DiscardPolicy 拒絕策略,前面這個 bug 就真的是一個繞不過去的 bug 了。

應該怎麼改變呢?

很簡單,換個線程池就可以了:

把我們之前這個核心線程數為 2,隊列長度無限長的線程池替換為一個自定義線程池。

這個自定義線程池的核心線程數、最大線程數、隊列長度都是 1,採用的線程拒絕策略是 DiscardPolicy。

其他的地方代碼都不動,整個代碼就變成了這樣,我把代碼貼出來給你看看,方便你直接運行:

public class MainTest {

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

        List<Callable<Void>> tasks = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            int finalI = i;
            tasks.add(() -> {
                System.out.println("callable " + finalI);
                Thread.sleep(500);
                return null;
            });
        }
        ExecutorService executor = new ThreadPoolExecutor(
                1,
                1,
                1,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(1),
                new ThreadPoolExecutor.DiscardPolicy()
        );
//        ExecutorService executor = Executors.newFixedThreadPool(2);
        Thread executorInvokerThread = new Thread(() -> {
            try {
                executor.invokeAll(tasks);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("invokeAll returned");
        });
        executorInvokerThread.start();

        Thread.sleep(800);
        System.out.println("shutdown");
        List<Runnable> runnables = executor.shutdownNow();
        for (Runnable r : runnables) {
            if (r instanceof Future) ((Future<?>)r).cancel(false);
        }
        System.out.println("Shutdown complete");
    }
}

然後我們先把程序運行起來看結果:

誒,怎麼回事?

我明明處理了 shutdownNow 的返回值呢,怎麼程序又沒有輸出 「invokeAll returned」 了,又阻塞在 invokeAll 方法上了?

就算我們不知道為什麼程序沒有停下來,但是從表現上看,這玩意肯定是 bug 了吧?

接下來我帶你分析一下為什麼會出現這個現象。

首先我問你在我們的案例裏面,這個線程池最多能容納幾個任務?

是不是最多只能接收 2 個任務?

最多只能接收 2 個任務,是不是說明我有 8 個任務是處理不了的,需要執行線程池的拒絕策略?

但是我們的拒絕策略是什麼?

是 DiscardPolicy,它的實現是這樣的,也就是靜默處理,丟棄任務,也不拋出異常:

好,到這裡你又接着想,shutdownNow 返回的是什麼東西,是不是線程池裏面還沒來得及執行的任務,也就是隊列裏面的任務?

但是隊列裏面最多也就一個任務,返回回來給你取消了也沒用。

所以,這個案例和處不處理 shutdownNow 的返回值沒有關係。

關鍵的是被拒絕的這 8 個任務,或者說關鍵是觸發了 DiscardPolicy 拒絕策略。

觸發一次和觸發多次的效果都是一樣的,在我們這個自定義線程池加 invokeAll 方法這個場景下,只要有任何一個任務被靜默處理了,就算玩蛋。

為什麼這樣說呢?

我們先看看默認的線程池拒絕策略 AbortPolicy 的實現方式:

被拒絕執行之後,它是會拋出異常,然後執行 finally 方法,調用 cancel,接着在 invokeAll 方法裏面會被捕捉到,所以不會阻塞:

如果是靜默處理,你沒有任何地方讓這個被靜默處理的 Future 拋出異常,也沒用任何地方能調用它的 cancel 方法,所以這裡就會一直阻塞。

所以,這就是 BUG。

那麼針對這個 BUG,官方是怎麼回復呢?

Martin 巨佬回復說:我覺得吧,應該在文檔上說明一下,DiscardPolicy 這個拒絕策略,在真實的場景中很少使用,不建議大家使用。要不,你把它當作一個 feature?

我覺得言外之意就是:我知道這是一個 BUG 了,但是你非得用 DiscardPolicy 這個不會在實際編碼中使用的拒絕策略來說事兒,我覺得你是故意來卡 BUG 的。

我對於這個回復是不滿意的。

Martin 老哥是有所不知,我們面試的時候有一個八股文環節,其中的一個老八股題是這樣的:

你有沒有自定義過線程池拒絕策略?

如果有一些大聰明,在自定義線程池拒絕策略的時候,寫出了一個花里胡哨的,但是又等效於 DiscardPolicy 的拒絕策略。

也就是又沒放進隊列,又沒拋出異常,不管你代碼寫的多花哨,一樣的是有這個問題。

所以,我覺得還是 invokeAll 方法的設計問題,一個不能在調用線程之外被其他線程訪問的 Future 就不應該被設計出來。

這違背了 Future 這個對象的設計理論。

所以我才說這是 BUG,也是設計問題。

什麼,你問我應該怎麼設計?

對不起,無可奉告。