JVM-垃圾收集入門

牛覓發表於2018-05-12

垃圾收集概述

對Java程式設計師而言不需要顯式地管理物件的生命週期:我們可以在需要時建立物件,物件不再被使用時,會被JVM在後臺自動進行回收。那為什麼我們還要去了解GC和記憶體分配?

答案很簡單:當需要排查各種記憶體溢位、記憶體洩露問題時或者當垃圾收整合為系統達到更高併發量的瓶頸時,就需要對這些“自動化”的技術實施必要的監控和調節。

垃圾收集器所關注的記憶體分配和回收的區域為Java堆和方法區。一個介面中的多個實現類需要的記憶體可能不一樣,一個方法中的多個分支需要的記憶體也可能不一樣,我們只有在程式處於執行期間時才能知道會建立那些物件。

簡單來說,垃圾收集由兩步構成:查詢不再使用的物件(垃圾物件),以及釋放這些物件所管理的記憶體。

查詢不再使用的物件

引用計數演算法

給物件中新增一個引用計數器,每當有一個地方引用它時,計數器值就加1;當引用失效時,計數器值就減1;任何時刻計數器為0的物件就是不可能再被使用的。

客觀地說,引用計數演算法的實現簡單,判定效率也很高。但是,至少主流的Java虛擬機器裡面沒有選用引用計數演算法來管理記憶體,其中最主要的原因是它很難解決物件之間相互迴圈引用的問題。

可達性分析演算法

在主流的商用程式語言(Java、C#,甚至包括前面提到的古老的Lisp)的主流實現中,都是稱通過可達性分析來判定物件是否存活的。這個演算法的基本思路就是 通過一系列的稱為“GC Roots”的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈(Reference Chain),當一個物件到GC Roots沒有任何引用鏈相連時,則證明此物件是不可用的,將會被判定為可回收的物件。

在Java語言中,可作為GC Roots的物件包括下面幾種:

  • 虛擬機器棧(棧幀中的本地變數表)中引用的物件。
  • 方法區中類靜態屬性引用的物件
  • 方法區中常量引用的物件
  • 本地方法棧中國中JNI(即一般說的Native方法)引用的物件

再談引用

無論通過哪種演算法查詢不再使用的物件,判定物件是否存活都與“引用”有關。在JDK 1.2以前,Java中的引用的定義很傳統:如果reference型別的資料中儲存的數值代表的是另外一塊記憶體的起始地址,就稱這塊記憶體代表著一個引用。這種定義很純粹,但是太過狹隘,一個物件在這種定義下只有被引用或者沒有被引用兩種狀態。

在JDK 1.2之後,Java對引用的概念進行了擴充,將引用分為強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)、虛引用(Phantom Reference)4鍾,這4鍾引用強度依次逐漸減弱。

  • 強引用就是指在程式程式碼之中普遍存在的,類似“Object obj = new Object()”這類的引用,只要強引用還存在,垃圾收集器永遠不會回收被引用的物件。
  • 軟引用是用來描述一些還有用但並非必需的物件。對於軟引用關聯著的物件,在系統將要發生記憶體溢位異常之前,將會把這些物件列進回收範圍之中進行第二次回收。如果這次回收還沒有足夠的記憶體,才會丟擲記憶體溢位異常。
  • 弱引用也是用來描述非必需物件的,但是它的強度比軟引用更弱一些,被弱引用關聯的物件只能生存到下一次垃圾收集發生之前。當垃圾收集器工作時,無論當前記憶體是否足夠,都會回收掉只被弱引用關聯的物件。
  • 虛引用也稱為幽靈引用或者幻影引用,它是最弱的一種引用關係。一個物件是否虛擬引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個物件例項。為一個物件設定虛引用關聯的唯一目的就是能在這個物件被收集器回收時收到一個系統通知。JDK 1.2之後,提供了PhantomReference類來實現虛引用。

回收過程

如果物件在進行可達性分析後發現沒有與GC Roots相連線的引用鏈,那它將會被第一次標記並且進行一次篩選,篩選的條件是此物件是否有必要執行finalize()方法。當物件沒有覆蓋finalize()方法,或者finalize()方法已經被虛擬機器呼叫過,虛擬機器將這兩種情況都視為“沒有必要執行”。

如果這個物件被判定為有必要執行finalize()方法,那麼這個物件將會放置在一個叫做F-Queue的佇列之中,並在稍後由一個虛擬機器自動建立的、低優先順序的Finalizer執行緒去執行它。finalize()方法是物件逃脫死亡命運的最後一次機會,稍後GC將對F-Queue中的物件進行第二次小規模的標記,如果物件要在finalize()中成功拯救自己,只要重新與引用鏈上的任何一個物件建立關聯即可,那在第二次標記時它將被移除出“即將回收”的集合;如果物件這時候還沒有逃脫,那基本上它就真的被回收了。

任何一個物件的finalize()方法都只會被系統自動呼叫一次,如果物件面臨下一次回收,它的finalize()不會被再次執行。

方法區回收

很多人認為方法區(或者Hotspot虛擬機器中的永久代)是沒有垃圾收集的,Java虛擬機器規範中不要求虛擬機器在方法區實現垃圾收集,而且在方法區中進行垃圾收集的“價效比”一般比較低:在堆中,尤其是在新生代中,常規應用進行一次垃圾收集一般可以回收70%~95%的空間,而永久代的垃圾收集效率遠低於此。

永久代的垃圾收集主要回收兩部分內容:廢棄常量和無用的類。回收廢棄常量與回收Java堆中的物件非常類似,而要判定一個類是否是“無用的類”的條件則相對苛刻許多。類需要同時滿足3個條件才能算是“無用的類”:

  • 該類所有的例項都已經被回收,也就是Java堆中不存在該類的任何例項。
  • 載入該類的ClassLoader已經被回收。
  • 該類對應的java.lang.Class物件沒有在任何地方被引用,無法再任何通過反射訪問該類的方法。

虛擬機器可以對滿足上述3個條件的無用類進行回收,這裡說的僅僅是“可以”,而並不是和物件一樣,不使用了就必然回收。是否對類進行回收,HotSpot虛擬機器提供了-Xnoclassgc引數進行控制。

在大量使用反射、動態代理、CGLib等ByteCode框架、動態生成JSP以及OSGi這類頻繁自定義ClassLoader的場景都需要虛擬機器具備類解除安裝的功能,以保證永久代不會溢位。

垃圾收集演算法

標記-清除演算法(Mark-Sweep)

最基礎的收集演算法是“標記-清除”演算法,該演算法分為“標記”和“清除”兩個階段:首先標記出所有需要回收的物件,在標記完成後統一回收所有被標記的物件。它的主要不足有兩個:

  • 效率問題:標記和清除兩個過程的效率都不高

  • 空間問題:標記清除之後會產生大量不連續的記憶體碎片,空間碎片太多可能會導致以後在程式執行過程中需要分配較大物件時,無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。標記—清除演算法的執行過程如下圖所示:

    標記—清除演算法的執行過程

複製演算法

為了解決效率問題,一種稱為“複製(Copying)”的收集演算法出現了,它將可用空間按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的記憶體用完了,就將還存活著的物件複製到另外一塊上面,然後把已使用過的記憶體空間一次清理掉。這樣使得每次都是對整個半區進行記憶體回收,記憶體分配時也就不用考慮記憶體碎片等複雜情況,只要移動堆頂指標,按順序分配記憶體即可,實現簡單,執行高效。這種演算法的代價是將記憶體縮小為原來的一半,未免太高了一點。複製算的執行過程如下圖所示:

複製演算法

現在的商業虛擬機器都採用這種收集演算法來回收新生代,IBM公司的專門研究表明,新生代的物件98%是“朝生夕死”的,所以不需要按照1:1的比例來劃分記憶體空間,而是將記憶體分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor。當回收時,將Eden和Survivor中還存活著的物件一次性地複製到另外一塊Survivor空間上,最後清理掉Eden和用過的Survivor空間。當Survivor空間不夠用時,需要依賴其他記憶體進行分配擔保。

標記-整理演算法

複製收集演算法在物件存活率較高時就要進行較多的複製操作,效率將會變低。所以在老年代一般不能直接選用這種演算法。

根據老年代的特點,提出了“標記-整理”(Mark-Compat)演算法,該演算法的標記過程仍然與“標記-清除”演算法一樣,但後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理端邊界以外的記憶體。"標記 - 整理"演算法的示意圖如下圖:

標記-整理演算法

分代收集演算法

當前商業虛擬機器的垃圾收集都採用“分代收集”演算法,該演算法根據物件存活週期的不同將記憶體劃分為幾塊,這樣就可以根據各個年代的特點採用最適當的收集演算法。一般是把Java堆分為"新生代(Young Generation)和"老年代(Old Generation或Tenured Generation)",新生代又被進一步劃分為不同區域,分別稱為Eden空間和Survivor空間。

在新生代中,每次垃圾收集時都發現有大批物件死去,只有少量存活,那就選用複製演算法,只需要付出少量存活物件的複製成本就可以完成收集。而老年代中因為物件存活率高、沒有額外空間對它進行分配擔保,就必須使用“標記-清理”或者“標記-整理”演算法來進行回收。

所有的垃圾收集演算法在新生代進行垃圾回收時都存在“時空停頓”現象。所有應用執行緒都停止執行所產生的停頓稱為時空停頓(stop-the-world)。通常這些停頓對應用的效能影響最大,調優垃圾收集時,儘量減少這種停頓是最為關鍵的考量因素。

Minor GC

新生代是堆的一部分,物件首先在新生代中分配。新生代填滿時,垃圾收集器會暫停所有的應用程式,回收新生代空間。不再使用的物件會被回收,仍然在使用的物件會被移動到其他地方。這種操作被稱為Minor GC。

Full GC

物件不斷地被移動到老年代,最終老年代也會被填滿,JVM需要找到老年代中不再使用的物件,並對它們進行回收。這個過程被稱為Full GC,通常導致應用程式長時間的停頓。

垃圾收集器

Java虛擬機器規範中對垃圾收集器應該如何實現並沒有任何規定,因此不同的廠商、不同版本的虛擬機器所提供的垃圾收集器都可能會有很大差別,並且一般都會提供引數供使用者根據自己的應用特點和要求組合出各個年代所使用的收集器。

img

Serial收集器

Serial收集器是最基本、發展歷史最悠久的收集器,在JDK 1.3之前是虛擬機器新生代收集的唯一選擇。該收集器使用單執行緒清理堆的內容。無論是進行Minor GC還是Full GC,在進行清理堆空間時,所有的應用執行緒都會被暫停(Stop The World),直到它收集結束。進行Full GC時,它還會對老年代空間的物件進行壓縮整理。

Serial收集器是虛擬機器執行在Client模式下的預設新生代收集器。也有著優於其他收集器的地方:

  • 簡單而高效(與其他收集器的單執行緒比)
  • 對於限定單個CPU的環境來說,Serial收集器由於沒有執行緒互動的開銷,專心做垃圾收集自然可以獲得最高的單執行緒收集效率。

ParNew收集器

ParNew收集器其實就是Serial收集器的多執行緒版本,除了使用多執行緒進行垃圾收集之外,其餘行為包括Serial收集器可用的所有控制引數、收集演算法、“Stop The World”、物件分配規則、回收策略等都與Serial收集器完全一樣,在實現上,這兩種收集器也共用了相當多的程式碼。

ParNew收集器是許多執行在Server模式下的虛擬機器中首選的新生代收集器,其中有一個與效能無關但很重要的原因是,除了Serial收集器外,目前只有它能與CMS收集器配合工作。CMS作為老年代的收集器,新生代只能選擇ParNew或者Serial收集器中的一個。ParNew收集器也是使用-XX:+UseConcMarkSweepGC選項後的預設新生代收集器,也可以使用-XX:+UseParNewGC選項來強制指定它。

ParNew收集器在單CPU的環境中絕對不會有比Serial收集器更好的效果,甚至由於存線上程互動的開銷,該收集器在通過超執行緒技術實現的兩個CPU的環境中都不能百分之百地保證可以超越Serial收集器。當然,隨著可以使用的CPU的數量的增加,它對於GC時系統資源的有效利用還是很有好處的。它預設開啟的收集執行緒數與CPU的數量相同,在CPU非常多的環境下,可以使用-XX:ParallelGCThreads引數來限制垃圾收集的執行緒數。

並行(Parallel):指多條垃圾收集執行緒並行工作,但此時使用者執行緒仍然處於等待狀態。

併發(Concurrent):指使用者執行緒與垃圾收集執行緒同時執行,使用者程式在繼續執行,而垃圾收集程式執行於另一個CPU上。

Parallel Scavenge收集器

Parallel Scavenge收集器是一個新生代收集器,也是使用複製演算法的收集器,又是並行的多執行緒收集器。但是Parallel Scavenge收集器的關注點與其他收集器不同,CMS等收集器的關注點是儘可能地縮短垃圾收集時使用者執行緒的停頓時間,而Parallel Scavenge收集器的目標則是達到一個可控制的吞吐量(Throughput)。所謂吞吐量就是CPU用於執行使用者程式碼的時間與CPU總消耗時間的比值,即吞吐量 = 執行使用者程式碼時間 / (執行使用者程式碼時間 + 垃圾收集時間),虛擬機器總共執行了100分鐘,其中垃圾收集花掉1分鐘,那吞吐量就是99%。

停頓時間越短就越適合需要與使用者互動的程式,良好的響應速度能提升使用者體驗,而高吞吐量則可以高效率地利用CPU時間,儘快完成程式的運算任務,主要適合在後臺運算任務,主要適合在後臺運算而不需要太多互動的任務。

Parallel Scavenge收集器提供了兩個引數用於精確控制吞吐量,分別是最大垃圾收集停頓時間的-XX:MaxGCPauseMillis引數以及直接設定吞吐量大小的-XX:GCTimeRatio引數。

  • MaxGCPauseMillis:MaxGCPauseMillis引數允許的值是一個大於0的毫秒數,收集器將盡可能地保證記憶體回收花費的時間不超過設定值。不要認為如果把這個引數的值設定得稍小一點就能使得系統的垃圾收集速度變得更快,GC停頓時間縮短是以犧牲吞吐量和新生代空間來換取的:系統把新生代調小一些,收集300MB新生代肯定比收集500MB快,這就導致垃圾收集發生得更頻繁一些,原來10秒收集一次、每次停頓100毫秒,現在變成5秒收集一次、每次停頓70毫秒。停頓時間的確在下降,但吞吐量也降下來了。
  • GCTimeRatio:GCTimeRatio引數的值應當是(0,100)之間的整數,預設值為99,是垃圾收集時間佔總時間的比率,相當於吞吐量的倒數。如果把此引數設定為19,那允許的最大GC時間就佔總時間的5%(即1 / (1+19))。

除了上述兩個引數之外,Parallel Scavenge收集器還有一個引數-XX:+UseAdaptiveSizePolicy值得關注。這是一個開關引數,當這個引數開啟之後,就不需要手工指定新生代的大小(-Xmn)、Eden與Survivor區的比例(-XX:SurvivorRatio)、晉升老年代物件年齡(-XX:PretenureSizeThreshold)等細節引數,虛擬機器會根據當前系統的執行情況收集效能監控資訊,動態調整這些引數以提供最合適的停頓時間或者最大的吞吐量,這種調節方式稱為GC自適應的調節策略。

Serial Old收集器

Serial Old是Serial收集器的老年代版本,同樣是一個單執行緒收集器,使用“標記-整理”演算法。這個收集器的主要意義也是在於給Client模式下的虛擬機器使用。如果在Server模式下 ,那麼它主要還有兩大用途:

  • 在JDK 1.5以及之前的版本中與Parallel Scavenge收集器搭配使用。
  • 作為CMS收集器的後備預案,在併發收集發生Concurrent Mode Failure時使用。

Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多執行緒和“標記-整理”演算法。這個收集器是在JDK 1.6中才開始提供的。在此之前,新生代的Parallel Scavenge收集器一直處於比較尷尬的狀態。原因是如果新生代選擇了Parallel Scavenge收集器,老年代除了Serial Old(PS MarkSweep)收集器外別無選擇。

Parallel Old收集器出現後,“吞吐量優先”收集器終於有了比較名副其實的應用組合,在注重吞吐量以及CPU資源敏感的場合,都可以優先考慮Parallel Scavenge加Parallel Old收集器。

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。CMS收集器是基於“標記-清除”演算法實現的,它的運作過程相對於前面幾種收集器來說更復雜一些,整個過程分為4個步驟:

  • 初始標記(CMS initial mark):標記一下GC Roots能直接關聯到的物件,速度很快。
  • 併發標記(CMS concurrent mark):進行GC Roots Tracing的過程
  • 重新標記(CMS remark):為了修正併發標記期間因使用者程式繼續運作而導致標記產生變動的那一部分物件的標記記錄,這個階段的停頓時間一般會比初始標記階段稍長一些,但遠比並發標記的時間短。
  • 併發清除(CMS concurrent sweep)

其中,初始標記、重新標記這兩個步驟仍然需要“Stop The World”。整個過程中耗時最長的併發標記和併發清楚過程收集器執行緒都可以與使用者執行緒一起工作,所以,從總體上來說,CMS收集器的記憶體回收過程是與使用者執行緒一起併發執行的。

CMS收集器也存在如下3個明顯的缺點:

  1. 對CPU資源非常敏感

    面向併發設計的程式都對CPU資源比較敏感。CMS收集器在併發階段,雖然不會導致使用者執行緒停頓,但會因為佔用了一部分執行緒(或者說CPU資源)而導致應用程式變慢,總吞吐量會降低。CMS預設啟動的回收執行緒數是(CPU數量 + 3)/ 4,也就是當CPU在4個以上時,併發回收時垃圾收集執行緒不小於25%的CPU資源,並且隨著CPU數量的增加而下降。當CPU不足4個(譬如2個)時,CMS對使用者程式的影響就可能變得很大,如果本來CPU負載就比較大,還分出一半的運算能力去執行收集器執行緒,就可能導致使用者程式的執行速度忽然降低了50%,其實也讓人無法接受。

  2. 無法處理浮動垃圾(Floating Garbage),可能出現“Concurrent Mode Failure”失敗而導致另一次Full GC的產生。

    CMS在併發清理階段使用者執行緒還需要執行著,伴隨程式執行自然就會有新的垃圾不斷產生,這一部分垃圾出現在標記過程之後,CMS無法在當次收集中處理掉它們,只好留待下一次GC時再清理掉。這一部分垃圾就稱為“浮動垃圾”。由於在垃圾收集階段使用者執行緒還需要執行,那就還需要預留有足夠的記憶體空間給使用者執行緒使用,因此CMS收集器不能其他收集器那樣等到老年代幾乎完全被填滿了在進行收集,需要預留一部分空間提供併發收集時的程式運作使用。

    如果CMS執行期間預留的記憶體無法滿足程式需要,就會出現一次“Concurrent Mode Failure”失敗,這時虛擬機器將啟動後備預案:臨時啟用Serial Old收集器來重新進行老年代的垃圾收集,這樣停頓時間就很長了。

    老年代使用了多少記憶體時才會觸發CMS收集器,是由引數:-XX:CMSInitiatingOccupancyFraction的值決定的。如果引數CMSInitiatingOccupancyFraction的值設定過高很容易導致大量“Concurrent Mode Failure”失敗,效能反而降低。

  3. 產生空間碎片

    CMS是一款基於“標記-清除”演算法實現的收集器,這意味著收集結束時會有大量空間碎片產生。空間碎片過多時,將會給大物件分配帶來很大麻煩,往往會出現老年代還有很大空間剩餘,但無法找到足夠大的連續空間來分配當前物件,不得不提前觸發一次Full GC。

    為了解決這個問題,CMS收集器提供了一個-XX:+UseCMSCompactAtFullCollection開發引數(預設開啟),用於在CMS收集器頂不住要進行FullGC時開啟記憶體碎片的合併整理過程,記憶體整理的過程是無法併發的,空間碎片問題沒有了,但停頓時間變長了。另外一個引數-XX:CMSFullGCsBeforeCompaction,這個引數用於設定執行多少次不壓縮的Full GC後,跟著來一次帶壓縮的(預設值為0,表示每次進入Full GC時都進行碎片整理)。

G1收集器

G1 GC,全稱Garbage-First Garbage Collector,通過-XX:+UseG1GC引數來啟用,在JDK 7u4版本發行時被正式推出。在JDK 9中,G1被提議設定為預設垃圾收集器(JEP 248)。

G1是一種伺服器端的垃圾收集器,G1收集器的設計目標是取代CMS收集器。與CMS相比,在以下方面表現的更出色:

  • G1是一個有整理記憶體過程的垃圾收集器,不會產生很多記憶體碎片。
  • G1的Stop The World(STW)更可控,G1在停頓時間上新增了預測機制,使用者可以指定期望停頓時間。

Region

使用G1收集器時,Java堆的記憶體佈局就與其他收集器有很大差別,它將整個Java堆劃分為多個大小相等的獨立區域(Region),雖然還保留有新生代和老年代的概念,但新生代和老年代不再是物理隔離的,它們都是一部分Region(不需要連續)的集合。

JVM-垃圾收集入門

在上圖中,注意到有一些Region標明瞭H,它代表Humongous,這表示這些Region儲存的是巨大物件(humongous object,H-obj),即大小大於等於region一半的物件。

可預測的停頓時間模型

G1跟蹤各個Region裡面的垃圾堆積的價值大小(回收所獲得的空間大小以及回收所需時間的經驗值),在後臺維護一個優先列表,每次根據允許的收集時間,優先回收價值最大Region(這也就是Garbage-First名稱的來由)。

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

Remembered Set

Java堆分為多個Region後,垃圾收集是否就真的能以Region為單位進行了?Region不可能是孤立的,一個物件分配在某個Region中,它並非只能被本Region中的其他物件引用,而是可以與整個Java堆任意的物件發生引用關係。那在做可達性判定確定物件是否存活的時候,豈不是還得掃描整個Java堆才能保證準備性。這個問題其實並非在G1中才有,只是在G1中更加突出而已。

在G1收集器中,Region之間的物件引用以及其他收集器中的新生代與老年代之間的物件引用,虛擬機器都是使用Remembered Set來避免全堆掃描的。G1中每個Region都有一個與之對應的Remembered Set,虛擬機器發現程式在對Reference型別的資料進行寫操作時,會產生一個Write Barrier暫時中斷寫操作,檢查Reference引用的物件是否處於不同的Region之中,如果是,便通過CardTable把相關引用資訊記錄到被引用物件所屬的Region的Remembered Set中。當進行記憶體回收時,在GC根節點的列舉範圍中加入Remembered Set即保證不對全堆掃描也不會有遺漏。

相關文章