你們好,我是 why,歡迎來到我連續周更優質原創文章的第 57 篇。java
老規矩,先來一個簡短的荒腔走板,給冰冷的技術文注入一絲色彩。node
上面這個圖是個人第一臺筆記本電腦,從上面的標籤能夠看到,是購於 2012 年 6 月 10 日,那一天是高考完的次日。
程序員
我讀高三的時候媽媽承諾我,考完以後就能夠擁有一臺本身的筆記本電腦。那個時候媽媽和我都不太懂電腦,我記得很清楚,是姑父開車帶我去買的,花了 3000 多。web
一晃 8 年多的時間過去了,這臺電腦也跟着我從老家到成都再到北京再回成都。
算法
其實我大三實習的時候就想本身換個 Mac,而後一想可能要存錢去北京,因而想着再等等吧。
bootstrap
到北京後,發現每月剩下的錢很少很多,這電腦也能用,那能省則省了吧,再等等吧。安全
這一等,就是 5 年。說來慚愧,我到如今都還沒擁有一臺本身的 Mac。微信
上週的監控圖就是用這臺電腦跑的,我不是說預計還須要跑 19 天才可能會看到 OOM 嗎。
數據結構
其實我挺擔憂跑着跑着就藍屏了,可是發完文章後不到一小時,小區就停電了。多線程
因而,這場停電,終止了這個監控,也拯救了這個電腦。從某種角度上來講,這場停電也拯救了個人錢包。
好了,說迴文章。
上週《個人程序跑了60多小時,就是爲了讓你看一眼JDK的BUG致使的內存泄漏》這篇文章發佈後。
有好幾個同窗都來問了我一些相關的問題。
好比這樣的:
寫上篇文章的時候,個人側重點主要在 ConcurrentLinkedQueue(下文統一縮寫 CLQ)存在過一個可能會致使內存泄漏的 BUG ,這個 BUG 的前因後果是怎樣的,以及怎麼經過可視化工具讓咱們感覺到這個 BUG 的存在。
其實對於 BUG 在源碼裏面具體是怎樣體現的,以及修改以後爲何就不會內存泄漏了並無進行詳細的解讀。
開始的想法是,告訴你們有這個事情,若是有興趣的能夠直接去調試分析一下。
可是有的同窗反映調試也看不明白啊。一個方法,在斷點處一臉懵逼的進來,又一臉懵逼的出去。
苦思冥想沒搞清楚,而後就來問我。
我發現一兩句話也說不太清楚,因而把 Debug 的關鍵截圖放到文檔裏面配以文字說明,才勉強能說的比較清楚一點,也不知道這位同窗看明白了沒。
但就拿這個文檔來講:真的是暖男石錘了。
因此,文本主要是分享一個我本身調試的奇淫技巧,最後再作個 remove 方法的解讀。
可是若是要深入理解 CLQ 這個十分優秀、十分有想法的基於非阻塞方法實現的線程安全的隊列,你們須要去看的是 offer、poll 方法。而後一個狀況一個狀況的去分析,本身拿着草稿本在上面寫寫畫畫。
我也妄想過經過這篇文章給大家把它講的明明白白的,後來我發現這對我而言難度有點大。
最後再說一下若是你用 IDEA 調試時,大機率會碰到的一個巨坑。
好了,先把以前的這個坑給填上。
修復以後的 JDK8 到底怎麼就避免了內存泄漏的問題了?
咱們先看一下 CLQ 的數據結構。
CLQ 的 Node 裏面有一個 item(放的是存儲的對象),還有一個 next 節點(指向的是當前 Node 的下一個節點)。
從數據結構來看,也知道這是一個單向鏈表了。Java 程序員,就靠日誌活着的。
因此我想經過日誌的方式直接輸出鏈表結構,這應該是最簡單的演示方式了。
爲了經過日誌體現出數據變化的過程,咱們先來一個自定義的 CLQ。
方法很簡單,直接把 JDK 8 的 CLQ 複製出來一份,而後修更名稱就能夠,咱們這裏的名稱是 whyConcurrentLinkedQueue(下文簡寫爲 WhyCLQ):
搞一個測試用例跑一下:
而後你會發現報錯了:
這個錯誤是關於 Unsafe 操做的,在代碼的第 931 行:
getUnsafe 方法的源碼是這樣的:
而這個方法裏面就是判斷當前類的類加載器是否是爲 null:
這裏拋出異常了,說明不是 null。也就會說當前類的加載器不是啓動類加載器 BootstrapClassLoader。
咱們知道,rt.jar 包下的類是須要 bootstrap 類加載器加載的。
誒,巧了。這個類就位於 rt.jar 包裏面:
來,再複習一下雙親委派機制:
若是咱們自定義了一個 CLQ ,那麼這個類的類加載器是什麼類加載器呢?
咱們驗證一下:從 Debug 的截圖能夠看出當前類 WhyCLQ 的類加載器是 AppClassLoader。其父類加載器(parent)是 ExtClassLoader 類加載器。
不是 BootstrapClassLoader ,因此咱們這裏拋出了異常。
在介紹怎麼解決這個異常以前,先簡單的說一下 Unsafe。
這個類名稱一聽就是很是牛逼的。Unsafe,不安全。
感受像是在釣魚執法,表面上瘋狂的在那給你擺手,說:別靠近我,別使用我,我很不安全。
實際上心裏是這樣的:
做爲一個正常的男人,看到這個東西誰不想去調用一下,看看究竟是怎麼不安全的呢?
咱們看一下《美團點評 2019 技術年貨》裏面是怎麼描述的:
同時,看一下它相關的 API:
因爲 Unsafe 不是文本重點,我就不展開說明了。若是你對 Unsafe 這個類掌握的還不深入,建議你好好了解一下。若是你清楚的知道這個類的威力,在某些場景下能夠達到意想不到的效果,它就是一枚銀彈般的存在。
在《美團點評 2019 技術年貨》裏面有一小節是專門分享這個類的,有興趣的朋友能夠查看文末獲取方式。
好了,知道拋出問題的緣由了,咱們自定義的 CLQ 就不能用了嗎?
固然不是,別忘了,咱們還有極其「流氓」的反射方法可使用:
這樣,咱們自定義的 CLQ 就可使用了。免費附贈你一個 Unsafe 的知識,不用謝。
接下來咱們就能夠在不修改源碼邏輯的狀況下,加入輸出語句以方便調試。
好比咱們須要這樣清晰的輸出日誌:
因此在咱們自定義的 CLQ 裏面加一個打印鏈表結構的方法:
而後給咱們的 remove 方法增長一個循環次數的入參,並在操做隊列以前和以後調用咱們打印鏈表結構的方法,就像下面這樣式兒的:
其中的 printWhyCLQ 方法以下:
有的朋友確定注意到了,我這個方法名稱是 removeJDK8 。這個方法裏面的邏輯就是 JDK8 的 CLQ 的 remove 方法。
你能夠這麼理解:我就是把 JDK8 的 CLQ 的 remove 方法的名稱變成了 removeJDK8 。
爲何這樣命名呢?
由於我要把 JDK7 對應的 remove 方法直接拿過來,放在同一個類裏面方便調用,操做和上面的 JDK8 方法一致:
這樣,咱們就有一個自定義的 CLQ,裏面包含 JDK7 和 JDK8 對應的 CLQ 的 remove 方法。
萬事俱備,就差個 Demo 跑起來了:
在下面一小節中,咱們對比一下修復前(JDK7)和修復後(JDK8)的輸出日誌,一切就會很是的明瞭。
咱們把 Demo 跑起來,看輸出結果,進行對比:
你仔細品這個輸出結果,還須要我給你分析個啥玩意?
和 JDK8 的方法比起來,上面 JDK7 的方法執行完成後鏈表長度都長了一些。
JDK8 的方法執行完成後,鏈表長度最長也沒有超過 3 個。
咱們再看 JDK7,我拿一次循環出來分析:
這就是我上篇文章中說到的:一個節點中的 item 對象被置爲 null 了,可是該節點,因爲代碼問題,並無從鏈表中取下來,致使不能被回收。
而上篇文章中提到的「愈來愈慢」,因爲能夠直接的看到鏈表結構了,因此也很好解釋了:
好比,我把 Demo 中 for 循環的次數修改成 100,運行以後,咱們看最後一次循環的結果爲:
remove 方法是從鏈表的頭結點開始遍歷鏈表,而咱們每次須要移除的實際上是最後一個節點,因爲鏈表愈來愈長,因此遍歷鏈表的時間愈來愈長。
因此致使咱們上一篇的案例中每循環 10000 次,時間都會增長。
接下來咱們看一下 JDK8 的源碼中的 remove(obj) 方法究竟是怎麼樣工做的。
這個方法的目的就是從頭結點開始遍歷鏈表,而後判斷每一個 Node 裏面的 item 是否是須要被刪除的這個,若是是則刪除,若是不是則繼續遍歷。
我想了好久這個地方怎麼能把代碼的執行流程說清楚呢?
除了 Debug 以外,由於 Debug 須要截很是多的圖纔可能說的清楚。
只有瘋狂的輸出日誌了。
咱們先看簡單的分析一下 JDK8 對應的源碼:
490 行是在對象被移除以前,咱們能夠在這裏加一行輸出語句打印當前的鏈表結構。
505 行是在對象被移除以後,咱們能夠在這裏加一行輸出語句打印刪除操做完成以後的鏈表結構。
縱觀整個方法,只有我標註的兩個地方會去修改鏈表結構。因此,咱們分別在這兩處地方的先後輸出相關日誌,而後分析日誌,就能夠知道這個方法的工做流程了。
知道它的工做流程了,再返回去看代碼,那還不是易如反掌的事兒?
這就是傳說中的蛇皮走位,反向操做。
因此,按照咱們上面的分析,在自定義的 CLQ 裏面加入輸出語句以下:
其中的 sortName 方法是爲了把 java.lang.Object@xxx 截取爲 @xxx,精簡輸出:
上面的 removeJDK8 方法除了輸出語句以外,其餘的代碼邏輯和 JDK8 的對應方法如出一轍。
咱們仍是用這個示例代碼:
跑起來分析日誌:
日誌不少,可是細細分析下來流程很是的清晰,你能夠在草稿本上畫一畫。
我帶着你們分析前兩個循環,一共 10 行日誌,咱們一行行的分析,注意咱們下面畫的圖僅體現了 node 裏面的 item 元素:
第【0】次循環,【移除以前】,鏈表item對象指向 = @723279cf->@10f87f48->
從測試代碼中能夠知道,被刪除以前咱們確實是有兩個節點:
因此根據日誌畫圖以下:
第【0】次循環,【修改節點item爲null】被修改的p節點的item爲(@10f87f48),即須要被刪除的節點
第【0】次循環,【修改節點item爲null以後】,鏈表item對象指向 = @723279cf->null->
第【0】次循環,【移除以後】,鏈表item對象指向 = @723279cf->null->
其實移除以後,就是把節點的 item 修改成 null 以後,因此結構和上面仍是同樣的:
第【0】次循環就分析完了。能夠看到如今的鏈表裏面有一個 item 爲 null 的元素,它還在鏈上,因此不會被回收。
接下來,咱們分析一下第【1】次循環。
第【1】次循環,【移除以前】,鏈表item對象指向 = @723279cf->null->@10f87f48->
因爲進入下次循環,因此會先執行 add 方法,因此如今的鏈表結構變成了這樣:
第【1】次循環,【處理null節點】把item爲(@723279cf)的pred節點的next節點,從item爲(null)的p節點修改成item爲(@10f87f48)的next節點
pred 節點裏面的 item 就是 @723297cf。
p 節點裏面的 item 就是 null。
next 節點裏面的 item 就是 @10f87f48。
第【1】次循環,【處理null節點以後】,鏈表item對象指向 = @723279cf->@10f87f48->
第【1】次循環,【修改節點item爲null】被修改的p節點的item爲(@10f87f48),即須要被刪除的節點
第【1】次循環,【修改節點item爲null以後】,鏈表item對象指向 = @723279cf->null->
第【1】次循環,【移除以後】,鏈表item對象指向 = @723279cf->null->
第【1】次循環完成後又回到了第【0】次循環完後的樣子。
中間的那個 item 爲 null 的節點去哪了呢?
由於這是個單向鏈表,從頭節點已經不能遍歷到這個節點了。因此等待它的命運將是被回收,因此也就不會內存溢出了。
到這裏,我以爲這個問題算是回答清楚了吧?
關於 remove(obj) 我就分享到這裏。
實話實話,這個方法對於 CLQ 並非很是的重要,咱們通常使用場景也比較少。
我寫這節主要是兩個目的。
一是回答讀者的提問,由於畢竟是看了個人文章引起出來的問題,我有義務回答。
二是分享一下這種本身 copy 一個類出來,而後只加入輸出語句的調試方式。這個調試方法老讀者確定知道了,我在寫 ArrayList 的時候也用過,寫 Dubbo 負載均衡算法的時候也用過。當你被一步步 debug 帶暈的時候,你能夠試一試這種方式,先總體再局部。好比本文的 CLQ,多線程調試 CLQ 的狀況下,我以爲日誌的輸出對於你理解它的精髓很是的有幫助。
仍是以前說過的,若是要深入理解 CLQ 這個十分優秀、十分有想法的基於非阻塞方法實現的線程安全的隊列,你們須要去看的是 offer、poll 方法。而後一個狀況一個狀況的去分析,看看它是怎麼避免頻繁 CAS 的,本身拿着草稿本在上面寫寫畫畫。
我也妄想過經過這篇文章給大家把它講的明明白白的,後來我發現這對我而言難度有點大。
我在這裏給你們指個路,看哪幾種狀況:
單線程下的 offer。
單線程下的 poll。
多線程下的一個線程 offer ,一個線程 poll。offer 比 poll 快。
多線程下的一個線程 offer ,一個線程 poll。offer 比 poll 慢。
就這四種狀況,玩去吧。
一種很是優秀的思想,很是牛逼的實現,我但願你能靜下心來堅持過半小時。
看了我上面的介紹,準備靜下心來看第一種狀況:單線程下的 offer。
若是你用 IDEA 的 Debug 調試 CLQ 的 offer 方法,半個小時後你心態應該就會炸裂:
你有可能會碰到的一個巨坑,好比咱們的測試代碼是這樣的:很是簡單,在隊列裏面添加一個元素。
因爲初始化的狀況下 head=tail=new Node<E>(null):
因此在 add 方法被調用以後的鏈表結構裏面的 item 指向應該是這樣的:
咱們在 offer 方法裏面加入幾個輸出語句:
執行以後的日誌是這樣的:
爲何最後一行輸出,【offer以後】輸出的日誌不是 null->@723279cf 呢?
由於這個方法裏面會調用 first 方法,獲取真正的頭節點,即 item 不爲 null 的節點:
到這裏都一切正常。可是,當你用 debug 模式操做的時候就不太同樣了:
頭節點的 item 不爲 null 了!而頭節點的下一個節點爲 null,因此拋出空指針異常。
單線程的狀況下代碼直接運行的結果和 Debug 運行的結果不一致!這不是遇到鬼了嗎。
我在網上查了一圈,發現遇到鬼的網友還很多。
最終找到了這個地方:
https://stackoverflow.com/questions/55889152/why-my-object-has-been-changed-by-intellij-ideas-debugger-soundlessly
這個哥們遇到的問題和咱們如出一轍:
這個問題下面只有一個回答:
你知道回答這個問題的哥們是誰嗎?IDEA 的產品經理,獻上個人 respect。
最後的解決方案就是關閉 IDEA 的這兩個配置:
由於 IDEA 在 Debug 模式下會主動的幫咱們調用一次 toString 方法,而 toString 方法裏面,會去調用迭代器。
而 CLQ 的迭代器,會觸發 first 方法,這個裏面和以前說的,會修改 head 元素:
一切,都真相大白了。
以前,我認爲是玄學。而如今,沒有什麼是玄學,咱們要相信科學。
我身邊也有朋友碰到過這個問題,若是不知道這個坑,很是的摳腦袋,很容易就「懷疑人生」了:
☜ 滑 動 查 看 更 多 圖 片
文章中提到的《美團點評 2019 技術年貨》是公衆號【美團技術團隊】 2019 年出品的後臺技術文章集合,內容很是的豐富:若是你有興趣,能夠在公衆號後臺回覆關鍵字【java】便可得到 PDF 的下載連接。
若是你以爲麻煩了,那你也能夠直接加我微信,備註【PDF】,我直接發給你:放心,若是你不主動找我聊天,我也是不會主動和你搭話的。靜靜的躺在朋友圈裏,作個點贊之交。畢竟說出來你可能不信,我也是有輕微的社交恐懼症的。
最後,你們安排個「一鍵三連」(轉發、在看、點贊)吧,周更很累的,不要白嫖我,須要一點正反饋。才疏學淺,不免會有紕漏,若是你發現了錯誤的地方,因爲本號沒有留言功能,還請你在後臺留言指出來,我對其加以修改。
感謝您的閱讀,我堅持原創,十分歡迎並感謝您的關注。
我是 why,一個被代碼耽誤的文學創做者,不是大佬,可是喜歡分享,是一個又暖又有料的四川好男人。
還有,重要的事情說三遍:歡迎關注我呀。歡迎關注我呀。歡迎關注我呀。