前言
GC 對於Java 來講重要性不言而喻,不管是平日裏對 JVM 的調優仍是面試中的無情轟炸。面試
這篇文章我會以一問一答的方式來展開有關 GC 的內容。算法
不過在此以前強烈建議先看這篇文章深度揭祕垃圾回收底層。segmentfault
由於這篇文章解釋了不少有關垃圾回收的基本知識,能從源頭上理解垃圾回收和日益發展的垃圾收集器演進的方向,這很重要。數組
本文章所說的 GC 實現沒有特殊說明的話,默認指的是 HotSpot 的。多線程
我先將十八個問題都列出來,若是都清楚的話那就能夠關閉這篇文章了。併發
好了,開始表演。oracle
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 爲了維持併發的正確性分別用了什麼手段?
以前文章分析到了併發執行漏標的兩個充分必要條件是:
-
將新對象插入已掃描完畢的對象中,即插入黑色對象到白色對象的引用。
-
刪除了灰色對象到白色對象的引用。
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 的,能夠分爲四大階段:
一、初始標記(initial marking),這個階段是 STW 的,掃描根集合,標記根直接可達的對象便可。在G1中標記對象是利用外部的bitmap來記錄,而不是對象頭。
二、併發階段(concurrent marking),這個階段和應用線程併發,從上一步標記的根直接可達對象開始進行 tracing,遞歸掃描全部可達對象。 STAB 也會在這個階段記錄着變動的引用。
三、最終標記(final marking), 這個階段是 STW 的,處理 STAB 中的引用。
四、清理階段(clenaup),這個階段是 STW 的,根據標記的 bitmap 統計每一個 region 存活對象的多少,若是有徹底沒存活的 region 則總體回收。
對象拷貝階段(evacuation),這個階段是 STW 的。
根據標記結果選擇合適的 reigon 組成收集集合(collection set 即 CSet),而後將 CSet 存活對象拷貝到新 region 中。
G1 的瓶頸在於對象拷貝階段,須要花較多的瓶頸來轉移對象。
簡單說下 cms 回收流程
其實從以前問題的 CollectorState 枚舉能夠得知幾個流程了。
一、初始標記(initial mark),這個階段是 STW 的,掃描根集合,標記根直接可達的對象便可。
二、併發標記(Concurrent marking),這個階段和應用線程併發,從上一步標記的根直接可達對象開始進行 tracing,遞歸掃描全部可達對象。
三、併發預清理(Concurrent precleaning),這個階段和應用線程併發,就是想幫從新標記階段先作點工做,掃描一下卡表髒的區域和新晉升到老年代的對象等,由於從新標記是 STW 的,因此分擔一點。
四、可中斷的預清理階段(AbortablePreclean),這個和上一個階段基本上一致,就是爲了分擔從新標記標記的工做。
五、從新標記(remark),這個階段是 STW 的,由於併發階段引用關係會發生變化,因此要從新遍歷一遍新生代對象、Gc Roots、卡表等,來修正標記。
六、併發清理(Concurrent sweeping),這個階段和應用線程併發,用於清理垃圾。
七、併發重置(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的練級攻略」,更多硬核文章等你來閱,還有走心的20w字算法筆記、精心挑選的進階必備500本PDF。