炸了!一口氣問了我18個JVM問題!

yes的練級攻略發表於2020-11-13

前言

GC 對於Java 來說重要性不言而喻,不論是平日裡對 JVM 的調優還是面試中的無情轟炸。

這篇文章我會以一問一答的方式來展開有關 GC 的內容。

不過在此之前強烈建議先看這篇文章深度揭祕垃圾回收底層

因為這篇文章解釋了很多有關垃圾回收的基本知識,能從源頭上理解垃圾回收和日益發展的垃圾收集器演進的方向,這很重要

本文章所說的 GC 實現沒有特殊說明的話,預設指的是 HotSpot 的。

我先將十八個問題都列出來,如果都清楚的話那就可以關閉這篇文章了。

好了,開始表演。

young gc、old gc、full gc、mixed gc 傻傻分不清?

這個問題的前置條件是你得知道 GC 分代,為什麼分代。這個在之前文章提了,不清楚的可以去看看。

現在我們來回答一下這個問題。

其實 GC 分為兩大類,分別是 Partial GC 和 Full GC。

Partial GC 即部分收集,分為 young gc、old gc、mixed gc。

  • young gc:指的是單單收集年輕代的 GC。
  • old gc:指的是單單收集老年代的 GC。
  • mixed gc:這個是 G1 收集器特有的,指的是收集整個年輕代和部分老年代的 GC。

Full GC 即整堆回收,指的是收取整個堆,包括年輕代、老年代,如果有永久代的話還包括永久代。

其實還有 Major GC 這個名詞,在《深入理解Java虛擬機器》中這個名詞指代的是單單老年代的 GC,也就是和 old gc 等價的,不過也有很多資料認為其是和 full gc 等價的。

還有 Minor GC,其指的就是年輕代的 gc。

young gc 觸發條件是什麼?

大致上可以認為在年輕代的 eden 快要被佔滿的時候會觸發 young gc。

為什麼要說大致上呢?因為有一些收集器的回收實現是在 full gc 前會讓先執行以下 young gc。

比如 Parallel Scavenge,不過有引數可以調整讓其不進行 young gc。

可能還有別的實現也有這種操作,不過正常情況下就當做 eden 區快滿了即可。

eden 快滿的觸發因素有兩個,一個是為物件分配記憶體不夠,一個是為 TLAB 分配記憶體不夠。

full gc 觸發條件有哪些?

這個觸發條件稍微有點多,我們來看下。

  • 在要進行 young gc 的時候,根據之前統計資料發現年輕代平均晉升大小比現在老年代剩餘空間要大,那就會觸發 full gc。
  • 有永久代的話如果永久代滿了也會觸發 full gc。
  • 老年代空間不足,大物件直接在老年代申請分配,如果此時老年代空間不足則會觸發 full gc。
  • 擔保失敗即 promotion failure,新生代的 to 區放不下從 eden 和 from 拷貝過來物件,或者新生代物件 gc 年齡到達閾值需要晉升這兩種情況,老年代如果放不下的話都會觸發 full gc。
  • 執行 System.gc()、jmap -dump 等命令會觸發 full gc。

知道 TLAB 嗎?來說說看

這個得從記憶體申請說起。

一般而言生成物件需要向堆中的新生代申請記憶體空間,而堆又是全域性共享的,像新生代記憶體又是規整的,是通過一個指標來劃分的。

記憶體是緊湊的,新物件建立指標就右移物件大小 size 即可,這叫指標加法(bump [up] the pointer)。

可想而知如果多個執行緒都在分配物件,那麼這個指標就會成為熱點資源,需要互斥那分配的效率就低了。

於是搞了個 TLAB(Thread Local Allocation Buffer),為一個執行緒分配的記憶體申請區域。

這個區域只允許這一個執行緒申請分配物件,允許所有執行緒訪問這塊記憶體區域

TLAB 的思想其實很簡單,就是劃一塊區域給一個執行緒,這樣每個執行緒只需要在自己的那畝地申請物件記憶體,不需要爭搶熱點指標。

當這塊記憶體用完了之後再去申請即可。

這種思想其實很常見,比如分散式發號器,每次不會一個一個號的取,會取一批號,用完之後再去申請一批。

可以看到每個執行緒有自己的一塊記憶體分配區域,短一點的箭頭代表 TLAB 內部的分配指標。

如果這塊區域用完了再去申請即可。

不過每次申請的大小不固定,會根據該執行緒啟動到現在的歷史資訊來調整,比如這個執行緒一直在分配記憶體那麼 TLAB 就大一些,如果這個執行緒基本上不會申請分配記憶體那 TLAB 就小一些。

還有 TLAB 會浪費空間,我們來看下這個圖。

可以看到 TLAB 內部只剩一格大小,申請的物件需要兩格,這時候需要再申請一塊 TLAB ,之前的那一格就浪費了。

在 HotSpot 中會生成一個填充物件來填滿這一塊,因為堆需要線性遍歷,遍歷的流程是通過物件頭得知物件的大小,然後跳過這個大小就能找到下一個物件,所以不能有空洞。

當然也可以通過空閒連結串列等外部記錄方式來實現遍歷。

還有 TLAB 只能分配小物件,大的物件還是需要在共享的 eden 區分配

所以總的來說 TLAB 是為了避免物件分配時的競爭而設計的。

那 PLAB 知道嗎?

可以看到和 TLAB 很像,PLAB 即 Promotion Local Allocation Buffers。

用在年輕代物件晉升到老年代時。

在多執行緒並行執行 YGC 時,可能有很多物件需要晉升到老年代,此時老年代的指標就“熱”起來了,於是搞了個 PLAB。

先從老年代 freelist(空閒連結串列) 申請一塊空間,然後在這一塊空間中就可以通過指標加法(bump the pointer)來分配記憶體,這樣對 freelist 競爭也少了,分配空間也快了。

大致就是上圖這麼個思想,每個執行緒先申請一塊作為 PLAB ,然後在這一塊記憶體裡面分配晉升的物件。

這和 TLAB 的思想相似。

產生 concurrent mode failure 真正的原因

《深入理解Java虛擬機器》:由於CMS收集器無法處理“浮動垃圾”(FloatingGarbage),有可能出現“Con-current Mode Failure”失敗進而導致另一次完全“Stop The World”的Full GC的產生。

這段話的意思是因為拋這個錯而導致一次 Full GC。

實際上是 Full GC 導致拋這個錯,我們來看一下原始碼,版本是 openjdk-8。

首先搜一下這個錯。

再找找看 report_concurrent_mode_interruption 被誰呼叫。

查到是在 void CMSCollector::acquire_control_and_collect(...) 這個方法中被呼叫的。

再來看看 first_state : CollectorState first_state = _collectorState;

看列舉已經很清楚了,就是在 cms gc 還沒結束的時候。

acquire_control_and_collect 這個方法是 cms 執行 foreground gc 的。

cms 分為 foreground gc 和 background gc。

foreground 其實就是 Full gc。

因此是 full gc 的時候 cms gc 還在進行中導致拋這個錯

究其原因是因為分配速率太快導致堆不夠用,回收不過來因此產生 full gc。

也有可能是發起 cms gc 設定的堆的閾值太高。

CMS GC 發生 concurrent mode failure 時的 full GC 為什麼是單執行緒的?

以下的回答來自 R 大

因為沒足夠開發資源,偷懶了。就這麼簡單。沒有任何技術上的問題。 大公司都自己內部做了優化。

所以最初怎麼會偷這個懶的呢?多災多難的CMS GC經歷了多次動盪。它最初是作為Sun Labs的Exact VM的低延遲GC而設計實現的。

但 Exact VM在與 HotSpot VM爭搶 Sun 的正牌 JVM 的內部鬥爭中失利,CMS GC 後來就作為 Exact VM 的技術遺產被移植到了 HotSpot VM上。

就在這個移植還在進行中的時候,Sun 已經開始略顯疲態;到 CMS GC 完全移植到 HotSpot VM 的時候,Sun 已經處於快要不行的階段了。

開發資源減少,開發人員流失,當時的 HotSpot VM 開發組能夠做的事情並不多,只能挑重要的來做。而這個時候 Sun Labs 的另一個 GC 實現,Garbage-First GC(G1 GC)已經面世。

相比可能在長時間執行後受碎片化影響的 CMS,G1 會增量式的整理/壓縮堆裡的資料,避免受碎片化影響,因而被認為更具潛力。

於是當時本來就不多的開發資源,一部分還投給了把G1 GC產品化的專案上——結果也是進展緩慢。

畢竟只有一兩個人在做。所以當時就沒能有足夠開發資源去打磨 CMS GC 的各種配套設施的細節,配套的備份 full GC 的並行化也就耽擱了下來。

但肯定會有同學抱有疑問:HotSpot VM不是已經有並行GC了麼?而且還有好幾個?

讓我們來看看:

  • ParNew:並行的young gen GC,不負責收集old gen。
  • Parallel GC(ParallelScavenge):並行的young gen GC,與ParNew相似但不相容;同樣不負責收集old gen。
  • ParallelOld GC(PSCompact):並行的full GC,但與ParNew / CMS不相容。

所以…就是這麼一回事。

HotSpot VM 確實是已經有並行 GC 了,但兩個是隻負責在 young GC 時收集 young gen 的,這倆之中還只有 ParNew 能跟 CMS 搭配使用;

而並行 full GC 雖然有一個 ParallelOld,但卻與 CMS GC 不相容所以無法作為它的備份 full GC使用。

為什麼有些新老年代的收集器不能組合使用比如 ParNew 和 Parallel Old?

這張圖是 2008 年 HostSpot 一位 GC 組成員畫的,那時候 G1 還沒問世,在研發中,所以畫了個問號在上面。

裡面的回答是 :

"ParNew" is written in a style... "Parallel Old" is not written in the "ParNew" style

HotSpot VM 自身的分代收集器實現有一套框架,只有在框架內的實現才能互相搭配使用。

而有個開發他不想按照這個框架實現,自己寫了個,測試的成績還不錯後來被 HotSpot VM 給吸收了,這就導致了不相容。

我之前看到一個回答解釋的很形象:就像動車組車頭帶不了綠皮車廂一樣,電氣,掛鉤啥的都不匹配。

新生代的 GC 如何避免全堆掃描?

在常見的分代 GC 中就是利用記憶集來實現的,記錄可能存在的老年代中有新生代的引用的物件地址,來避免全堆掃描。

上圖有個物件精度的,一個是卡精度的,卡精度的叫卡表。

把堆中分為很多塊,每塊 512 位元組(卡頁),用位元組陣列來中的一個元素來表示某一塊,1表示髒塊,裡面存在跨代引用。

在 Hotspot 中的實現是卡表,是通過寫後屏障維護的,虛擬碼如下。

cms 中需要記錄老年代指向年輕代的引用,但是寫屏障的實現並沒有做任何條件的過濾

不判斷當前物件是老年代物件且引用的是新生代物件才會標記對應的卡表為髒。

只要是引用賦值都會把物件的卡標記為髒,當然YGC掃描的時候只會掃老年代的卡表。

這樣做是減少寫屏障帶來的消耗,畢竟引用的賦值非常的頻繁。

那 cms 的記憶集和 G1 的記憶集有什麼不一樣?

cms 的記憶集的實現是卡表即 card table。

通常實現的記憶集是 points-out 的,我們知道記憶集是用來記錄非收集區域指向收集區域的跨代引用,它的主語其實是非收集區域,所以是 points-out 的。

在 cms 中只有老年代指向年輕代的卡表,用於年輕代 gc。

而 G1 是基於 region 的,所以在 points-out 的卡表之上還加了個 points-into 的結構。

因為一個 region 需要知道有哪些別的 region 有指向自己的指標,然後還需要知道這些指標在哪些 card 中

其實 G1 的記憶集就是個 hash table,key 就是別的 region 的起始地址,然後 value 是一個集合,裡面儲存這 card table 的 index。

我們來看下這個圖就很清晰了。

像每次引用欄位的賦值都需要維護記憶集開銷很大,所以 G1 的實現利用了 logging write barrier(下文會介紹)。

也是非同步思想,會先將修改記錄到佇列中,當佇列超過一定閾值由後臺執行緒取出遍歷來更新記憶集。

為什麼 G1 不維護年輕代到老年代的記憶集?

G1 分了 young GC 和 mixed gc。

young gc 會選取所有年輕代的 region 進行收集。

midex gc 會選取所有年輕代的 region 和一些收集收益高的老年代 region 進行收集。

所以年輕代的 region 都在收集範圍內,所以不需要額外記錄年輕代到老年代的跨代引用

cms 和 G1 為了維持併發的正確性分別用了什麼手段?

之前文章分析到了併發執行漏標的兩個充分必要條件是:

  1. 將新物件插入已掃描完畢的物件中,即插入黑色物件到白色物件的引用。

  2. 刪除了灰色物件到白色物件的引用。

cms 和 g1 分別通過增量更新和 SATB 來打破這兩個充分必要條件,維持了 GC 執行緒與應用執行緒併發的正確性。

cms 用了增量更新(Incremental update),打破了第一個條件,通過寫屏障將插入的白色物件標記成灰色,即加入到標記棧中,在 remark 階段再掃描,防止漏標情況。

G1 用了 SATB(snapshot-at-the-beginning),打破了第二個條件,會通過寫屏障把舊的引用關係記下來,之後再把舊引用關係再掃描過。

這個從英文名詞來看就已經很清晰了。講白了就是在 GC 開始時候如果物件是存活的就認為其存活,等於拍了個快照。

而且 gc 過程中新分配的物件也都認為是活的。每個 region 會維持 TAMS (top at mark start)指標,分別是 prevTAMS 和 nextTAMS 分別標記兩次併發標記開始時候 Top 指標的位置。

Top 指標就是 region 中最新分配物件的位置,所以 nextTAMS 和 Top 之間區域的物件都是新分配的物件都認為其是存活的即可。

而利用增量更新的 cms 在 remark 階段需要重新所有執行緒棧和整個年輕代,因為等於之前的根有新增,所以需要重新掃描過,如果年輕代的物件很多的話會比較耗時。

要注意這階段是 STW 的,很關鍵,所以 CMS 也提供了一個 CMSScavengeBeforeRemark 引數,來強制 remark 階段之前來一次 YGC。

而 g1 通過 SATB 的話在最終標記階段只需要掃描 SATB 記錄的舊引用即可,從這方面來說會比 cms 快,但是也因為這樣浮動垃圾會比 cms 多。

什麼是 logging write barrier ?

寫屏障其實耗的是應用程式的效能,是在引用賦值的時候執行的邏輯,這個操作非常的頻繁,因此就搞了個 logging write barrier。

把寫屏障要執行的一些邏輯搬運到後臺執行緒執行,來減輕對應用程式的影響

在寫屏障裡只需要記錄一個 log 資訊到一個佇列中,然後別的後臺執行緒會從佇列中取出資訊來完成後續的操作,其實就是非同步思想。

像 SATB write barrier ,每個 Java 執行緒有一個獨立的、定長的 SATBMarkQueue,在寫屏障裡只把舊引用壓入該佇列中。滿了之後會加到全域性 SATBMarkQueueSet。

後臺執行緒會掃描,如果超過一定閾值就會處理,開始 tracing。

在維護記憶集的寫屏障也用了 logging write barrier 。

簡單說下 G1 回收流程

G1 從大局上看分為兩大階段,分別是併發標記和物件拷貝。

併發標記是基於 STAB 的,可以分為四大階段:

1、初始標記(initial marking),這個階段是 STW 的,掃描根集合,標記根直接可達的物件即可。在G1中標記物件是利用外部的bitmap來記錄,而不是物件頭。

2、併發階段(concurrent marking),這個階段和應用執行緒併發,從上一步標記的根直接可達物件開始進行 tracing,遞迴掃描所有可達物件。 STAB 也會在這個階段記錄著變更的引用。

3、最終標記(final marking), 這個階段是 STW 的,處理 STAB 中的引用。

4、清理階段(clenaup),這個階段是 STW 的,根據標記的 bitmap 統計每個 region 存活物件的多少,如果有完全沒存活的 region 則整體回收。

物件拷貝階段(evacuation),這個階段是 STW 的。

根據標記結果選擇合適的 reigon 組成收集集合(collection set 即 CSet),然後將 CSet 存活物件拷貝到新 region 中。

G1 的瓶頸在於物件拷貝階段,需要花較多的瓶頸來轉移物件。

簡單說下 cms 回收流程

其實從之前問題的 CollectorState 列舉可以得知幾個流程了。

1、初始標記(initial mark),這個階段是 STW 的,掃描根集合,標記根直接可達的物件即可。

2、併發標記(Concurrent marking),這個階段和應用執行緒併發,從上一步標記的根直接可達物件開始進行 tracing,遞迴掃描所有可達物件。

3、併發預清理(Concurrent precleaning),這個階段和應用執行緒併發,就是想幫重新標記階段先做點工作,掃描一下卡表髒的區域和新晉升到老年代的物件等,因為重新標記是 STW 的,所以分擔一點。

4、可中斷的預清理階段(AbortablePreclean),這個和上一個階段基本上一致,就是為了分擔重新標記標記的工作。

5、重新標記(remark),這個階段是 STW 的,因為併發階段引用關係會發生變化,所以要重新遍歷一遍新生代物件、Gc Roots、卡表等,來修正標記。

6、併發清理(Concurrent sweeping),這個階段和應用執行緒併發,用於清理垃圾。

7、併發重置(Concurrent reset),這個階段和應用執行緒併發,重置 cms 內部狀態。

cms 的瓶頸就在於重新標記階段,需要較長花費時間來進行重新掃描。

cms 寫屏障又是維護卡表,又得維護增量更新?

卡表其實只有一份,又得用來支援 YGC 又得支援 CMS 併發時的增量更新肯定是不夠的。

每次 YGC 都會掃描重置卡表,這樣增量更新的記錄就被清理了。

所以還搞了個 mod-union table,在併發標記時,如果發生 YGC 需要重置卡表的記錄時,就會更新 mod-union table 對應的位置。

這樣 cms 重新標記階段就能結合當時的卡表和 mod-union table 來處理增量更新,防止漏標物件了。

GC 調優的兩大目標是啥?

分別是最短暫停時間和吞吐量

最短暫停時間:因為 GC 會 STW 暫停所有應用執行緒,這時候對於使用者而言就等於卡頓了,因此對於時延敏感的應用來說減少 STW 的時間是關鍵。

吞吐量:對於一些對時延不敏感的應用比如一些後臺計算應用來說,吞吐量是關注的重點,它們不關注每次 GC 停頓的時間,只關注總的停頓時間少,吞吐量高。

舉個例子:

方案一:每次 GC 停頓 100 ms,每秒停頓 5 次。

方案二:每次 GC 停頓 200 ms,每秒停頓 2 次。

兩個方案相對而言第一個時延低,第二個吞吐高,基本上兩者不可兼得。

所以調優時候需要明確應用的目標

GC 如何調優

這個問題在面試中很容易問到,抓住核心回答。

現在都是分代 GC,調優的思路就是儘量讓物件在新生代就被回收,防止過多的物件晉升到老年代,減少大物件的分配。

需要平衡分代的大小、垃圾回收的次數和停頓時間

需要對 GC 進行完整的監控,監控各年代佔用大小、YGC 觸發頻率、Full GC 觸發頻率,物件分配速率等等。

然後根據實際情況進行調優。

比如進行了莫名其妙的 Full GC,有可能是某個第三方庫調了 System.gc。

Full GC 頻繁可能是 CMS GC 觸發記憶體閾值過低,導致物件分配不過來。

還有物件年齡晉升的閾值、survivor 過小等等,具體情況還是得具體分析,反正核心是不變的。

最後

其實還有關於 ZGC 的內容沒有分析,別急, ZGC 的文章已經寫了一半了,之後會發。

有關 GC 的問題在面試中還是很常見的,其實來來回回就那麼幾樣東西,記得我提到的抓住核心即可。

當然如果你有實際調優經歷那更可,所以要抓住工作中的機會,如果發生異常情況請積極參與,然後勤加思考,這可都是實打實的實戰經歷。

當然如果你想知道更多的 GC 細節那就看原始碼吧,原始碼之中無祕密。

個人能力有限,如果有紕漏的地方請抓緊聯絡我,也歡迎私信聯絡我

巨人的肩膀

https://segmentfault.com/a/1190000021394215?utm_source=tag-newest

https://blogs.oracle.com/jonthecollector/our-collectors

https://www.iteye.com/blog/user/rednaxelafx R大的部落格

https://www.jianshu.com/u/90ab66c248e6 佔小狼的部落格


我是 yes,從一點點到億點點,我們下篇見。

相關文章