<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          喜提 JDK 的 BUG 一枚!

          共 9370字,需瀏覽 19分鐘

           ·

          2022-07-07 16:20

          前段時間在 RocketMQ 的 ISSUE 里面沖浪的時候,看到一個 pr,雖說是在 RocketMQ 的地盤上發(fā)現(xiàn)的,但是這個玩意吧,其實和 RocketMQ 沒有任何關(guān)系。

          純純的就是 JDK 的一個 BUG。

          我先問你一個問題:LinkedBlockingQueue 這個玩意是線程安全的嗎?

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

          答案是:是線程安全的,因為有這兩把鎖的存在。

          但是在 RocketMQ 的某個場景下,居然穩(wěn)定復(fù)現(xiàn)了 LinkedBlockingQueue 線程不安全的情況。

          先說結(jié)論:LinkedBlockingQueue 的 stream 遍歷的方式,在多線程下是有一定問題的,可能會出現(xiàn)死循環(huán)。

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

          搞個Demo

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

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

          在這個鏈接里面,前面圍繞著 RocketMQ 討論了很多。

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

          直接給出了一個非常簡單的復(fù)現(xiàn)代碼。而且完全把 RocketMQ 的東西剝離了出去:

          正所謂前人栽樹后人乘涼,既然讓我看到了 areyouok 這位大佬的代碼,那我也就直接拿來當(dāng)做演示的 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 個線程,每個線程里面在不停的調(diào)用 offer 和 remove 方法。

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

          再強調(diào)一次:LinkedBlockingQueue 里面有 ReentrantLock 鎖,所以即使多個線程并發(fā)操作 offer 或者 remove 方法,也都要分別拿到鎖才能操作,所以這一定是線程安全的。

          然后主線程里面搞個死循環(huán),對 queue 進行 stream 操作,看看能不能找到隊列里面第一個不為空的元素。

          這個 stream 操作是一個障眼法,真正的關(guān)鍵點在于 tryAdvance 方法:

          先在這個方法這里插個眼,一會再細(xì)嗦它。

          按理來說,這個方法運行起來之后,應(yīng)該不停的輸出這兩句話才對:

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

          但是,你把代碼粘出去用 JDK 8 跑一把,你會發(fā)現(xiàn)控制臺只有這個玩意:

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

          但是當(dāng)我們不動代碼,只是替換一下 JDK 版本,比如我剛好有個 JDK 15,替換之后再次運行,交替的效果就出來了:

          那么基于上面的表現(xiàn),我是不是可以大膽的猜測,這是 JDK 8 版本的 BUG 呢?

          現(xiàn)在我們有了能在 JDK 8 運行環(huán)境下穩(wěn)定復(fù)現(xiàn)的 Demo,接下來就是定位 BUG 的原因了。

          啥原因呀?

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

          非常的簡單,你想一想,主線程應(yīng)該一直輸出但是卻沒有輸出,那么它到底是在干什么呢?

          我初步懷疑是在等待鎖。

          怎么去驗證呢?

          朋友們,可愛的小相機又出現(xiàn)了:

          通過它我可以 Dump 當(dāng)前狀態(tài)下各個線程都在干嘛。

          但是當(dāng)我看到主線程的狀態(tài)是 RUNNABLE 的時候,我就有點懵逼了:

          啥情況啊?

          如果是在等待鎖,不應(yīng)該是 RUNNABLE 啊?

          再來 Dump 一次,驗證一下:

          發(fā)現(xiàn)還是在 RUNNABLE,那么直接就可以排除鎖等待的這個懷疑了。

          我專門體現(xiàn)出兩次 Dump 線程的這個操作,是有原因的。

          因為很多朋友在 Dump 線程的時候拿著一個 Dump 文件在哪兒使勁分析,但是我覺得正確的操作應(yīng)該是在不同時間點多次 Dump,對比分析不同 Dump 文件里面的相同線程分別是在干啥。

          比如我兩次不同時間點 Dump,發(fā)現(xiàn)主線程都是 RUNNABLE 狀態(tài),那么說明從程序的角度來說,主線程并沒有阻塞。

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

          經(jīng)典啊,朋友們。你想想這是什么經(jīng)典的畫面啊?

          這不就是,這個玩意嗎,線程里面有個死循環(huán):

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

          來驗證一波。

          從 Dump 文件中我們可以觀察到的是主線程正在執(zhí)行這個方法:

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

          還記得我前面插的眼嗎?

          這里就是我前面說的 stream 只是障眼法,真正關(guān)鍵的點在于 tryAdvance 方法。

          點過去看一眼 JDK 8 的 tryAdvance 方法,果不其然,里面有一個 while 循環(huán):

          從 while 條件上看是 current 節(jié)點一直不是 null,所以跳不出這個循環(huán)。

          但是從 while 循環(huán)體里面的邏輯來看,里面的 current 節(jié)點是會發(fā)生變化的:

          current = current.next;

          來,結(jié)合這目前有的這幾個條件,我來細(xì)嗦一下。

          • LinkedBlockingQueue 的數(shù)據(jù)結(jié)果是鏈表。
          • 在 tryAdvance 方法里面出現(xiàn)了死循環(huán),說明循環(huán)條件 current!=null 一直是 true,而e!=null一直是假。
          • 但是循環(huán)體里面有獲取下一節(jié)點的動作,current = current.next。

          綜上可得,當(dāng)前這個鏈表中有一個節(jié)點是這樣的:

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

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

          那么什么時候才會出現(xiàn)這樣的節(jié)點呢?

          這個情況就是把節(jié)點從鏈表上拿掉,所以肯定是調(diào)用移除節(jié)點相關(guān)的方法的時候。

          縱觀我們的 Demo 代碼,里面和移除相關(guān)的代碼就這一行:

          queue.remove();

          而前面說了,這個 remove 方法是移除頭節(jié)點,效果和 poll 是一樣一樣的,它的源碼里面也是直接調(diào)用了 poll 方法:

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

          java.util.concurrent.LinkedBlockingQueue#poll()

          兩個標(biāo)號為 ① 的地方分別是拿鎖和釋放鎖,說明這個方法是線程安全的。

          然后重點是標(biāo)號為 ② 的地方,這個 dequeue 方法,這個方法就是移除頭節(jié)點的方法:

          java.util.concurrent.LinkedBlockingQueue#dequeue

          它是怎么移除頭節(jié)點的呢?

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

          h.next=h

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

          那么 dequeue 方法的這個地方和 tryAdvance 方法里面的 while 循環(huán)會發(fā)生一個什么樣神奇的事情呢?

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

          畫面演示

          現(xiàn)在我已經(jīng)掌握到這個 BUG 的原理了,所以為了方便我 Debug,我把實例代碼也簡化一下,核心邏輯不變,還是就這么幾行代碼,主要還是得觸發(fā) tryAdvance 方法:

          首先根據(jù)代碼,當(dāng) queue 隊列添加完元素之后,隊列是長這樣的:

          畫個示意圖是這樣的:

          然后,我們接著往下執(zhí)行遍歷的操作,也就是觸發(fā) tryAdvance 方法:

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

          就是如果往上再看一步,觸發(fā) tryAdvance 方法的地方叫做 forEachWithCancel ,從源碼上看其實也是一個循環(huán),循環(huán)結(jié)束條件是 tryAdvance 方法返回為 false ,意思是遍歷結(jié)束了。

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

          說人話就是,當(dāng)某個線程在執(zhí)行 tryAdvance 方法,且加鎖成功之后,如果其他線程需要操作隊列,那么是獲取不到鎖的,必須等這個線程操作完成并釋放鎖。

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

          而每次 tryAdvance 方法,只處理鏈表中的一個節(jié)點。

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

          第一次觸發(fā)的時候,current 對象是 null,所以會執(zhí)行一個初始化的東西:

          current = q.head.next;

          那么這個時候 current 就是 節(jié)點 1:

          接著執(zhí)行 while 循環(huán),這時 current!=null 條件滿足,進入循環(huán)體。

          在循環(huán)體里面,會執(zhí)行兩行代碼。

          第一行是這個,取出當(dāng)前節(jié)點里面的值:

          e = current.item;

          在我的 Demo 里面,e=1。

          第二行是這行代碼,含義是維護 current 為下一節(jié)點,等著下次 tryAdvance 方法觸發(fā)的時候直接拿來用:

          current = current.next;

          接著因為 e!=null,所以 break 結(jié)束循環(huán):

          第一次 tryAdvance 方法執(zhí)行完成之后,current 指向的是這個位置的節(jié)點:

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

          假設(shè)第二次 tryAdvance 方法觸發(fā)的時候,執(zhí)行到下面框起來的部分的任意一行代碼,也就是還沒有獲取鎖或者獲取不到鎖的時候:

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

          執(zhí)行三次 remove() 方法之后,鏈表就變成了這樣:

          接下來,當(dāng)我把這兩個圖合并在一起的時候,就是見證奇跡的時候:

          當(dāng)?shù)谌螆?zhí)行 remover 方法后,tryAdvance 方法再次成功搶到鎖,開始執(zhí)行,從我們的上帝視角,看到的是這樣的場景:

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

          可以看到,current 的 next 節(jié)點還是它自己,而且它們都是 LinkedBlockingQueue$Mode@701 這個對象,并不為 null。

          所以這個地方的死循環(huán)就是這么來的。

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

          你要相信,只要給到你能穩(wěn)定復(fù)現(xiàn)的代碼,一切 BUG 都是能夠調(diào)試出來的。

          我在調(diào)試的過程中,還想到了另外一個問題:如果我調(diào)用的是這個 remove 方法呢,移除指定元素。

          會不會出現(xiàn)一樣的問題呢?

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

          還是在 tryAdvance 方法里面打上斷點,然后在第二次觸發(fā) tryAdvance 方法之后,通過 Alt+F8 調(diào)出 Evaluate 功能,分別執(zhí)行 queue.remove 1,2,3:

          然后觀察 current 元素,并沒有出現(xiàn)自己指向自己的情況:

          為什么呢?

          源碼之下無秘密。

          答案就寫在 unlink 方法里面:

          入?yún)⒅械?p 是要移除的節(jié)點,而 trail 是要移除的節(jié)點的上一個節(jié)點。

          在源碼里面只看到了 trail.next=p.next,也就是通過指針,跳過要移除的節(jié)點。

          但是并沒有看到前面 dequeue 方法中出現(xiàn)的類似于 p.next=p 的源碼,也就是把節(jié)點的下一個節(jié)點指向自己的動作。

          為什么?

          作者都在注釋里面給你寫清楚了:

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

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

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

          怎么修復(fù)的?

          我在 JDK 的 BUG 庫里面搜了一下,其實這個問題 2016 年就出現(xiàn)在了 JDK 的 BUG 列表里面:

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

          在 JDK9 的版本里面完成了修復(fù)。

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

          主要的變化是在 try 的代碼塊里面。

          JDK15 的源碼里面調(diào)用了一個 succ 方法,從方法上的注釋也可以看出來就是專門修復(fù)這個 BUG 的:

          比如回到這個場景下:

          我們來細(xì)嗦一下當(dāng)前這個情況下, succ 方法是怎么處理的:

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

          p 是上圖中的 current 對應(yīng)的元素。

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

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

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

          最后取到了元素 4,也就是最后一個元素,接著結(jié)束循環(huán):

          沒有死循環(huán),完美。

          延伸一下

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

          下次你面試的時候遇到這個問題,你就微微一笑,答到:由于內(nèi)部有讀寫鎖的存在,這個玩意一般情況下是線程安全的。但是,在 JDK8 的場景下,當(dāng)它遇到 stream 操作的時候,又有其他線程在調(diào)用無參的 remove 方法,會有一定幾率出現(xiàn)死循環(huán)的情況。

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

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

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

          第一個你就別用 stream 了唄,老老實實的使用迭代器循環(huán),它不香嗎?

          第二個方案是這樣的:

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

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

          來,你有本事再給我表演一個線程不安全。

          現(xiàn)在,我換一個問題問你:ConcurrentHashMap 是線程安全的嗎?

          我之前寫過,這玩意在 JDK8 下也是有死循環(huán)的《震驚!ConcurrentHashMap里面也有死循環(huán),作者留下的“彩蛋”了解一下?》

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

          當(dāng)時的回答再次搬運一下:

          是的,ConcurrentHashMap 本身一定是線程安全的。但是,如果你使用不當(dāng)還是有可能會出現(xiàn)線程不安全的情況。

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

          org.springframework.core.SimpleAliasRegistry

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

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

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

          這個是根據(jù)場景而定的,這個別名管理器,在這里加鎖應(yīng)該是為了避免多個線程操作 ConcurrentHashMap 。

          雖然 ConcurrentHashMap 是線程安全的,但是假設(shè)如果一個線程 put,一個線程 get,在這個代碼的場景里面是不允許的。

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

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

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

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

          我舉這個例子的是想說線程安全與否不是絕對的,要看場景。給你一個線程安全的容器,你使用不當(dāng)還是會有線程安全的問題。

          再比如,HashMap 一定是線程不安全的嗎?

          說不能說的這么死吧。它是一個線程不安全的容器。但是如果我的使用場景是只讀呢?

          在這個只讀的場景下,它就是線程安全的。

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

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

          最后,再說一次結(jié)論:LinkedBlockingQueue 的 stream 遍歷的方式,在多線程下是有一定問題的,可能會出現(xiàn)死循環(huán)。

          好了,那本文的技術(shù)部分就到這里啦。

          下面這個環(huán)節(jié)叫做[荒腔走板],技術(shù)文章后面我偶爾會記錄、分享點生活相關(guān)的事情,和技術(shù)毫無關(guān)系。我知道看起來很突兀,但是我喜歡,因為這是一個普通博主的生活氣息。

          你要不喜歡,退出之前記得文末點個“在看”哦。

          瀏覽 19
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  99乱伦 | 一卡二卡久久 | 久草视频在线播放 | 成人91久久 | 91成人在线免费 |