Doug Lea在J.U.C包裡面寫的BUG又被網友發現了

why技術發表於2020-10-10

這是why的第 69 篇原創文章

BUG描述

一個編號為 8073704 的 JDK BUG,將串聯起我的這篇文章。

也就是下面的這個連結。

https://bugs.openjdk.java.net/browse/JDK-8073704

這個 BUG 在 JDK 9 版本中進行了修復。也就是說,如果你用的 JDK 8,也許會遇到這樣的問題。

先帶大家看看這個問題是怎麼樣的:

這個 BUG 說:FutureTask.isDone 方法在任務還沒有完成的時候就會返回 true。

可以看到,這是一個 P4 級別(優先順序不高)的 BUG,這個 BUG 也是分配給了 Doug Lea,因為 FutureTask 類就是他寫的:

響應了國家政策:誰汙染,誰治理。

這個 BUG 的作者 Martin 老哥是這樣描述的:

下面我會給大家翻譯一下他要表達的東西。

但是在翻譯之前,我得先做好背景鋪墊,以免有的朋友看了後一臉懵逼。

如果要懂他在說什麼,那我必須得再給你看個圖片,這是 FutureTask 的文件描述:

看 Martin 老哥提交的 BUG 描述的時候,得對照著狀態圖和狀態對應的數字來看。

他說 FutureTask#isDone 方法現在是這樣寫的:

他覺得從原始碼來看,是隻要當前狀態不等於 NEW(即不等於0)則返回 true,表示任務完成。

他覺得應該是這樣寫:

這樣寫的目的是除了判斷了 NEW 狀態之外,還判斷了兩個中間狀態:COMPLETING 和 INTERRUPTING。

那麼除去上面的三個狀態之外呢,就只剩下了這四個狀態:

這四個狀態可以代表一個任務的最終狀態。

當然,他說上面的程式碼還有優化空間,比如下面這樣,程式碼少了,但是理解起來也得多轉個彎:

state>COMPLETING,滿足這個條件的狀態只有下面這幾種:

而這幾種中,只有 INTERRUPTING 是一箇中間態,所以他用後面的 != 排除掉了。

這樣就是程式碼簡潔了,但是理解起來多轉個小彎。但是這兩段程式碼表示的含義是一模一樣的。

好了,關於這個 BUG 的描述就是這樣的。

彙總為一句話就是,這個 Martin 老哥認為:

FutureTask.isDone 方法在任務還沒有完成的時候,比如還是 COMPLETING 和 INTERRUPTING 的時候就會返回 true,這樣是不對的。這就是 BUG。

僅從 isDone 原始碼中那段 status != NEW 的程式碼,我認為這個 Martin 老哥說的確實沒有問題。因為確實有兩個中間態,這段原始碼中是沒有考慮的。

接下來,我們就圍繞著這個問題進行展開,看看各位大神的討論。

展開討論

首先,第一個發言的哥們是 Pardeep,是在這個問題被提出的 13 天之後:

我沒有太 get 到這個哥們回答的點是什麼啊。

他說:我們應該去看一下 isDone 方法的描述。

描述上說:如果一個任務已完成,呼叫這個方法則返回true。而完成除了是正常完成外,還有可能是任務異常或者任務取消導致的完成,這些都算完成。

我覺得他的這個回答和問題有點對不上號,感覺是答非所問。

就當他丟擲了一個關於 isDone 方法的知識點吧。

三天後,第二個發言的哥們叫做 Paul,他的觀點是這樣的:

首先,他說我們不需要檢查 INTERRUPING 這個中間狀態。

因為如果一個任務處於這個狀態,那麼獲取結果的時候一定是丟擲 CancellationException。

叫我們看看 isCancelled 方法和 get 方法。

那我們先看看 isCancelled 方法:

直接判斷了狀態是否大於等於 CANCELLED,也就是判斷了狀態是否是這三種中的一個:

判斷任務是否取消(isCancelled)的時候,並沒有對 INTERRUPING 這個中間狀態做特殊處理。

按照這個邏輯,那麼判斷任務是否完成(isDone)的時候,也不需要對 INTERRUPING 這個中間狀態做特殊處理。

接著,我們看看 get 方法。

get 方法最終會呼叫這個 report 方法:

如果變數 s (即狀態)是 INTERRUPING (值是 5),那麼是大於 CANCELLED (值是 4)狀態的,則丟擲 CancellationException (CE)異常。

所以,他覺得對於 INTERRUPING 狀態沒有必要進行檢測。

因為如果你呼叫 isCancelled 方法,那麼會告訴你任務取消了。

如果你呼叫 get 方法,會丟擲 CE 異常。

所以,綜上所述,我認為 Paul 這個哥們的邏輯是這樣的:

我們作為使用者,最終都會呼叫 get 方法來獲取結果,假設在呼叫 get 方法之前。我們用 isCancelled 或者 isDone 判斷了一下任務的狀態。

如果當前狀態好死不死的就是 INTERRUPING 。那麼呼叫 isCancelled 返回 true,那按照正常邏輯,是不會繼續呼叫 get 方法的。

如果呼叫的是 isDone ,那麼也返回 true,就會去呼叫 get 方法。

所以在 get 方法這裡保證了,就算當前處於 INTERRUPING 中間態,程式丟擲 CE 異常就可以了。

因此,Paul 認為如果沒有必要檢測 INTERRUPING 狀態的話,那麼我們就可以把程式碼從:

簡化為:

但是,這個哥們還說了一句話來兜底。

他說:Unless i have missed something subtle about the interactions

除非我沒有注意到一些非常小的細節問題。你看,說話的藝術。話都被他一個人說完了。

好了,Paul 同學發言完畢了。42 分鐘之後,一個叫 Chris 的小老弟接過了話筒,他這樣說的:

我覺得吧,保羅說的挺有道理的,我贊成他的建議。

但是吧,我也覺得我們在討論的是一個非常細節,非常小的問題,我不知道,就算現在這樣寫,會導致任何問題嗎?

寫到這裡,先給大家捋一下:

  • Martin 老哥提出 BUG 說 FutureTask#isDone 方法沒有判斷 INTERRUPING 和 COMPLETING 這個兩個中間狀態是不對的。
  • Paul 同學說,對於 INTERRUPING 這個狀態,可以參照 isCancelled 方法,不需要做特殊判斷。
  • Chris 小老弟說 Paul 同學說的對。

於是他們覺得 isDone 方法應該修改成這樣:

所以,現在只剩下一箇中間狀態是有爭議的了:COMPLETING 。

對於剩下的這個中間狀態,一位叫做 David 的靚仔,在三小時後發表了自己的意見:

他上來就是一個暴擊,直截了當的說:我認為在座的各位都是垃圾。

好吧,他沒有這樣說。所以你看,還是要多學學英語,不然我騙了你,你還不知道。

其實他說的是:我認為沒有必要做任何改變。

COMPLETING 狀態是一個轉瞬即逝的過渡狀態,它代表我們已經有最終狀態了,但是在設定最終狀態開始和結束的時間間隙內有一個瞬間狀態,它就是 COMPLETING 狀態。

其實你是可以通過 get 方法知道任務是否是完成了,通過這個方法你可以獲得最終的正確答案。

因為 COMPLETING 這個轉瞬即逝的過渡狀態是不會被程式給檢測到的。

David 靚仔的回答在兩個半小時候得到了大佬的肯定:

Doug Lea 說:現在原始碼裡面是故意這樣寫的,原因就是 David 這位靚仔說的,我寫的時候就是這樣考慮過的。

另外,我覺得這個 BUG 的提交者自己應該解釋我們為什麼需要修改這部分程式碼。

其實 Doug 的言外之意就是:你說這部分有問題,你給我舉個例子,別隻是整理論的,你弄點程式碼給我看看。

半小時之後,這個 BUG 的提交者回復了:

intentional 知道是啥意思不?

害,我又得兼職教英語了:

他說:哦,原來是故意的呀。

這句話,你用不同的語氣可以讀出不同的含義。

我這裡傾向於他覺得既然 Doug 當初寫這段程式碼的時候考慮到了這點,他分析之後覺得自己這樣寫是沒有問題的,就這樣寫了。

好嘛,前面說 INTERRUPING 不需要特殊處理,現在說 COMPLETING 狀態是檢測不到的。

那就沒得玩了。

事情現在看起來已經是被定性了,那就是不需要進行修改。

但是就在這時 Paul 同學殺了個回馬槍,應該也是前面的討論激發了他的思路,你不是說檢測不出來嗎,你不是說 get 方法可以獲得最終的正確結果嗎?

那你看看我這段程式碼是什麼情況:

程式碼是這樣的,大家可以直接貼上出來,在 JDK 8/9 環境下分別執行一下:

public static void main(String[] args) throws Exception {
        AtomicReference<FutureTask<Integer>> a = new AtomicReference<>();
        Runnable task = () -> {
            while (true) {
                FutureTask<Integer> f = new FutureTask<>(() -> 1);
                a.set(f);
                f.run();
            }
        };
        Supplier<Runnable> observe = () -> () -> {
            while (a.get() == null);
            int c = 0;
            int ic = 0;
            while (true) {
                c++;
                FutureTask<Integer> f = a.get();
                while (!f.isDone()) {}
                try {
                    /*
                    Set the interrupt flag of this thread.
                    The future reports it is done but in some cases a call to
                    "get" will result in an underlying call to "awaitDone" if
                    the state is observed to be completing.
                    "awaitDone" checks if the thread is interrupted and if so
                    throws an InterruptedException.
                     */
                    Thread.currentThread().interrupt();
                    f.get();
                }
                catch (ExecutionException e) {
                    throw new RuntimeException(e);
                }
                catch (InterruptedException e) {
                    ic ++;
                    System.out.println("InterruptedException observed when isDone() == true " + c + " " + ic + " " + Thread.currentThread());
                }
            }
        };
        CompletableFuture.runAsync(task);
        Stream.generate(observe::get)
                .limit(Runtime.getRuntime().availableProcessors() - 1)
                .forEach(CompletableFuture::runAsync);
        Thread.sleep(1000);
        System.exit(0);
    }

先看一下這段程式碼的核心邏輯:

首先標號為 ① 的地方是兩個計數器,c 代表的是第一個 while 迴圈的次數,ic 代表的是丟擲 InterruptedException(IE) 的次數。

標號為 ② 的地方是判斷當前任務是否是完成狀態,如果是,則繼續往下。

標號為 ③ 的地方是先中斷當前執行緒,然後呼叫 get 方法獲取任務結果。

標號為 ④ 的地方是如果 get 方法丟擲了 IE 異常,則在這裡進行記錄,列印日誌。

需要注意的是,如果列印日誌了,說明了一個問題:

前面明明 isDone 方法返回 true 了,說明方法執行完成了。但是我呼叫 get 方法的時候卻丟擲了 IE 異常?

這你怕是有點說不通吧!

JDK 8 的執行結果我給大家截個圖。

這個異常是在哪裡被丟擲來的呢?

awaitDone 方法的入口處,就先檢查了當前執行緒是否被中斷,如果被中斷了,那麼丟擲 IE 異常:

而程式碼怎麼樣才能執行到 awaitDone 方法呢?

任務狀態是小於等於 COMPLETING 的時候。

在示例程式碼中,前面的 while 迴圈中的 isDone 方法已經返回了 true,說明當前狀態肯定不是 NEW。

那麼只剩下個什麼東西了?

就只有一個 COMPLETING 狀態了。

小樣,這不就是監測到了嗎?

在這段示例程式碼出來後的第 8 個小時,David 靚仔又來說話了:

他要表達的意思,我理解的是這樣的:

在 j.u.c 包裡面,優先檢查執行緒中斷狀態是很常見的操作,因為相對來說,會導致執行緒中斷的地方非常的少。

但是不能因為少,我們就不檢查了。

我們還是得對其進行了一個優先檢查,告知程式當前執行緒是否發生了中斷,即是否有繼續往下執行的意義。

但是,在這個場景中,當前執行緒中斷了,但並不能表示 Future 裡面的 task 任務的完成情況。這是兩個不相關的事情。

即使當前執行緒中斷了,但是 task 任務仍然可以繼續完成。但是執行 get 方法的執行緒被中斷了,所以可能會丟擲 InterruptedException。

因此,他給出的解決建議是:

可以選擇優先返回結果,在 awaitDone 方法的迴圈中把檢查中斷的程式碼挪到後面去。

五天之後,之前 BUG 的提交者 Martin 同學又來了:

他說他改變主意了。

改變什麼主意了?他之前的主意是什麼?

在 Doug 說他是故意這樣寫的之後,Martin 說:

It's intentional。哦,原來是故意的呀。

那個時候他的主意就是:大佬都說了,這樣寫是考慮過的,肯定沒有問題。

現在他的主意是:如果 isDone 方法返回了 true,那麼 get 方法應該明確的返回結果值,而不會丟擲 IE 異常。

需要注意的是,這個時候對於 BUG 的描述已經發生變化了。

從“FutureTask.isDone 方法在任務還沒有完成的時候就會返回 true”變成了“如果 isDone 方法返回了 true,那麼 get 方法應該明確的返回結果值,而不會丟擲 IE 異常”。

然後 David 靚仔給出了一個最簡單的解決方案:

最簡單的解決方案就是先檢查狀態,再檢查當前執行緒是否中斷。

然後,這個 BUG 由 Martin 同學進行了修復:

修復的程式碼可以先不看,下面一小節我會給大家做個對比。

他修復的同時還小心翼翼的要求 Doug 祝福他,為他站個臺。

最後,Martin 同學說他已經提交給了 jsr166,預計在 JDK 9 版本進行修復。

出於好奇,我在 JDK 的原始碼中搜尋了一下 Martin 同學的名字,本以為是個青銅,沒想到是個王者,失敬失敬:

程式碼對比

既然說在 JDK 9 中對該 BUG 進行了修復,那麼帶大家對比一下 JDK 9/8 的程式碼。

java.util.concurrent.FutureTask#awaitDone:

可以看到,JDK 9 把檢查是否中斷的操作延後了一步。

程式碼修改為這樣後,把之前的那段示例程式碼放到 JDK 9 上面跑一下,你會驚奇的發現,沒有丟擲異常了。

因為原始碼裡面判斷 COMPLETING 的操作在判斷執行緒中斷標識之前:

我想就不需要我再過多解釋了吧。

然後多說一句 JDK 9 現在的 FutureTask#awaitDone 裡面有這樣的一行註釋:

它說:isDone 方法已經告訴使用者任務已經完成了,那麼呼叫 get 方法的時候我們就不應該什麼都不返回或者丟擲一個 IE 異常。

這行註釋想要表達的東西,就是上面一小節的 BUG 裡面我們在討論的事情。寫這行註釋的人,就是 Martin 同學。

當我瞭解了這個 BUG 的來龍去脈之後,又突然間在 JDK 9 的原始碼裡面看到這個註釋的時候,有一種很神奇的感覺。

就是一種原始碼說:you feel me?

我馬上心領神會:I get you。

挺好。

虛假喚醒

在 JDK 9 的註釋裡面還有這個詞彙:

spurious wakeup,虛假喚醒。

如果你之前不知道這個東西的存在,那麼恭喜你,又 get 到了一個你基本上用不到的知識點。

除非你自己需要在程式碼中用到 wait、notify 這樣的方法。

哦,也不對,面試的時候可能會用到。

“虛假喚醒”是怎麼一回事呢,我給你看個例子:

java.lang.Thread#join(long) 方法:

這裡為什麼要用 while 迴圈,而不是直接用 if 呢?

因為迴圈體內有呼叫 wait 方法。

為什麼呼叫了 wait 方法就必須用 while 迴圈呢?

別問,問就是防止虛假喚醒。

看一下 wait 方法的 javadoc:

一個執行緒能在沒有被通知、中斷或超時的情況下喚醒,也即所謂的“虛假喚醒”,雖然這點在實踐中很少發生,但是程式應該迴圈檢測導致執行緒喚醒的條件,並在條件不滿足的情況下繼續等待,來防止虛假喚醒。

所以,建議寫法是這樣的:

在 join 方法中,isAlive 方法就是這裡的 condition does not hold。

在《Effective Java》一書中也有提到“虛假喚醒”的地方:

書中的建議是:沒有理由在新開發的程式碼中使用 wait、notify 方法,即使有,也應該是極少了,請多使用併發工具類。

再送你一個面試題:為什麼 wait 方法必須放在 while 迴圈體內執行?

現在你能回答的上來這個問題了吧。

關於“虛假喚醒”就說這麼多,有興趣的同學可以再去仔細瞭解一下。

Netty的一個坑

好好的說著 JDK 的 FutureTask 呢,怎麼突然轉彎到 Netty 上了?

因為 Netty 裡面,其核心的 Future 介面實現中,犯了一個基本的邏輯錯誤,在實現 cancel 和 isDone 方法時違反了 JDK 的約定。

這是一個讓 Netty 作者也感到驚訝的錯誤。

先看看 JDK Future 介面中,對於 cancel 方法的說明:

https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/Future.html

文件的方法說明上說:如果呼叫了 cancel 方法,那麼再呼叫 isDone 將永遠返回 true。

看一下這個測試程式碼:

可以看到,在呼叫了 cancel 方法後,再次呼叫 isDone 方法,返回的確實 false。

這個點我是很久之前在知乎的這篇文章上看到的,和本文討論的內容有一點點相關度,我就又翻了出來,多說了一嘴。

有興趣的可以看看:《一個讓Netty作者也感到驚訝的錯誤》

好啦,才疏學淺,難免會有紕漏,如果你發現了錯誤的地方,可以在留言區提出來,我對其加以修改。

感謝您的閱讀,我堅持原創,十分歡迎並感謝您的關注。

相關文章