我試圖通過這篇文章告訴你,這行原始碼有多牛逼。

2023-05-15 15:00:18

你好呀,我是歪歪。

這次給你盤一個特別有意思的原始碼,正如我標題說的那樣:看懂這行原始碼之後,我不禁鼓起掌來,直呼祖師爺牛逼。

這行原始碼是這樣的:

java.util.concurrent.LinkedBlockingQueue#dequeue

h.next = h,不過是一個把下一個節點指向自己的動作而已。

這行程式碼後面的註釋「help GC」其實在 JDK 的原始碼裡面也隨處可見。

不管怎麼看都是一行平平無奇的程式碼和隨處可見的註釋而已。

但是這行程式碼背後隱藏的故事,可就太有意思了,真的牛逼,兒豁嘛。

它在幹啥。

首先,我們得先知道這行程式碼所在的方法是在幹啥,然後再去分析這行程式碼的作用。

所以老規矩,先搞個 Demo 出來跑跑:

在 LinkedBlockingQueue 的 remove 方法中就呼叫了 dequeue 方法,呼叫鏈路是這樣的:

這個方法在 remove 的過程中承擔一個什麼樣的角色呢?

這個問題的答案可以在方法的註釋上找到:

這個方法就是從佇列的頭部,刪除一個節點,其他啥也不幹。

就拿 Demo 來說,在執行這個方法之前,我們先看一下當前這個連結串列的情況是怎麼樣的:

這是一個單向連結串列,然後 head 結點裡面沒有元素,即 item=null,對應做個圖出來就是這樣的:

當執行完這個方法之後,連結串列變成了這樣:

再對應做個圖出來,就是這樣的:

可以發現 1 沒了,因為它是真正的「頭節點」,所以被 remove 掉了。

這個方法就幹了這麼一個事兒。

雖然它一共也只有六行程式碼,但是為了讓你更好的入戲,我決定先給你逐行講解一下這個方法的程式碼,講著講著,你就會發現,誒,問題它就來了。

首先,我們回到方法入口處,也就是回到這個時候:

前兩行方法是這樣的:

對應到圖上,也就是這樣的:

  • h 對應的是 head 節點
  • first 對應的是 「1」 節點

然後,來到第三行:

h 的 next 還是 h,這就是一個自己指向自己的動作,對應到圖上是這樣的:

然後,第四行程式碼:

把 first 變成 head:

最後,第五行和第六行:

拿到 first 的 item 值,作為方法的返回值。然後再把 first 的 item 值設定為 null。

對應到圖中就是這樣,第五行的 x 就是 1,第六行執行完成之後,圖就變成了這樣:

整個連結串列就變成了這樣:

那麼現在問題來了:

如果我們沒有 h.next=h 這一行程式碼,會出現什麼問題呢?

我也不知道,但是我們可以推演一下:

也就是最終我們得到的是這樣的一個連結串列:

這個時候我們發現,由於 head 指標的位置已經發生了變化,而且這個連結串列又是一個單向連結串列,所以當我們使用這個連結串列的時候,沒有任何問題。

而這個物件:

已經沒有任何指標指向它了,那麼它不經過任何處理,也是可以被 GC 回收掉的。

對嗎?

你細細的品一品,是不是這個道理,從 GC 的角度來說它確實是「不可達了」,確實可以被回收掉了。

所以,當時有人問了我這樣的一個問題:

我經過上面的一頓分析,發現:嗯,確實是這樣的,確實沒啥卵用啊,不寫這一行程式碼,功能也是完成正常的。

但是當時我是這樣回覆的:

我沒有把話說滿,因為這一行故意寫了一行「help GC」的註釋,可能有 GC 方面的考慮。

那麼到底有沒有 GC 方面的考慮,是怎麼考慮的呢?

憑藉著我這幾年寫文章的敏銳嗅覺,我覺得這裡「大有文章」,於是我帶著這個問題,在網上溜達了一圈,還真有收穫。

help GC?

首先,一頓搜尋,排除了無數個無關的線索之後,我在 openjdk 的 bug 列表裡面定位到了這樣的一個連結:

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

點選進這個連結的原因是標題當時就把吸引到了,翻譯過來就是說:LinkedBlockingQueue 的節點應該在成為「垃圾」之前解除自己的連結。

先不管啥意思吧,反正 LinkedBlockingQueue、Nodes、unlink、garbage 這些關鍵詞是完全對上了。

於是我看了一下描述部分,主要關心到了這兩個部分:

看到標號為 ① 的地方,我才發現在 JDK 6 裡面對應實現是這樣的:

而且當時的方法還是叫 extract 而不是 dequeue。

這個方法名稱的變化,也算是一處小細節吧。

dequeue 是一個更加專業的叫法:

仔細看 JDK 6 中的 extract 方法,你會發現,根本就沒有 help GC 這樣的註釋,也沒有相關的程式碼。

它的實現方式就是我前面畫圖的這種:

也就是說這行程式碼一定是出於某種原因,在後面的 JDK 版本中加上的。那麼為什麼要進行標號為 ① 處那樣的修改呢?

標號為 ② 的地方給到了一個連結,說是這個連結裡面有關於這個問題深入的討論。

For details and in-depth discussion, see:
http://thread.gmane.org/gmane.comp.java.jsr.166-concurrency/5758

我非常確信我找對了地方,而且我要尋找的答案就在這個連結裡面。

但是當我點過去的時候,我發現不管怎麼存取,這個連結存取不到了...

雖然這裡的線索斷了,但是順藤摸瓜,我找到了這個 BUG 連結:

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

這兩個 BUG 連結說的其實是同一個事情,但是這個連結裡面給了一個範例程式碼。

這個程式碼比較長,我給你截個圖,你先不用細看,只是對比我框起來的兩個部分,你會發現這兩部分的程式碼其實是一樣的:

當 LinkedBlockingQueue 裡面加入了 h.next=null 的程式碼,跑上面的程式,輸出結果是這樣:

但是,當 LinkedBlockingQueue 使用 JDK 6 的原始碼跑,也就是沒有 h.next=null 的程式碼跑上面的程式,輸出結果是這樣:

產生了 47 次 FGC。

這個程式碼,在我的電腦上跑,我用的是 JDK 8 的原始碼,然後註釋掉 h.next = h 這行程式碼,只是會觸發一次 FGC,時間差距是 2 倍:

加上 h.next = h,兩次時間就相對穩定:

好,到這裡,不管原理是什麼,我們至少驗證了,在這個地方必須要 help GC 一下,不然確實會有效能影響。

但是,到底是為什麼呢?

在反覆仔細的閱讀了這個 BUG 的描述部分之後,我大概懂了。

最關鍵的一個點其實是藏在了前面範例程式碼中我標註了五角星的那一行註釋:

SAME test, but create the queue before GC, head node will be in old gen(頭節點會進入老年代)

我大概知道問題的原因是因為「head node will be in old gen」,但是具體讓我描述出來我也有點說不出來。

說人話就是:我懂一點,但是不多。

於是又經過一番查詢,我找到了這個連結,在這裡面徹底搞明白是怎麼一回事了:

http://concurrencyfreaks.blogspot.com/2016/10/self-linking-and-latency-life-of.html

在這個連結裡面提到了一個視訊,它讓我從第 23 分鐘開始看:

我看了一下這個視訊,應該是 2015 年釋出的。因為整個會議的主題是:20 years of Java, just the beginning:

https://www.infoq.com/presentations/twitter-services/

這個視訊的主題是叫做「Life if a twitter JVM engineer」,是一個 twitter 的 JVM 工程師在大會分享的在工作遇到的一些關於 JVM 的問題。

雖然是全程英文,但是你知道的,我的 English level 還是比較 high 的。

日常聽說,問題不大。所以大概也就聽了個幾十遍吧,結合著他的 PPT 也就知道關於這個部分他到底在分享啥了。

我要尋找的答案,也藏在這個視訊裡面。

我挑關鍵的給你說。

首先他展示了這樣的這個圖片:

老年代的 x 物件指向了年輕代的 y 物件。一個非常簡單的示意圖,他主要是想要表達「跨代參照」這個問題。

然後,出現了這個圖片:

這裡的 Queue 就是本文中討論的 LinkedBlockingQueue。

首先可以看到整個 Queue 在老年代,作為一個佇列物件,極有可能生命週期比較長,所以佇列在老年代是一個正常的現象。

然後我們往這個佇列裡面插入了 A,B 兩個元素,由於這兩個元素是我們剛剛插入的,所以它們在年輕代,也沒有任何毛病。

此時就出現了老年代的 Queue 物件,指向了位於年輕代的 A,B 節點,這樣的跨代參照。

接著,A 節點被幹掉了,出隊:

A 出隊的時候,由於它是在年輕代的,且沒有任何老年代的物件指向它,所以它是可以被 GC 回收掉的。

同理,我們插入 D,E 節點,並讓 B 節點出隊:

假設此時發生一次 YGC, A,B 節點由於「不可達」被幹掉了,C 節點在經歷幾次 YGC 之後,由於不是「垃圾」,所以晉升到了老年代:

這個時候假設 C 出隊,你說會出現什麼情況?

首先,我問你:這個時候 C 出隊之後,它是否是垃圾?

肯定是的,因為它不可達了嘛。從圖片上也可以看到,C 雖然在老年代,但是沒有任何物件指向它了,它確實完犢子了:

好,接下來,請坐好,認真聽了。

此時,我們加入一個 F 節點,沒有任何毛病:

接著 D 元素被出隊了:

就像下面這個動圖一樣:

我把這一幀拿出來,針對這個 D 節點,單獨的說:

假設在這個時候,再次發生 YGC,D 節點雖然出隊了,它也位於年輕代。但是位於老年代的 C 節點還指向它,所以在 YGC 的時候,垃圾回收執行緒不敢動它。

因此,在幾輪 YGC 之後,本來是「垃圾」的 D,搖身一變,進入老年代了:

雖然它依然是「垃圾」,但是它進入了老年代,YGC 對它束手無策,得 FGC 才能幹掉它了。

然後越來越多的出隊節點,變成了這樣:

然後,他們都進入了老年代:

我們站在上帝視角,我們知道,這一串節點,應該在 YGC 的時候就被回收掉。

但是這種情況,你讓 GC 怎麼處理?

它根本就處理不了。

GC 執行緒沒有上帝視角,站在它的視角,它做的每一步動作都是正確的、符合規定的。最終呈現的效果就是必須要經歷 FGC 才能把這些本來早就應該回收的節點,進行回收。而我們知道,FGC 是應該儘量避免的,所以這個處置方案,還是「差點意思」的。

所以,我們應該怎麼辦?

你回想一下,萬惡之源,是不是這個時候:

C 雖然被移出佇列了,但是它還持有一個下一個節點的參照,讓這個參照變成跨代參照的時候,就出毛病了。

所以,help GC,這不就來了嗎?

不管你是位於年輕代還是老年代,只要是出隊,就把你的 next 參照幹掉,杜絕出現前面我們分析的這種情況。

這個時候,你再回過頭去看前面提到的這句話:

head node will be in old gen...

你就應該懂得起,為什麼 head node 在 old gen 就要出事兒。

h.next=null ???

前面一節,經過一頓分析之後,知道了為什麼要有這一行程式碼:

但是你仔細一看,在我們的原始碼裡面是 h.hext=h 呀?

而且,經過前面的分析我們可以知道,理論上,h.next=null 和 h.hext=h 都能達到 help GC 的目的,那麼為什麼最終的寫法是 h.hext=h 呢?

或者換句話說:為什麼是 h.next=h,而不是 h.next=null 呢?

針對這個問題,我也盯著原始碼,仔細思考了很久,最終得出了一個「非常大膽」的結論是:這兩個寫法是一樣的,不過是編碼習慣不一樣而已。

但是,注意,我要說但是了。

再次經過一番查詢、分析和論證,這個地方它還必須得是 h.next=h。

因為在這個 bug 下面有這樣的一句討論:

關鍵詞是:weakly consistent iterator,弱一致性迭代器。也就是說這個問題的答案是藏在 iterator 迭代器裡面的。

在 iterator 對應的原始碼中,有這樣的一個方法:

java.util.concurrent.LinkedBlockingQueue.Itr#nextNode

針對 if 判斷中的 s==p,我們把 s 替換一下,就變成了 p.next=p:

那麼什麼時候會出現 p.next=p 這樣的程式碼呢?

答案就藏在這個方法的註釋部分:dequeued nodes (p.next == p)

dequeue 這不是巧了嗎,這不是和前面給呼應起來了嗎?

好,到這裡,我要開始給你畫圖說明了,假設我們 LinkedBlockingQueue 裡面放的元素是這樣的:

畫圖出來就是這樣的:

現在我們要對這個連結串列進行迭代,對應到畫圖就是這樣的:

linkedBlockingQueue.iterator();

看到這個圖的時候,問題就來了:current 指標是什麼時候冒出來的呢?

current,這個變數是在生成迭代器的時候就初始化好了的,指向的是 head.next:

然後 current 是通過 nextNode 這個方法進行維護的:

正常迭代下,每呼叫一次都會返回 s,而 s 又是 p.next,即下一個節點:

所以,每次呼叫之後 current 都會移動一格:

這種情況,完全就沒有這個分支的事兒:

什麼時候才會和它扯上關係呢?

你想象一個場景。

A 執行緒剛剛要對這個佇列進行迭代,而 B 執行緒同時在對這個佇列進行 remove。

對於 A 執行緒,剛剛開始迭代,畫圖是這樣的:

然後 current 還沒開始移動呢,B 執行緒「咔咔」幾下,直接就把 1,2,3 全部給幹出佇列了,於是站在 B 執行緒的視角,佇列是這樣的了:

到這裡,你先思考一個問題:1,2,3 這幾個節點,不管是自己指向自己,還是指向一個 null,此時發生一個 YGC 它們還在不在?

2 和 3 指定是沒了,但是 1 可不能被回收了啊。

因為雖然元素為 1 的節點出隊了,但是站在 A 執行緒的視角,它還持有一個 current 參照呢,它還是「可達」的。

所以,這個時候 A 執行緒開始迭代,雖然 1 被 B 出隊了,但是它一樣會被輸出。

然後,我們再來對於下面這兩種情況,A 執行緒會如何進行迭代:

當 1 節點的 next 指為 null 的時候,即 p.next 為 null,那麼滿足 s==null 的判斷,所以 nextNode 方法就會返回 s,也就是返回了 null:

當你呼叫 hasNext 方法判斷是否還有下一節點的時候,就會返回 false,迴圈就結束了:

然後,我們站在上帝視角是知道的,後面還有 4 和 5 沒輸出呢,所以這樣就會出現問題。

但是,當 1 節點的 next 指向自己的時候,有趣的事情就來了:

current 指標就變成了 head.next。

而你看看當前的這個連結串列裡面 head.next 是啥?

不就是 4 節點嗎?

這不就銜接上了嗎?

所以最終 A 執行緒會輸出 1,4,5。

雖然我們知道 1 元素其實已經出隊了,但是 A 執行緒開始迭代的時候,它至少還在。

這玩意就體現了前面提到的: weakly consistent iterator,弱一致性迭代器。

這個時候,你再結合者迭代器上的註解去看,就能搞得明明白白了:

如果 hasNext 方法返回為 true,那麼就必須要有下一個節點。即使這個節點被比如 take 等等的方法給移除了,也需要返回它。這就是 weakly-consistent iterator。

然後,你再看看整個類開始部分的 Java doc,其實我整篇文章就是對於這一段描述的翻譯和擴充:

看完並理解我這篇文章之後,你再去看這部分的 Java doc,你就知道它是在說個啥事情,以及它為什麼要這樣的去做這件事情了。

好了,看到這裡,你現在應該明白了,為什麼必須要有 h.next=h,為什麼不能是 h.next=null 了吧?

明白了就好。

因為本文就到這裡就要結束了。

如果你還沒明白,不要懷疑自己,大膽的說出來:什麼玩意?寫的彎彎繞繞的,看求不懂。呸,垃圾作者。

最後,我還想要說的是,關於 LBQ 這個佇列,我之前也寫過這篇文章專門說它:《喜提JDK的BUG一枚!多執行緒的情況下請謹慎使用這個類的stream遍歷。》

文章裡面也提到了 dequeue 這個方法:

但是當時我完全沒有思考到文字提到的問題,順著程式碼就捋過去了。

我覺得看到這部分程式碼,然後能提出本文中這兩個問題的人,才是在帶著自己思考深度閱讀原始碼的人。

解決問題不厲害,提出問題才是最屌的,因為當一個問題提出來的時候,它就已經被解決了。

帶著質疑的眼光看程式碼,帶著求真的態度去探索,與君共勉之。