這是why技術的第35篇原創文章
上面這張圖是我還是北漂的時候,在鼓樓附近的胡同里面拍的。
那天剛剛下完雨,路過這個地方的時候,一瞬間就被這五顏六色的門板和自行車給吸引了,於是拍下了這張圖片。看到這張圖片的時候我就很開心,多鮮活、多舒服的畫面呀。
以后的文章里面我的第一張配圖都用自己隨時拍下的照片吧。分享生活、分享技術,哈哈。
好了,說回文章。
這次的文章我們聊聊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),將所有即將被刪除的引用關系的舊引用記錄下來。
最后說一句(求關注)
1.本文大部分內容來源於《深入理解Java虛擬機(第三版)》,只是我覺得三色標記這個點,僅僅用圖片和文字是很難描述清楚的,所以我加上自己的理解做了動圖。強烈建議先去閱讀第三版相關內容,如果沒有讀明白,再來讀這篇文章,應該能更好的理解。
2.最近有很多讀者在找我修改簡歷、咨詢工作的相關事情了,我就知道馬上又要開始春招了。
其實我也不是很有資格給你們修改簡歷,也不是一個技術很牛逼的人,只是把我知道的分享出來了而已,不僅能讓我鞏固知識,還是倒逼我進行知識輸入,在此之外還能對你有一點點幫助,那就是我文章的全部價值所在。
另外如果你正在經歷春招或者社招,有興趣的可以閱讀一下我之前的這篇文章,看看是否有一點點幫助:
《面試了15位來自985/211高校的2020屆研究生之后的思考》
才疏學淺,難免會有紕漏,如果你發現了錯誤的地方,還請你留言給我指出來,我對其加以修改。
如果你覺得文章還不錯,你的轉發、分享、贊賞、點贊、留言就是對我最大的鼓勵。
感謝您的閱讀,我堅持原創,十分歡迎並感謝您的關注。
以上。
歡迎關注公眾號【why技術】,堅持輸出原創。分享技術、品味生活,願你我共同進步。