阿里面試官:你說你熟悉jvm?那你講一下併發的可達性分析

這次的文章我們聊聊jvm。jvm可以說是面試必備技能了。簡歷上寫了,多問幾句。簡歷上沒寫,也得提上幾句。

我們先從一個簡單的熱身題入手,引出本文想要分享的內容。

當面試扯到jvm這一部分的時候,面試官大概率會問你jvm怎麼判斷哪些對象應該回收呢?

這種經典的面試題當然難不住你。

你會脫口而出引用計數算法可達性分析算法

然後你就停下來了嗎?難道你不知道你回答了一句話之後,面試官肯定會接着問你能詳細說明一下嗎?所以,不要停。主動點,面試的時候主動點。你要抓住面試官把話語權交給你的寶貴機會,接着說啊,你得支棱起來

因爲引用計數法的算法是這樣的:在對象中添加一個引用計數器,每當一個地方引用它時,計數器就加一;當引用失效時,計數器值就減一;任何時刻計數器爲零的對象就是不可能再被使用的。

但是這樣的算法有個問題,是什麼呢?

不經意間來一波自問自答。讓面試官聽的一愣一愣的。

就是不能解決循環依賴的問題。

並拿着自己準備的紙和筆快速的畫出下面這樣的圖:

 

 

Object 1和Object 2其實都可以被回收,但是它們之間還有相互引用,所以它們各自的計數器爲1,則還是不會被回收。

所以,Java虛擬機沒有采用引用計數法。它採用的是可達性分析算法。

可達性分析算法的思路就是通過一系列的“GC Roots”,也就是根對象作爲起始節點集合,從根節點開始,根據引用關係向下搜索,搜索過程所走過的路徑稱爲引用鏈,如果某個對象到GC Roots間沒有任何引用鏈相連。

用圖論的話來說就是從GC Roots到這個對象不可達時,則證明此對象是不可能再被使用的。所以此對象就是可以被回收的對象。

說這句話的時候再次,快速的紙上畫出下面的圖:

 

 

好了,到這裏就可以把話語權交給面試官了。因爲到這裏,他接下來可以問的點有很多,你不知道他會問什麼,比如:

你剛剛談到了根節點,那你知道哪些對象可以作爲根對象嗎?

你剛剛談到了引用,那你知道java裏面有哪幾種引用嗎?

你剛剛談到了可達性分析算法,那如果在該算法中被判定不可達對象,是不是一定會被回收呢?

談談你熟悉的垃圾回收器和他們的工作過程?

.......

上面的這些問題都太常規了,任何一份面經裏面都會有這樣的幾個問題。

而本文要解決的是下面這個稍微不那麼常見,但是你答題的過程中一定會提到的點“併發標記”、“浮動垃圾”。

CMS和G1都是有一個併發標記的過程,併發標記要解決什麼問題?帶來了什麼問題?怎麼解決這些問題呢?

 

 

併發標記要解決什麼問題?

剛剛我們談到的可達性分析算法是需要一個理論上的前提的:該算法的全過程都需要基於一個能保障一致性的快照中才能夠分析,這意味着必須全程凍結用戶線程的運行。

爲了不凍結用戶線程的運行,那我們就需要讓垃圾回收線程和用戶線程同時運行。

所有我們來個反證法,先假設不併發標記,即只有垃圾回收線程在運行的流程是怎樣的:

第一步是需要找到根節點,也就是我們常說的根節點枚舉。

而在這個過程中,由於GC Roots是遠遠少於整個java堆中的全部對象的,而且在OopMap此類優化技巧的加持下,它帶來的停頓時間是非常短暫且相對固定的,可以理解爲不會隨着堆裏面的對象的增加而增加。大概就是下面這個圖的意思:

 

 

但是我們做完根節點枚舉,只是做完了第一步。接下來,我們需要從GC Roots往下繼續遍歷對象圖,進行"標記"過程。而這一步的停頓時間必然是隨着java堆中的對象增加而增加的。大概就是下面這個圖的意思:

 

 

這個邏輯不復雜:堆約大,存儲的對象越多,對象圖結構越複雜,要標記更多對象,所以產生的停頓時間也自然就長了。

所有,經過上面的分析,我們知道了,根節點的枚舉階段是不太耗時的,也不會隨着java堆裏面存儲的對象增加而增加耗時。而"標記"過程的耗時是會隨着java堆裏面存儲的對象增加而增加的。

"標記"階段是所有使用可達性分析算法的垃圾回收器都有的階段。因此我們可以知道,如果能夠削減"標記"過程這部分的停頓時間,那麼收益將是可觀的。

所以併發標記要解決什麼問題呢?

就是要消減這一部分的停頓時間。那就是讓垃圾回收器和用戶線程同時運行,併發工作。也就是我們說的併發標記的階段。

 

 

併發標記帶來了什麼問題?

在說帶來什麼問題之前,我們必須得先搞清楚一個問題:

爲什麼遍歷對象圖的時候必須在一個能保障一致性的快照中

爲了說明這個問題,我們就要引入"三色標記"大法了。注意:"三色標記"也是jvm的一個考點哦。

什麼是"三色標記"?《深入理解Java虛擬機(第三版)》中是這樣描述的:

在遍歷對象圖的過程中,把訪問都的對象按照"是否訪問過"這個條件標記成以下三種顏色:

白色:表示對象尚未被垃圾回收器訪問過。顯然,在可達性分析剛剛開始的階段,所有的對象都是白色的,若在分析結束的階段,仍然是白色的對象,即代表不可達。

黑色:表示對象已經被垃圾回收器訪問過,且這個對象的所有引用都已經掃描過。黑色的對象代表已經掃描過,它是安全存活的,如果有其它的對象引用指向了黑色對象,無須重新掃描一遍。黑色對象不可能直接(不經過灰色對象)指向某個白色對象。

灰色:表示對象已經被垃圾回收器訪問過,但這個對象至少存在一個引用還沒有被掃描過

讀完上面描述,再品一品下面的圖:

 

 

可以看到,灰色對象是黑色對象與白色對象之間的中間態。當標記過程結束後,只會有黑色和白色的對象,而白色的對象就是需要被回收的對象。

在可達性分析的掃描過程中,如果只有垃圾回收線程在工作,那肯定不會有任何問題。

但是垃圾回收器和用戶線程同時運行呢?這個時候就有點意思了。

垃圾回收器在對象圖上面標記顏色,而同時用戶線程在修改引用關係,引用關係修改了,那麼對象圖就變化了,這樣就有可能出現兩種後果:

一種是把原本消亡的對象錯誤的標記爲存活,這不是好事,但是其實是可以容忍的,只不過產生了一點逃過本次回收的浮動垃圾而已,下次清理就可以。

一種是把原本存活的對象錯誤的標記爲已消亡,這就是非常嚴重的後果了,一個程序還需要使用的對象被回收了,那程序肯定會因此發生錯誤。

當面試官問你:爲什麼會產生浮動垃圾的時候,你就可以用上面的話來回答。

但是大概率情況下面試官應該更加關心第二種情況。

他可能會問:你剛剛說的第二種情況,"把原本存活的對象錯誤的標記爲已消亡"能具體的說明一下嗎?怎麼消亡的?垃圾回收器是怎麼解決這個問題的?

所以接下來,我們主要分析一下併發標記的過程中"對象消失"的問題。具體"對象"是怎麼沒了的。

 

 

這裏藉助《深入理解Java虛擬機(第三版)》的示例,但是第三版的示例的描述寫的不是特別容易理解,我就盡我所能的描述的清楚一些,下面會結合動圖,分析標記的三種情況:

正常標記

我們先看一下一次正常的標記過程:

首先是初始狀態,很簡單,只有GC Roots是黑色的。同時需要注意下面的圖片的箭頭方向,代表的是有向的,比如其中的一條引用鏈是: 根節點->5->6->7->8->11->10

 

 

在掃描的過程中,變化是這樣的:

內心OS:爲了做下面的這些動圖、爲了把動圖裏面的每張圖截的大小一個像素都不差,鬼知道我做的多辛苦,做瞎我的鈦合金狗眼。

 

 

你看上面的動圖,灰色對象始終是介於黑色和白色之間的。當掃描順利完成後,對象圖就變成了這個樣子:

 

 

此時,黑色對象是存活的對象,白色對象是消亡了,可以回收的對象。

記住,上面演示的是一切都是那麼美好的正常情況。

對象消失的情況一

接下來,我們看看對象消失的情況:

如果用戶線程在標記的時候,修改了引用關係,就會出現下面的情況:

 

 

當掃描完成後,對象圖就變成了這個樣子:

 

 

這時,我們和之前分析的正常掃描結束的對象圖對比,就能清楚的看到,掃描完成後,原本還在被對象5引用的對象9,由於是白色對象,所以根據三色標記原則,對象9會被當成垃圾回收。

這樣就出現了對象消失的情況。

對象消息的情況二

下面再給各位看看另外一種"對象消失"的現象:

 

 

上面演示的是用戶線程切斷引用後重新被黑色對象引用的對象就是原來引用鏈的一部分。

對象7和對象10本來就是原引用鏈(根節點->5->6->7->8->11->10)的一部分。修改後的引用鏈變成了(根節點->5->6->7->10)。

當掃描完成後,對象圖就變成了這個樣子:

 

 

由於黑色對象不會重新掃描,這將導致掃描結束後對象10和對象11都會回收了。他們都是被修改之前的原來的引用鏈的一部分。

所以,回到最開始的疑問:併發標記帶來了什麼問題?

經過我們上面三種情況(一種正常情況,兩種"對象丟失"的情況)的動圖分析,和掃描完成後的最終對象圖進行分析對比,我們知道了,併發標記除了會產生浮動垃圾,還會出現"對象消失"的問題。

 

 

怎麼解決"對象消失"問題呢?

有一個大佬叫Wilson,他在1994年在理論上證明了,當且僅當以下兩個條件同時滿足時,會產生"對象消失"的問題,原來應該是黑色的對象被誤標爲了白色:

條件一:賦值器插入了一條或者多條從黑色對象到白色對象的新引用。

條件二:賦值器刪除了全部從灰色對象到該白色對象的直接或間接引用。

你在結合我們上面出現過的圖捋一捋上面的這兩個條件,是不是當且僅當的關係:

黑色對象5到白色對象9之間的引用是新建的,對應條件一。

黑色對象6到白色對象9之間的引用被刪除了,對應條件二。

 

 

由於兩個條件之間是當且僅當的關係。所以,我們要解決併發標記時對象消失的問題,只需要破壞兩個條件中的任意一個就行。

於是產生了兩種解決方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB)。

在HotSpot虛擬機中,CMS是基於增量更新來做併發標記的,G1則採用的是原始快照的方式。

什麼是增量更新呢?

增量更新要破壞的是第一個條件(賦值器插入了一條或者多條從黑色對象到白色對象的新引用),當黑色對象插入新的指向白色對象的引用關係時,就將這個新插入的引用記錄下來,等併發掃描結束之後,再將這些記錄過的引用關係中的黑色對象爲根,重新掃描一次。

可以簡化的理解爲:黑色對象一旦插入了指向白色對象的引用之後,它就變回了灰色對象。

下面的圖就是一次併發掃描結束之後,記錄了黑色對象5新指向了白色對象9:

 

 

這樣對象9又被掃描成爲了黑色。也就不會被回收,所以不會出現對象消失的情況。

什麼是原始快照呢?

原始快照要破壞的是第二個條件(賦值器刪除了全部從灰色對象到該白色對象的直接或間接引用),當灰色對象要刪除指向白色對象的引用關係時,就將這個要刪除的引用記錄下來,在併發掃描結束之後,再將這些記錄過的引用關係中的灰色對象爲根,重新掃描一次。

這個可以簡化理解爲:無論引用關係刪除與否,都會按照剛剛開始掃描那一刻的對象圖快照開進行搜索。

 

 

需要注意的是,上面的介紹中無論是對引用關係記錄的插入還是刪除,虛擬機的記錄操作都是通過寫屏障實現的。寫屏障也是一個重要的知識點,但是不是本文重點,就不進行詳細介紹了。

只是補充兩點:

1.這裏的寫屏障和我們常說的爲了解決併發亂序執行問題的"內存屏障"不是一碼事,需要區分開來。

2.寫屏障可以看作虛擬機層面對"引用類型字段賦值"這個動作的AOP切面,在引用對象賦值時會產生一個環形通知,供程序執行額外的動作,也就是說賦值的前後都在寫屏障的覆蓋範疇內。在賦值前的部分的寫屏障叫做寫前屏障(Pre-Write Barrier),在賦值後的則叫作寫後屏障(Post-Write Barrier)。

所以,經過簡單的推導我們可以知道:

增量更新用的是寫後屏障(Post-Write Barrier),記錄了所有新增的引用關係。

原始快照用的是寫前屏障(Pre-Write Barrier),將所有即將被刪除的引用關係的舊引用記錄下來。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章