JVM虛擬機之垃圾收集算法和垃圾收集器

前言

現在面試多少都會問到JVM虛擬機之類的問題,但是平時開發工作中又很少會用到這些知識,所以要想理解這些抽象的概念一方面需要多看多理解,一方面要在平時的開發工作中多往底層考慮一點,將知識串起來,形成思維導圖一樣的知識體系,一旦將知識碎片拼接起來,就會有一種豁然開朗的感覺。

這篇博客可以看做是《深入理解JAVA虛擬機》的學習筆記,加上一些個人的理解,學習需要整理輸出和應用,主動學習可以加深理解。

垃圾收集算法

常見的垃圾收集算法有標記清除算法複製收集算法標記整理算法分代收集算法

以下圖示中,藍色是存活對象,灰色是可回收內存,白色是未使用內存。

標記清除算法(Mark-Sweep)

算法分爲兩個階段:

  • 標記階段:標記出所有需要回收的對象
  • 清除階段:標記階段完成後統一回收所有被標記的對象

回收前狀態:
在這裏插入圖片描述
回收後狀態:
在這裏插入圖片描述
它是最基礎的垃圾收集算法,但是會有兩個明顯的問題:

  • 效率問題:標記和清除兩個過程的效率都不高
  • 空間問題:標記清除之後會產生大量的不連續內存碎片,空間碎片太多可能會導致以後在程序運行過程中需要分配較大對象時,無法找到足夠的連續內存而不得不提前觸發另一次收集動作。

複製收集算法(Copy-Collection)

爲了解決不連續空間碎片問題,出現了複製收集算法。
它將可用內存按容量劃分爲大小相等的兩塊,每次只使用其中的一塊。當這一塊內存使用完了,就將還存活的對象複製到另一塊中,然後再把已使用的一塊內存空間全部清理掉。
這樣使得每次都是對整個半區進行內存回收,內存分配時就不用考慮內存碎片等複雜情況,只要移動堆頂指針,按順序分配內存即可,實現簡單,運行高效。

回收前狀態:
在這裏插入圖片描述
回收後狀態:
在這裏插入圖片描述
這種算法的代價是將內存縮小爲原來的一半,在對象存活率較高時就要進行較多的複製操作,效率也會變低。
一般適用於對象存活率不高的場景

標記整理算法(Mark-Compact)

算法也分爲兩個階段:

  • 標記階段:標記出所有需要回收的對象
  • 整理階段:不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然後直接清理掉端邊界以外的內存

回收前狀態:
在這裏插入圖片描述
回收後狀態:
在這裏插入圖片描述
相當於在標記清除算法的基礎上進行了一次內存整理的操作。

分代收集算法(Generational-Collection)

當前虛擬機的垃圾收集都採用分代收集算法,這不是一種具體的算法,只是根據對象存活週期的不同,將內存劃分爲幾塊,這樣就可以根據各個塊對象的存活特點選擇合適的垃圾收集算法。
IBM公司的專門研究表明,新生代中的對象98%都是朝生夕死的,所以並不需要按照1:1的比例來劃分內存空間,而是將內存分爲一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor空間。當回收時,將Eden和Survivor中還存活着的對象一次性地複製到另一塊Survivor空間上,最後清理掉Eden和剛纔使用過的Survivor空間。
基於這些大量的研究數據,Java虛擬機將堆劃分爲新生代和老年代,在新生代中,每次垃圾收集時都有大量對象死亡,只有少量存活,就可以選用複製收集算法,只需要付出少量存活對象的複製成本就可以完成收集。
而老年代中因爲對象存活率高、沒有額外空間對它進行分配擔保,就必須使用標記清除算法或者標記整理算法。

注意,標記清除算法或者標記整理算法,比複製收集算法要慢10倍以上。

垃圾收集器

如是說垃圾收集算法是內存回收的方法論,那麼垃圾收集器就是內存回收的具體實現。
常見的垃圾收集器有7種:

Serial和Serial Old收集器

新生代使用複製收集算法,老年代使用標記整理算法

-XX:+UseSerialGC
-XX:+UseSerialOldGC

Serial(串行)收集器是最基本、歷史最悠久的垃圾收集器了。
顧名思義它是一個單線程收集器,但是單線程意義不僅僅是它只會使用一個CPU或一條收集線程去完成垃圾收集工作,更重要的是在它進行垃圾收集時,必須暫停其他所有的工作線程(Stop The World),直到它收集結束。
在這裏插入圖片描述
它優於其他收集器的地方:
簡單而高效,對於單個CPU機器來說,Serial收集器由於沒有線程交互的開銷,專心做垃圾收集工作,自然可以獲得最高的單線程收集效率。

Serial Old收集器是Serial收集器的老年代版本,它同樣是一個單線程收集器。
主要有兩大作用:

  • 在JDK1.5以及之前的版本中與Parallel Scavenge收集器搭配使用
  • 作爲CMS收集器的後備方案

ParNew收集器

新生代使用複製收集算法,老年代使用標記整理算法

-XX:+UseParNewGC

ParNew收集器其實就是Serial收集器的多線程版本,除了使用多線程進行垃圾收集外,其餘行爲 (控制參數、收集算法、回收策略等等)和Serial收集器完全一樣,這兩種收集器也共用了相當多的代碼。默認的收集線程數跟CPU核數相同,當然也可以用參數(-XX:ParallelGCThreads)指定收集線程數,但是一般不推薦修改。
在這裏插入圖片描述
ParNew收集器除了多線程收集之外,其他與Serial收集器相比並沒有太多創新之處,但它卻是許多運行在Server模式下的虛擬機中首選的新生代收集器,其中有一個與性能無關但很重要的原因是,除了Serial收集器外,目前只有它能與CMS收集器配合工作。

ParNew收集器在單CPU的環境中絕對不會有比Serial收集器更好的效果,甚至由於存在線程交互的開銷,該收集器在通過超線程技術實現的兩個CPU的環境中都不能百分之百地保證可以超越Serial收集器。
當然,隨着可以使用的CPU的數量的增加,它對於GC時系統資源的有效利用還是很有好處的。它默認開啓的收集線程數與CPU的數量相同。

Parallel Scavenge和Parallel Old收集器

新生代使用複製收集算法,老年代使用標記整理算法

-XX:+UseParallelGC
-XX:+UseParallelOldGC

Parallel Scavenge收集器是一個新生代收集器,類似於ParNew收集器,是並行的多線程收集器,是Server模式(內存大於2G,2個CPU)下的默認收集器。
在這裏插入圖片描述
Parallel Scavenge收集器的目標是達到一個可控制的吞吐量(Throughput)。所謂吞吐量就是CPU用於運行用戶代碼的時間與CPU總消耗時間的比值,即吞吐量 = 運行用戶代碼時間 /(運行用戶代碼時間 +垃圾收集時間),虛擬機總共運行了100分鐘,其中垃圾收集花掉1分鐘,那吞吐量就是99%。
Parallel Scavenge收集器提供了很多參數供用戶找到最合適的停頓時間或最大吞吐量

-XX:+UseAdaptiveSizePolicy

虛擬機會根據當前系統的運行情況收集性能監控信息,動態調整這些參數以提供最合適的停頓時間或者最大的吞吐量,這種調節方式稱爲GC自適應的調節策略(GC Ergonomics)。自適應調節策略也是Parallel Scavenge收集器與ParNew收集器的一個重要區別。
由於與吞吐量關係密切,Parallel Scavenge收集器也經常稱爲“吞吐量優先”收集器。

Parallel Old是Parallel Scavenge收集器的老年代版本,在注重吞吐量以及CPU資源敏感的場合,都可以優先考慮Parallel Scavenge加Parallel Old收集器。

CMS收集器

使用標記清除算法

-XX:+UseConcMarkSweepGC

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間爲目標的收集器。它非常符合在注重用戶體驗的應用上使用,它是HotSpot虛擬機第一款真正意義上的併發收集器, 它第一次實現了讓垃圾收集線程與用戶線程(基本上)同時工作。

CMS收集器收集過程

顧名思義,它是基於標記清除算法實現的,運作過程相比於前面幾種垃圾收集器來說更加複雜一些,整個過程主要分爲四個步驟:

  • 初始標記(CMS initial mark):暫停所有的用戶線程,記錄GC Roots能直接關聯到的對象,速度很快。
  • 併發標記(CMS concurrent mark):同時開啓用戶線程和GC線程,用一個閉包結構去記錄可達對象。但在這個階段結束,這個閉包結構並不能保證包含當前所有的可達對象。因爲用戶線程可能會不斷的更新引用域,所以GC線程無法保證可達性分析的實時性。所以這個算法裏會跟蹤記錄這些發生引用更新的地方(GC Roots Tracing)。
  • 重新標記(CMS remark):重新標記階段就是爲了修正併發標記期間因爲用戶程序繼續運行而導致標記產生變動的那一部分對象的標記記錄,這個階段的停頓時間一般會比初始標記階段的時間稍長,遠遠比並發標記階段時間短。
  • 併發清除(CMS concurrent sweep):開啓用戶線程,同時GC線程開始對未標記的區域做清掃。
  • 併發重置(CMS concurrent reset):對標記的區域進行重置。

在這裏插入圖片描述

由於整個過程中耗時最長的併發標記和併發清除過程收集器線程都可以與用戶線程一起工作,所以,從總體上來說,CMS收集器的內存回收過程是與用戶線程一起併發執行的。

CMS收集器優缺點

CMS是一款優秀的收集器,它的主要優點在名字上已經體現出來了:併發收集、低停頓。
但是也有4個明顯的缺點:

  • CMS收集器對CPU資源非常敏感,會和服務搶資源
  • CMS收集器無法處理浮動垃圾(Floating Garbage),在併發清理階段又產生的垃圾,這種浮動垃圾只能等到下一次GC再清理了
  • 執行過程中的不確定性,會存在上一次垃圾回收還沒執行完,然後垃圾回收又被觸發的情況,特別是在併發標記和併發清理階段會出現,一邊回收,系統一邊運行,也許沒回收完就再次觸發Full GC,也就是concurrent mode failure,此時會進入Stop The World,用Serial Old垃圾收集器來回收
  • CMS是一款基於標記清除算法實現的收集器,收集結束時會有大量空間碎片產生

空間碎片過多時,將會給大對象分配帶來很大麻煩,往往會出現老年代還有很大空間剩餘,但是無法找到足夠大的連續空間來分配當前對象,不得不提前觸發一次Full GC。

可以通過參數

-XX:+UseCMSCompactAtFullCollection

讓JVM在執行完標記清除後再做一次內存整理。

CMS收集器參數

-XX:+UseConcMarkSweepGC:啓用CMS收集器
-XX:ConcGCThreads:併發的GC線程數
-XX:+UseCMSCompactAtFullCollection:Full GC之後做壓縮整理(減少碎片)
-XX:CMSFullGCsBeforeCompaction:多少次Full GC之後壓縮一次,默認是0,代表每次Full GC後都會壓縮一次
-XX:CMSInitiatingOccupancyFraction:當老年代使用達到該比例時會觸發Full GC(默認是92,這是百分比)
-XX:+UseCMSInitiatingOccupancyOnly:只使用設定的回收閾值(-XX:CMSInitiatingOccupancyFraction設定的值),如果不指定,JVM僅在第一次使用設定值,後續則會自動調整
-XX:+CMSScavengeBeforeRemark:在CMS GC前啓動一次Minor GC,目的在於減少老年代對年輕代的引用,降低CMS GC的標記階段時的開銷,一般CMS的GC耗時80%都在重新標記階段

G1收集器

新生代和老年代都使用複製收集算法

-XX:+UseG1GC

G1收集器將Java堆劃分爲多個大小相等的獨立區域(Region),JVM最多可以有2048個獨立區域。
一般Region大小等於堆大小除以2048,比如堆大小爲4096M,則Region大小爲2M,當然也可以用參數-XX:G1HeapRegionSize手動指定Region大小,但是推薦默認的計算方式。 G1保留了年輕代和老年代的概念,但不再是物理隔閡了,它們都是(可以不連續)Region的集合。
在這裏插入圖片描述
默認年輕代對堆內存的佔比是5%,如果堆大小爲4096M,那麼年輕代佔據200MB左右的內存,對應大概是100個Region,可以通過-XX:G1NewSizePercent設置年輕代初始佔比,在系統運行中,JVM會不停的給年輕代增加更多的Region,但是年輕代最多的佔比不會超過60%,可以通過-XX:G1MaxNewSizePercent調整。年輕代中的Eden和Survivor對應的Region也跟之前一樣,默認8:1:1,假設年輕代現在有1000個Region,Eden區對應800個,S0對應100個,S1對應100個。

一個Region可能之前是年輕代,如果Region進行了垃圾回收,之後可能又會變成老年代,也就是說Region的區域功能可能會動態變化。

G1垃圾收集器對於對象什麼時候會轉移到老年代跟之前講過的原則一樣,唯一不同的是對大對象的處理,G1有專門分配大對象的Region叫Humongous區,而不是讓大對象直接進入老年代的Region中。在G1中,大對象的判定規則就是一個大對象超過了一個Region大小的50%,比如按照上面算的,每個Region是2M,只要一個大對象超過了1M,就會被放入Humongous中,而且一個大對象如果太大,可能會橫跨多個Region來存放。

Humongous區專門存放短期巨型對象,不用直接進老年代,可以節約老年代的空間,避免因爲老年代空間不夠的GC開銷。

Full GC的時候除了收集年輕代和老年代之外,也會將Humongous區一併回收。

G1收集器收集過程

G1收集器一次Mixed GC的運作過程大致分爲以下幾個步驟:

  • 初始標記(Initial Marking):暫停所有的用戶線程,記錄GC Roots能直接關聯到的對象,並且修改TAMS(Next Top at Mark Start)的值,讓下一階段用戶程序併發運行時,能在正確可用的Region中創建新對象,這階段需要停頓線程,但耗時很短。
  • 併發標記(Concurrent Marking):從GC Root開始對堆中對象進行可達性分析,找出存活的對象,這階段耗時較長,但可與用戶程序併發執行。
  • 最終標記(Final Marking):修正併發標記期間因爲用戶程序繼續運行而導致標記產生變動的那一部分對象的標記記錄,虛擬機將這段時間對象變化記錄在線程Remembered Set Logs裏面。最終標記階段需要把Remembered Set Logs的數據合併到Remembered Set中,這階段需要停頓線程,但是可並行執行。
  • 篩選回收(Live Data Counting and Evacuation):首先對各個Region的回收價值和成本進行排序,根據用戶所期望的GC停頓時間(可以使用JVM參數-XX:MaxGCPauseMillis指定)來制定回收計劃。這個階段其實也可以做到與用戶程序一起併發執行,但是因爲只回收一部分Region,時間是用戶可控制的,而且停頓用戶線程將大幅提高收集效率。

在這裏插入圖片描述
比如說老年代此時有1000個Region都滿了,但是因爲根據預期停頓時間,本 次垃圾回收可能只能停頓200毫秒,那麼通過之前回收成本計算得知,可能回收其中800個 Region剛好需要200ms,那麼就只會回收800個Region,儘量把GC導致的停頓時間控制在 我們指定的範圍內。

G1收集器之所以能建立可預測的停頓時間模型,是因爲它可以有計劃地避免在整個Java堆中進行全區域的垃圾收集。

G1跟蹤各個Region裏面的垃圾堆積的價值大小(回收所獲得的空間大小以及回收所需時間的經驗值),在後臺維護一個優先列表,每次根據允許的收集時間,優先回收價值最大的Region(這也就是Garbage-First名稱的來由)。比如一個Region花200ms能回收10M垃圾,另外一個Region花50ms能回收20M垃圾,在回收時間有限情況下,G1當然會優先選擇後面這個Region進行回收。

這種使用Region劃分內存空間以及有優先級的區域回收方式,保證了G1收集器在有限的時間內可以獲取儘可能高的收集效率。

在G1收集器中,Region之間的對象引用以及其他收集器中的新生代與老年代之間的對象引用,虛擬機都是使用Remembered Set來避免全堆掃描的。

G1收集器收集分類

Young GC

Young GC並不是說現有的Eden區放滿了就會馬上觸發,而且G1會計算下現在Eden區回收大概要多久時間,如果回收時間遠遠小於參數-XX:MaxGCPauseMills設定的值,那麼會增加年輕代的Region,繼續給新對象存放,不會馬上做Young GC,直到下一次Eden區放滿,G1計算回收時間接近參數-XX:MaxGCPauseMills設定的值,那麼就會觸發Young GC。

Mixed GC

不是Full GC,老年代的堆佔有率達到參數(-XX:InitiatingHeapOccupancyPercen)設定的值則觸發,回收所有的年輕代和部分老年代(根據期望的GC停頓時間確定老年代垃圾收集的優先順序)以及大對象區,正常情況G1的垃圾收集是先做MixedGC,主要使用複製算法,需要把各個Region中存活的對象拷貝到別的Region裏去,拷貝過程中如果發現沒有足夠的空Region能夠承載拷貝對象就會觸發一次Full GC。

Full GC

停止系統程序,然後採用單線程進行標記、清理和壓縮整理,好空閒出來一批Region來供下一次Mixed GC使用,這個過程非常耗時。

G1收集器優化建議

假設參數-XX:MaxGCPauseMills設置的值很大,導致系統運行很久,年輕代可能都佔用了堆內存的60%了,此時才觸發年輕代GC,那麼存活下來的對象可能就會很多,此時就會導致Survivor區域放不下那麼多的對象,就會進入老年代中;或者是年輕代GC過後,存活下來的對象過多,導致進入Survivor區域後觸發了動態年齡判定規則,達到了Survivor區域的50%,也會快速導致一些對象進入老年代中。
所以這裏核心還是在於調節-XX:MaxGCPauseMills這個參數的值,在保證年輕代GC別太頻繁的同時,還得考慮每次GC過後的存活對象有多少,避免存活對象太多快速進入老年代,頻繁觸發Mixed GC。

G1收集器特點

G1收集器被視爲JDK1.7以上版本Java虛擬機的一個重要進化特徵。它具備以下特點:

  • 並行與併發:G1能充分利用CPU、多核環境下的硬件優勢,使用多個CPU(CPU或者
    CPU核心)來縮短Stop The World停頓時間。部分其他收集器原本需要停頓Java線程來執行GC動作,G1收集器仍然可以通過併發的方式讓Java程序繼續執行。
  • 分代收集:雖然G1可以不需要其他收集器配合就能獨立管理整個GC堆,但是還是保留了分代的概念。
  • 空間整合:與CMS的標記清除算法不同,G1從整體來看是基於標記整理算法 實現的收集器,從局部上來看是基於複製收集算法實現的。
  • 可預測的停頓:這是G1相對於CMS的另一個大優勢,降低停頓時間是G1和 CMS共同的關注點,但G1除了追求低停頓外,還能建立可預測的停頓時間模型,能讓使用者明確指定在一個長度爲M毫秒的時間片段(通過參數-XX:MaxGCPauseMillis指定)內完成垃圾收集。

G1收集器參數

-XX:+UseG1GC:開啓G1收集器
-XX:ParallelGCThreads:指定GC工作的線程數量 
-XX:G1HeapRegionSize:指定分區大小(1MB~32MB,且必須是2的冪),默認將整堆劃分爲2048個分區
-XX:MaxGCPauseMillis(默認200ms):目標暫停時間 
-XX:G1NewSizePercent(默認整堆5%):年輕代內存初始空間
-XX:G1MaxNewSizePercent:年輕代內存最大空間 
-XX:TargetSurvivorRatio(默認50%):Survivor區的填充容量,Survivor區域裏的一批對象(年齡1+年齡2+...+年齡n的多個年齡對象)總和超過了Survivor區域的50%,此時就會把年齡n()以上的對象都放入老年代
-XX:MaxTenuringThreshold(默認15):最大年齡閾值
-XX:InitiatingHeapOccupancyPercent(默認45%):老年代佔用空間達到整堆內存閾值,則執行新生代和老年代的混合收集(MixedGC),比如我們之前說的堆默認有2048個Region,如果有接近1000個Region都是老年代的Region,則可能就要觸發MixedGC了
-XX:G1HeapWastePercent(默認5%): GC過程中空出來的Region是否充足的閾值,在混合回收的時候,對Region回收都是基於複製算法進行的,都是把要回收的Region裏的存活對象放入其他Region,然後這個Region中的垃圾對象全部清理掉,這樣的話在回收過程就會不斷空出來新的Region,一旦空閒出來的Region數量達到了堆內存的5%,此時就會立即停止混合回收,意味着本次混合回收就結束了。
-XX:G1MixedGCLiveThresholdPercent(默認85%):Region中的存活對象低於這個值時纔會回收該Region,如果超過這個值,存活對象過多,回收的的意義不大。
-XX:G1MixedGCCountTarget(默認8):在一次回收過程中指定做幾次篩選回收,在最後篩選回收階段可以回收一會,然後暫停回收,恢復系統運行,一會再開始回收,這樣可以讓系統不至於單次停頓時間過長。

如何選擇垃圾收集器

  • 優先調整堆的大小讓服務器自己來選擇
  • 如果內存小於100M,使用串行收集器
  • 如果是單核,並且沒有停頓時間的要求,使用串行或讓JVM選擇
  • 如果允許停頓時間超過1秒,選擇並行或者JVM選擇
  • 如果響應時間最重要,並且不能超過1秒,使用併發收集器

總結

在這裏插入圖片描述
連線表示可以搭配使用,官方推薦使用G1收集器,因爲其性能高,但是沒有最好的收集器,也沒有萬能的收集器,我們能做的就是根據具體應用場景選擇適合的垃圾收集器,適合的就是最好的。

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