喜提JDK的BUG一枚!多執行緒的情況下請謹慎使用這個類的stream遍歷。

why技術發表於2022-06-13

你好呀,我是歪歪。

前段時間在 RocketMQ 的 ISSUE 裡面衝浪的時候,看到一個 pr,雖說是在 RocketMQ 的地盤上發現的,但是這個玩意吧,其實和 RocketMQ 沒有任何關係。

純純的就是 JDK 的一個 BUG。

我先問你一個問題:LinkedBlockingQueue 這個玩意是執行緒安全的嗎?

這都是老八股文了,你要是不能脫口而出,應該是要挨板子的。

答案是:是執行緒安全的,因為有這兩把鎖的存在。

但是在 RocketMQ 的某個場景下,居然穩定復現了 LinkedBlockingQueue 執行緒不安全的情況。

先說結論:LinkedBlockingQueue 的 stream 遍歷的方式,在多執行緒下是有一定問題的,可能會出現死迴圈。

老有意思了,這篇文章帶大家盤一盤。

搞個Demo

Demo 其實都不用我搞了,前面提到的 pr 的連結是這個:

https://github.com/apache/rocketmq/pull/3509

在這個連結裡面,前面圍繞著 RocketMQ 討論了很多。

但是在中間部分,一個暱稱叫做 areyouok 的大佬一針見血,指出了問題的所在。

直接給出了一個非常簡單的復現程式碼。而且完全把 RocketMQ 的東西剝離了出去:

正所謂前人栽樹後人乘涼,既然讓我看到了 areyouok 這位大佬的程式碼,那我也就直接拿來當做演示的 Demo 了。

如果你不介意的話,為了表示我的尊敬,我斗膽說一聲:感謝雷總的程式碼。

我先把雷總的程式碼粘出來,方便看文章的你也實際操作一把:

public class TestQueue {
    public static void main(String[] args) throws Exception {
        LinkedBlockingQueue<Object> queue = new LinkedBlockingQueue<>(1000);
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                while (true) {
                    queue.offer(new Object());
                    queue.remove();
                }
            }).start();
        }
        while (true) {
            System.out.println("begin scan, i still alive");
            queue.stream()
                    .filter(o -> o == null)
                    .findFirst()
                    .isPresent();
            Thread.sleep(100);
            System.out.println("finish scan, i still alive");
        }
    }
}

介紹一下上面的程式碼的核心邏輯。

首先是搞了 10 個執行緒,每個執行緒裡面在不停的呼叫 offer 和 remove 方法。

需要注意的是這個 remove 方法是無參方法,意思是移除頭節點。

再強調一次:LinkedBlockingQueue 裡面有 ReentrantLock 鎖,所以即使多個執行緒併發操作 offer 或者 remove 方法,也都要分別拿到鎖才能操作,所以這一定是執行緒安全的。

然後主執行緒裡面搞個死迴圈,對 queue 進行 stream 操作,看看能不能找到佇列裡面第一個不為空的元素。

這個 stream 操作是一個障眼法,真正的關鍵點在於 tryAdvance 方法:

先在這個方法這裡插個眼,一會再細嗦它。

按理來說,這個方法執行起來之後,應該不停的輸出這兩句話才對:

begin scan, i still alive
finish scan, i still alive

但是,你把程式碼粘出去用 JDK 8 跑一把,你會發現控制檯只有這個玩意:

或者只交替輸出幾次就沒了。

但是當我們不動程式碼,只是替換一下 JDK 版本,比如我剛好有個 JDK 15,替換之後再次執行,交替的效果就出來了:

那麼基於上面的表現,我是不是可以大膽的猜測,這是 JDK 8 版本的 BUG 呢?

現在我們有了能在 JDK 8 執行環境下穩定復現的 Demo,接下來就是定位 BUG 的原因了。

啥原因呀?

先說一下我拿到這個問題之後,排查的思路。

非常的簡單,你想一想,主執行緒應該一直輸出但是卻沒有輸出,那麼它到底是在幹什麼呢?

我初步懷疑是在等待鎖。

怎麼去驗證呢?

朋友們,可愛的小相機又出現了:

通過它我可以 Dump 當前狀態下各個執行緒都在幹嘛。

但是當我看到主執行緒的狀態是 RUNNABLE 的時候,我就有點懵逼了:

啥情況啊?

如果是在等待鎖,不應該是 RUNNABLE 啊?

再來 Dump 一次,驗證一下:

發現還是在 RUNNABLE,那麼直接就可以排除鎖等待的這個懷疑了。

我專門體現出兩次 Dump 執行緒的這個操作,是有原因的。

因為很多朋友在 Dump 執行緒的時候拿著一個 Dump 檔案在哪兒使勁分析,但是我覺得正確的操作應該是在不同時間點多次 Dump,對比分析不同 Dump 檔案裡面的相同執行緒分別是在幹啥。

比如我兩次不同時間點 Dump,發現主執行緒都是 RUNNABLE 狀態,那麼說明從程式的角度來說,主執行緒並沒有阻塞。

但是從控制檯輸出的角度來說,它似乎又是阻塞住了。

經典啊,朋友們。你想想這是什麼經典的畫面啊?

這不就是,這個玩意嗎,執行緒裡面有個死迴圈:

System.out.println("begin scan, i still alive");
while (true) {}
System.out.println("finish scan, i still alive");

來驗證一波。

從 Dump 檔案中我們可以觀察到的是主執行緒正在執行這個方法:

at java.util.concurrent.LinkedBlockingQueue$LBQSpliterator.tryAdvance(LinkedBlockingQueue.java:950)

還記得我前面插的眼嗎?

這裡就是我前面說的 stream 只是障眼法,真正關鍵的點在於 tryAdvance 方法。

點過去看一眼 JDK 8 的 tryAdvance 方法,果不其然,裡面有一個 while 迴圈:

從 while 條件上看是 current!=null 一直為ture,且 e!=null 一直為 false,所以跳不出這個迴圈。

但是從 while 迴圈體裡面的邏輯來看,裡面的 current 節點是會發生變化的:

current = current.next;

來,結合這目前有的這幾個條件,我來細嗦一下。

  • LinkedBlockingQueue 的資料結果是連結串列。
  • 在 tryAdvance 方法裡面出現了死迴圈,說明迴圈條件 current=null 一直是 true,e!=null 一直為 false。
  • 但是迴圈體裡面有獲取下一節點的動作,current = current.next。

綜上可得,當前這個連結串列中有一個節點是這樣的:

只有這樣,才會同時滿足這兩個條件:

  • current.item=null
  • current.next=null

那麼什麼時候才會出現這樣的節點呢?

這個情況就是把節點從連結串列上拿掉,所以肯定是呼叫移除節點相關的方法的時候。

縱觀我們的 Demo 程式碼,裡面和移除相關的程式碼就這一行:

queue.remove();

而前面說了,這個 remove 方法是移除頭節點,效果和 poll 是一樣一樣的,它的原始碼裡面也是直接呼叫了 poll 方法:

所以我們主要看一下 poll 方法的原始碼:

java.util.concurrent.LinkedBlockingQueue#poll()

兩個標號為 ① 的地方分別是拿鎖和釋放鎖,說明這個方法是執行緒安全的。

然後重點是標號為 ② 的地方,這個 dequeue 方法,這個方法就是移除頭節點的方法:

java.util.concurrent.LinkedBlockingQueue#dequeue

它是怎麼移除頭節點的呢?

就是我框起來的部分,自己指向自己,做一個性格孤僻的節點,就完事了。

h.next=h

也就是我前面畫的這個圖:

那麼 dequeue 方法的這個地方和 tryAdvance 方法裡面的 while 迴圈會發生一個什麼樣神奇的事情呢?

這玩意還不好描述,你知道吧,所以,我決定下面給你畫個圖,理解起來容易一點。

畫面演示

現在我已經掌握到這個 BUG 的原理了,所以為了方便我 Debug,我把例項程式碼也簡化一下,核心邏輯不變,還是就這麼幾行程式碼,主要還是得觸發 tryAdvance 方法:

首先根據程式碼,當 queue 佇列新增完元素之後,佇列是長這樣的:

畫個示意圖是這樣的:

然後,我們接著往下執行遍歷的操作,也就是觸發 tryAdvance 方法:

上面的圖我專門多截了一個方法。

就是如果往上再看一步,觸發 tryAdvance 方法的地方叫做 forEachWithCancel ,從原始碼上看其實也是一個迴圈,迴圈結束條件是 tryAdvance 方法返回為 false ,意思是遍歷結束了。

然後我還特意把加鎖和解鎖的地方框起來了,意思是說明 try 方法是執行緒安全的,因為這個時候把 put 和 take 的鎖都拿到了。

說人話就是,當某個執行緒在執行 tryAdvance 方法,且加鎖成功之後,如果其他執行緒需要操作佇列,那麼是獲取不到鎖的,必須等這個執行緒操作完成並釋放鎖。

但是加鎖的範圍不是整個遍歷期間,而是每次觸發 tryAdvance 方法的時候。

而每次 tryAdvance 方法,只處理連結串列中的一個節點。

到這裡鋪墊的差不多了,接下來我就帶你逐步的分析一下 tryAdvance 方法的核心原始碼,也就是這部分程式碼:

第一次觸發的時候,current 物件是 null,所以會執行一個初始化的東西:

current = q.head.next;

那麼這個時候 current 就是 節點 1:

接著執行 while 迴圈,這時 current!=null 條件滿足,進入迴圈體。

在迴圈體裡面,會執行兩行程式碼。

第一行是這個,取出當前節點裡面的值:

e = current.item;

在我的 Demo 裡面,e=1。

第二行是這行程式碼,含義是維護 current 為下一節點,等著下次 tryAdvance 方法觸發的時候直接拿來用:

current = current.next;

接著因為 e!=null,所以 break 結束迴圈:

第一次 tryAdvance 方法執行完成之後,current 指向的是這個位置的節點:

朋友們,接下來有意思的就來了。

假設第二次 tryAdvance 方法觸發的時候,執行到下面框起來的部分的任意一行程式碼,也就是還沒有獲取鎖或者獲取不到鎖的時候:

這時候有另外一個執行緒來了,它在執行 remove() 方法,不斷的移除頭結點。

執行三次 remove() 方法之後,連結串列就變成了這樣:

接下來,當我把這兩個圖合併在一起的時候,就是見證奇蹟的時候:

當第三次執行 remover 方法後,tryAdvance 方法再次成功搶到鎖,開始執行,從我們的上帝視角,看到的是這樣的場景:

這一點,我可以從 Debug 的檢視裡面進行驗證:

可以看到,current 的 next 節點還是它自己,而且它們都是 LinkedBlockingQueue$Mode@701 這個物件,並不為 null。

所以這個地方的死迴圈就是這麼來的。

分析完了之後,你再回想一下這個過程,其實這個問題是不是並沒有想象的那麼困難。

你要相信,只要給到你能穩定復現的程式碼,一切 BUG 都是能夠除錯出來的。

我在除錯的過程中,還想到了另外一個問題:如果我呼叫的是這個 remove 方法呢,移除指定元素。

會不會出現一樣的問題呢?

我也不知道,但是很簡單,實驗一把就知道了。

還是在 tryAdvance 方法裡面打上斷點,然後在第二次觸發 tryAdvance 方法之後,通過 Alt+F8 調出 Evaluate 功能,分別執行 queue.remove 1,2,3:

然後觀察 current 元素,並沒有出現自己指向自己的情況:

為什麼呢?

原始碼之下無祕密。

答案就寫在 unlink 方法裡面:

入參中的 p 是要移除的節點,而 trail 是要移除的節點的上一個節點。

在原始碼裡面只看到了 trail.next=p.next,也就是通過指標,跳過要移除的節點。

但是並沒有看到前面 dequeue 方法中出現的類似於 p.next=p 的原始碼,也就是把節點的下一個節點指向自己的動作。

為什麼?

作者都在註釋裡面給你寫清楚了:

p.next is not changed, to allow iterators that are traversing p to maintain their weak-consistency guarantee.
p.next 沒有發生改變,因為在設計上是為了保持正在遍歷 p 的迭代器的弱一致性。

說人話就是:這玩意不能指向自己啊,指向自己了要是這個節點正在被迭代器執行,那不是完犢子了嗎?

所以帶參的 remove 方法是考慮到了迭代器的情況,但是無參的 remove 方法,確實考慮不周。

怎麼修復的?

我在 JDK 的 BUG 庫裡面搜了一下,其實這個問題 2016 年就出現在了 JDK 的 BUG 列表裡面:

https://bugs.openjdk.org/browse/JDK-8171051

在 JDK9 的版本里面完成了修復。

我本地有一份 JDK15 的原始碼,所以給你對比著 JDK8 的原始碼看一下:

主要的變化是在 try 的程式碼塊裡面。

JDK15 的原始碼裡面呼叫了一個 succ 方法,從方法上的註釋也可以看出來就是專門修復這個 BUG 的:

比如回到這個場景下:

我們來細嗦一下當前這個情況下, succ 方法是怎麼處理的:

Node<E> succ(Node<E> p) {
    if (p == (p = p.next))
        p = head.next;
    return p;
}

p 是上圖中的 current 對應的元素。

首先 p = p.next 還是 p,因為它自己指向自己了,這個沒毛病吧?

那麼 p == (p = p.next),帶入條件,就是 p==p,條件為 true,這個沒毛病吧?

所以執行 p = head.next,從上圖中來看,head.next 就是元素為 4 的這個節點,沒毛病吧?

最後取到了元素 4,也就是最後一個元素,接著結束迴圈:

沒有死迴圈,完美。

延伸一下

回到我這篇文章開篇的一個問題:LinkedBlockingQueue 這個玩意是執行緒安全的嗎?

下次你面試的時候遇到這個問題,你就微微一笑,答到:由於內部有讀寫鎖的存在,這個玩意一般情況下是執行緒安全的。但是,在 JDK8 的場景下,當它遇到 stream 操作的時候,又有其他執行緒在呼叫無參的 remove 方法,會有一定機率出現死迴圈的情況。

說的時候自信一點,一般情況下,可以唬一下面試官。

前面我給的解決方案是升級 JDK 版本,但是你知道的,這是一個大動作,一般來說,能跑就不要輕舉妄動,

所以另外我還能想到兩個方案。

第一個你就別用 stream 了唄,老老實實的使用迭代器迴圈,它不香嗎?

第二個方案是這樣的:

效果槓槓的,絕對沒問題。

你內部的 ReentrantLock 算啥,我直接給你來個鎖提升,外部用 synchronized 給你包裹起來。

來,你有本事再給我表演一個執行緒不安全。

現在,我換一個問題問你:ConcurrentHashMap 是執行緒安全的嗎?

我之前寫過,這玩意在 JDK8 下也是有死迴圈的《震驚!ConcurrentHashMap裡面也有死迴圈,作者留下的“彩蛋”瞭解一下?》

在文章的最後我也問了一樣的問題。

當時的回答再次搬運一下:

是的,ConcurrentHashMap 本身一定是執行緒安全的。但是,如果你使用不當還是有可能會出現執行緒不安全的情況。

給大家看一點 Spring 中的原始碼吧:

org.springframework.core.SimpleAliasRegistry

在這個類中,aliasMap 是 ConcurrentHashMap 型別的:

在 registerAlias 和 getAliases 方法中,都有對 aliasMap 進行操作的程式碼,但是在操作之前都是用 synchronized 把 aliasMap 鎖住了。

為什麼我們操作 ConcurrentHashMap 的時候還要加鎖呢?

這個是根據場景而定的,這個別名管理器,在這裡加鎖應該是為了避免多個執行緒操作 ConcurrentHashMap 。

雖然 ConcurrentHashMap 是執行緒安全的,但是假設如果一個執行緒 put,一個執行緒 get,在這個程式碼的場景裡面是不允許的。

具體情況,需要具體分析。

如果覺得不太好理解的話我舉一個 Redis 的例子。

Redis 的 get、set 方法都是執行緒安全的吧。但是你如果先 get 再 set,那麼在多執行緒的情況下還是會有問題的。

因為這兩個操作不是原子性的。所以 incr 就應運而生了。

我舉這個例子的是想說執行緒安全與否不是絕對的,要看場景。給你一個執行緒安全的容器,你使用不當還是會有執行緒安全的問題。

再比如,HashMap 一定是執行緒不安全的嗎?

說不能說的這麼死吧。它是一個執行緒不安全的容器。但是如果我的使用場景是隻讀呢?

在這個只讀的場景下,它就是執行緒安全的。

總之,看場景,不要脫離場景討論問題。

道理,就是這麼一個道理。

最後,再說一次結論:LinkedBlockingQueue 的 stream 遍歷的方式,在多執行緒下是有一定問題的,可能會出現死迴圈。

相關文章