JVM分代垃圾回收機制和垃圾回收算法
一、什么是GC
GC (Garbage Collection)垃圾回收,顧名思義就是專門回收垃圾的。,在C/C++中,我們需要用到內存的時候,需要先手動申明一下,使用完后又需要在手動回收一下,這兩部非常麻煩而且還經常會出這個方面的問題。而這一切在Java中就已經被自動執行掉了,所以我們寫代碼的時候都不用再管這些無效的數據。
二、GC分類
在目前主流的虛擬機中,大多都是根據分代收集的理論來進行設計的。因為在虛擬機中絕大部分的對象都是朝生夕死的,而熬過了多次的垃圾回收后的對象就越難被回收。所以前面的理論堆就被劃分成了兩個區域,新生代和老年代,前者主要存儲那些朝生夕死的對象,后者存放難死的對象。
1、 新生代回收(Minor GC/Young GC):指只是進行新生代的回收。
2、老年代回收(Major GC/Old GC):指只是進行老年代的回收。目前只有 CMS 垃圾回收器會有這個單獨的回收老年代的行為。 (Major GC 定義是比較混亂,有說指是老年代,有的說是做整個堆的收集,這個需要你根據別人的場景來定,沒有固定的說法)
3、整堆回收(Full GC):收集整個 Java 堆和方法區(注意包含方法區)
三、垃圾回收算法
1、復制算法(Copying)
將一塊內存區域進行對半分,當有一半的內存使用完時將還存活的對象放到另一半內存區域中,原來的內存區域進行回收,不用考慮內存碎片區域,只要按順序分配內存就行。實現簡單,運行高效。
但是這樣也有個缺點就是對內存的利用率只有50%,于是在JVM中就有了以下的解決辦法:
Appel式回收
Eden區的添加,一般來說的內存區域的分配為:Eden:80%,Survivor:20%(From 10%,To 10%),當Survivor區不夠用的時候,就需要老年代進行分配擔保。
2、標記-清除法(Mark-Sweep)
算法分為“標記”和“清理”兩個階段:第一步掃描需要標記所有可以被回收的對象,第二遍掃描需要清理被第一步標記的對象,效率略低。因為需要大量的標記對象和清除所以回收效率是不復制算法的,如果大部分的對象是朝生夕死的那么標記的對象就會更多,效率會更低。
它還有個主要問題就是會產生大量的內存碎片導致大對象無法進行存儲,從而不得不提前觸發其他的垃圾回收。
3、標記-整理法(Mark-Compact )
步驟與清除法步驟一致但是,它的第二步是整理標記之外的所有對象,將所有對象向前移動之后直接清除掉這些對象所在之外的內存區域。標記法不會存在內存碎片,但是效率是遍低的。
整理法和清除法的主要區別就是一個是回收對象,一個整理對象,而移動對象還會需要暫停所有的業務線程后更新所有對象的引用(直接指針需要調整)。
四、JVM垃圾回收器
1、Serial/Serial Old
JVM誕生初期所采用的垃圾回收器,單線程,獨占式,適合單CPU。
它只適合堆內存幾十兆到幾百兆,如果超過的這個內存的大小則會大大的降低回收效率,所以在目前很雞肋。
Stop The World(STW)
單線程進行垃圾回收時,必須暫停所有的工作線程,直到它回收結束。這個暫停稱之為“Stop The World”,但是這種 STW 帶來了惡劣的用戶體驗,例如:應用每運行一個小時就需要暫停響應 5 分。這個也是早期 JVM 和 java 被 C/C++ 語言詬病性能差的一個重要原因。所以 JVM 開發團隊一直努力消除或降低 STW 的時間。
2、Parallel/Parallel Old
為了提高JVM的回收效率,從JDK 1.3開始,JVM使用了多線程的垃圾回收器,關注吞吐量的垃圾回收器,可以更高效的利用CPU時間,從而盡快完成程序的運算任務。
所謂吞吐量就是 CPU 用于運行用戶代碼的時間與 CPU 總消耗時間的比值,即吞吐量=運行用戶代碼時間/(運行用戶代碼時間+垃圾收集時間),虛擬機總 共運行了 100 分鐘,其中垃圾收集花掉 1 分鐘,那吞吐量就是 99%。
該垃圾回收器適合回收堆空間上百兆~幾個G。
JVM參數設置
JDK1.8 默認就是以下組合
-XX:+UseParallelGC 新生代使用 Parallel Scavenge,老年代使用 Parallel Old
-XX:MaxGCPauseMillis
不過不要異想天開地認為如果把這個參數的值設置得更小一點就能使得系統的垃圾收集速度變得更快,垃圾收集停頓時間縮短是以犧牲吞吐 量和新生代空間為代價換取的:系統把新生代調得小一些,收集 300MB 新生代肯定比收集 500MB 快,但這也直接導致垃圾收集發生得更頻繁,原來 10 秒收集一次、每次停頓 100 毫秒,現在變成 5 秒收集一次、 每次停頓 70 毫秒。停頓時間的確在下降,但吞吐量也降下來了。
-XX:GCTimeRatio
-XX:GCTimeRatio 參數的值則應當是一個大于 0 小于 100 的整數,也就是垃圾收集時間占總時間的比率,相當于吞吐量的倒數。
例如:把此參數設置為 19, 那允許的最大垃圾收集時占用總時間的 5% (即 1/(1+19)), 默認值為 99,即允許最大 1% (即 1/(1+99))的垃圾收集時間由于與吞吐量關系密切,ParallelScavenge 是“吞吐量優先垃圾回收器”。
-XX:+UseAdaptiveSizePolicy
-XX:+UseAdaptiveSizePolicy (默認開啟)。這是一個開關參數, 當這個參數被激活之后,就不需要人工指定新生代的大小(-Xmn)、Eden 與 Survivor 區的比例(-XX:SurvivorRatio)、 晉升老年代對象大小(-XX:PretenureSizeThreshold)等細節參數了,虛擬機會根據當前系統的運行情況收集性能監控信息,動態調整這些參數以提供最合適的停頓時間或者最大的吞吐量。
3、ParNew/CMS
ParNew
多線的垃圾回收器與Parallel差不多,唯一的區別:多線程,多 CPU 的,停頓時間比 Serial 少。(在 JDK9 以后,把 ParNew 合并到了 CMS 了) 。
Concurrent Mark Sweep(CMS)
此類垃圾回收器是追求最短的回收停頓時間(STW)為目標的。目前還有是很大一部分的 Java 應用集中在互聯網或者 B/S 系統的服務端上,這類應用比較重視服務的響應速度,希望停頓時間更短以提升用戶的體驗。
Mark Sweep 從名字上可以看出來,這個回收器采用的是標記 - 清除法。而它的步驟比起前面的幾個回收器都更麻煩些。
整體過程分為 4 個步驟:
初始標記:只標記與 GC Root 有直接關聯的對象,這類的對象比較少,標記快。
并發標記:標記與初始化標記的對象有關聯的所有對象,這類的對象比較多所以采用的并發,與用戶線程一起跑。
重新標記:修正那些并發標記時候標記產生異動的對象標記,這塊的時間比初始標記稍長一些,但是比起并發標記要快很多。
并發清除:與用戶線程一起運行,進行對象回收。
-XX:+UseConcMarkSweepGC ,表示新生代使用 ParNew,老年代的用 CMS。
缺點:
CPU敏感:因為采用的并發的技術所以對處理器的核心要求較大。
浮動垃圾:在CMS進行并發清楚的時候因為采用的是并發的輕快,所以在清除的時候用戶線程會產出新的垃圾。
因此在進行回收的時候需要預留一部分的空間來存放這些產生垃圾(JDK 1.6 設置的閾值為92%)。
但是如果用戶線程產出的垃圾比較快,預留內存放不下的時候就會出現 Concurrent Mode Failure,這時虛擬機將臨時啟用 Serial Old 來替代 CMS。
內存碎片:因為采用的是 標記 - 清除 法所以會產生內存碎片。
特點:
總體來說因為 CMS 是 JVM 產生的第一個并發垃圾收集器,所以還是具有代表性的。為什么采用 標記 - 清除 法,因為在實現 CMS 的時候如果還整理對象的話,那么需要再暫停業務線程,進行一個對象的整理那么 STW 的時間會更長,為了追求 STW 的時間所以沒有采用 標記 - 整理。
但是最大的問題是 CMS 采用了標記清除算法,所以會有內存碎片,當碎片較多時,給大對象的分配帶來很大的麻煩,為了解決這個問題,CMS 提供一個 參數:-XX:+UseCMSCompactAtFullCollection,一般是開啟的,如果分配不了大對象,就進行內存碎片的整理過程。 這個地方一般會使用 Serial Old ,因為 Serial Old 是一個單線程,所以如果內存空間很大、且對象較多時,CMS 發生這樣情況會很卡。
該垃圾回收器適合回收堆空間幾個 G~ 20G 左右。
4、 Garbage First (G1)
G1 垃圾回收器的設計思想與前面所有的垃圾回收器的都不一樣,前面垃圾回收器采用的都是 分代劃分 的方式進行設計的,而 G1 則是將堆看作是一個整體的區域,這個區域被劃分成了一個個大小一致的獨立區域(Region),而每個區域都可以根據需要扮演Eden、Survivor以及老年代區域。當進行對象回收的時候就可以根據每個區域的情況進行一個回收,從而效率。
Region
上面講到除了每個Region可以扮演不同的區域,還有一個類似老年代的區域 Humongous 區域,用來專門存放大對象的。當一個對象超過了Region區空間的一半大小則判定為大對象。(每個 Region 的大小可以通過參數-XX:G1HeapRegionSize 設定,取值范圍為 1MB~32MB,且應為 2 的 N 次 冪。)
而對于那些超過了整個 Region 容量的超級大對象,將會被存放在 N 個連續的 Humongous Region 之中,G1 的進行回收大多數情況下都把 Humongous Region 作為老年代的一部分來進行看待。
開啟參數 :-XX:+UseG1GC `
分區大小:-XX:+G1HeapRegionSize
一般建議逐漸增大該值,隨著 size 增加,垃圾的存活時間更長,GC 間隔更長,但每次 GC 的時間也會更長。
最大 GC 暫停時間 :-XX:MaxGCPauseMillis
運行過程
G1 的運作過程大致可劃分為以下四個步驟:
初始標記 (Initial Marking) :標記與 GC Roots 能關聯到的對象,修改 TAMS 指針的值,這個過程是需要暫停用戶線程的,但是耗時非常的短。
TAMS (Top at Mark Start):當進行下一步并發標記的時候用戶線程是會產生新的對象的,而這些對象是被判定為可存活對象而非垃圾。這個時候就需要劃分一小塊區域來存放這這些對象。
并發標記 (Concurrent Marking):進行掃描標記所有課回收的對象。當掃描完成后,并發會有引用變化的對象,而這些對象會漏標這些漏標的對象會被 SATB 算法所解決。
SATB(snapshot-at-the-beginning):類似快照,對當前區域進行一個快照的保存,之后再最終標記的時候進行對比查看漏標的會被重新標記上(后面的文章會詳解)。
最終標記 (Final Marking): 暫停所有的用戶線程,對之前漏標的對象進行一個標記。
篩選回收( Live Data Counting and Evacuation):更新Region的統計數據,對各個 Region 的回收價值進行一個排序,根據用戶所設置的停頓時間制定一個回收計劃,自由選擇任意個 Region 進行回收。將需要回收的Region 復制到空的 Region 區域中,再清除掉原來的整個Region區域。這塊還涉及到對象的移動所以需要暫停所有的用戶線程,有多條回收器線程進行完成。
特點:
并行與并發:G1 能充分利用多 CPU、多核環境下的硬件優勢,使用多個 CPU(CPU 或者 CPU 核心)來縮短 Stop-The-World 停頓的時間,部分其他收集器
原本需要停頓 Java 線程執行的 GC 動作,G1 收集器仍然可以通過并發的方式讓 Java 程序繼續執行。
分代收集:與其他收集器一樣,分代概念在 G1 中依然得以保留。雖然 G1 可以不需要其他收集器配合就能獨立管理整個 GC 堆,但它能夠采用不同的方式
去處理新創建的對象和已經存活了一段時間、熬過多次 GC 的舊對象以獲取更好的收集效果。
空間整合:與 CMS 的“標記—清理”算法不同,G1 從整體來看是基于“標記—整理”算法實現的收集器,從局部(兩個 Region 之間)上來看是基于“復制”算法實現的,但無論如何,這兩種算法都意味著 G1 運作期間不會產生內存空間碎片,收集后能提供規整的可用內存。這種特性有利于程序長時間運行,分配大對象時不會因為無法找到連續內存空間而提前觸發下一次 GC。
追求停頓時間:-XX:MaxGCPauseMillis 指定目標的最大停頓時間,G1 嘗試調整新生代和老年代的比例,堆大小,晉升年齡來達到這個目標時間。
該垃圾回收器適合回收堆空間上百 G。一般在 G1 和 CMS 中間選擇的話平衡點在 6~8G,只有內存比較大 G1 才能發揮優勢