JVM調優之垃圾定位、垃圾回收演算法、垃圾處理器對比

等不到的口琴發表於2021-02-03

談垃圾回收器之前,要先講講垃圾回收演算法,以及JVM對垃圾的認定策略,JVM垃圾回收器是垃圾回收演算法的具體實現,瞭解了前面的前置知識,有利於對垃圾回收器的理解。

什麼是垃圾?

垃圾,主要是指堆上的物件,那麼如何確定這些物件是可以被回收的呢?

大概思路就是,如果一個物件永遠不可能被訪問到,那麼就是垃圾,可以被回收瞭如何確定物件永遠不會被使用呢?

引用計數法

在物件中新增一個引用計數器,每當有一個地方引用它時,計數器值就加一;當引用失效時,計數器值就減一;任何時刻計數器為零的物件就是不可能再被使用的。但是,在Java領域,至少主流的Java虛擬機器裡面都沒有選用引用計數演算法來管理記憶體,主要原因是,這個看似簡單的演算法有很多例外情況要考慮,必須要配合大量額外處理才能保證正確地工作,譬如單純的引用計數就很難解決物件之間相互迴圈引用的問題。

如圖,每一個物件的引用都是1,構成了迴圈引用,但是並不能被其他物件訪問,這兩個物件再無任何引用,引用計數演算法也就無法回收它們。

程式碼驗證:

package com.courage;
public class ReferenceCountingGC {
    public Object instance = null;
    private static final int _1MB = 1024 * 1024;
    /**
     * 這個成員屬性的唯一意義就是佔點記憶體,以便能在GC日誌中看清楚是否有回收過
     */
    private byte[] bigSize = new byte[5* _1MB];
    public static void testGC() {
        //5 M
        ReferenceCountingGC objA = new ReferenceCountingGC();
        //5 M
        ReferenceCountingGC objB = new ReferenceCountingGC();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null;
// 假設在這行發生GC,objA和objB是否能被回收?
        System.gc();
    }
    public static void main(String[] args) {
        testGC();
    }
}

執行結果:

[0.004s][warning][gc] -XX:+PrintGCDetails is deprecated. Will use -Xlog:gc* instead.
[0.012s][info   ][gc,heap] Heap region size: 1M
[0.015s][info   ][gc     ] Using G1
[0.015s][info   ][gc,heap,coops] Heap address: 0x0000000701000000, size: 4080 MB, Compressed Oops mode: Zero based, Oop shift amount: 3
......
[0.119s][info   ][gc,metaspace   ] GC(0) Metaspace: 805K->805K(1056768K)
[0.119s][info   ][gc             ] GC(0) Pause Full (System.gc()) 14M->0M(8M) 2.886ms
[0.119s][info   ][gc,cpu         ] GC(0) User=0.03s Sys=0.00s Real=0.00s
[0.120s][info   ][gc,heap,exit   ] Heap
......

為了篇幅,我將部分列印內容省略了,可見System.gc()後記憶體佔用由14M->0M,將物件這10M釋放了。也就是JVM裡面並沒使用引用計數法來標記垃圾。

根可達演算法

這個演算法的基本思路就是通過一系列稱為“GC Roots”的根物件作為起始節點集,從這些節點開始,根據引用關係向下搜尋,搜尋過程所走過的路徑稱為“引用鏈”(Reference Chain),如果某個物件到GC Roots間沒有任何引用鏈相連,或者用圖論的話來說就是從GC Roots到這個物件不可達時,則證明此物件是不可能再被使用的。

在Java技術體系裡面,固定可作為GC Roots的物件包括以下幾種:

  1. 在虛擬機器棧(棧幀中的本地變數表)中引用的物件,譬如各個執行緒被呼叫的方法堆疊中使用到的
    引數、區域性變數、臨時變數等在方法區中類靜態屬性引用的物件,譬如Java類的引用型別靜態變數。
  2. 在方法區中常量引用的物件,譬如字串常量池(String Table)裡的引用。
  3. 在本地方法棧中JNI(即通常所說的Native方法)引用的物件。
  4. Java虛擬機器內部的引用,如基本資料型別對應的Class物件,一些常駐的異常物件(比如
    NullPointExcepiton、OutOfMemoryError)等,還有系統類載入器。
  5. 所有被同步鎖(synchronized關鍵字)持有的物件。
  6. 反映Java虛擬機器內部情況的JMXBean、JVMTI中註冊的回撥、原生程式碼快取等。

垃圾回收演算法

本文介紹了常見的三種垃圾回收演算法(mark-sweep,mark-compact,mark-copy),是java虛擬機器各種垃圾收集器的演算法基礎。

垃圾回收演算法思想

當前商業虛擬機器的垃圾收集器,大多數都遵循了“分代收集”(Generational Collection)的理論進行設計,分代收集名為理論,實質是一套符合大多數程式執行實際情況的經驗法則,它建立在兩個分代假說之上:
1)弱分代假說(Weak Generational Hypothesis):絕大多數物件都是朝生夕滅的。
2)強分代假說(Strong Generational Hypothesis):熬過越多次垃圾收集過程的物件就越難以消亡。

這兩個分代假說共同奠定了多款常用的垃圾收集器的一致的設計原則:收集器應該將Java堆劃分出不同的區域,然後將回收物件依據其年齡(年齡即物件熬過垃圾收集過程的次數)分配到不同的區域之中儲存顯而易見,如果一個區域中大多數物件都是朝生夕滅,難以熬過垃圾收集過程的話,那麼把它們集中放在一起,每次回收時只關注如何保留少量存活而不是去標記那些大量將要被回收的物件,就能以較低代價回收到大量的空間;

如果剩下的都是難以消亡的物件,那把它們集中放在一塊,虛擬機器便可以使用較低的頻率來回收這個區域,這就同時兼顧了垃圾收集的時間開銷和記憶體的空間有效利用。

標記-清除演算法 Mark-Sweep

演算法分為“標記”和“清除”兩個階段:首先標記出所有需要回收的物件,在標記完成後,統一回收掉所有被標記的物件,也可以反過來,標記存活的物件,統一回收所有未被標記的物件。

它的主要缺點有兩個:

第一個是執行效率不穩定,如果Java堆中包含大量物件,而且其中大部分是需要被回收的,這時必須進行大量標記和清除的動作,導致標記和清除兩個過程的執行效率都隨物件數量增長而降低;

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

標記-複製 Mark-Copy

標記-複製演算法常被簡稱為複製演算法它將可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的記憶體用完了,就將還存活著的物件複製到另外一塊上面,然後再把已使用過的記憶體空間一次清理掉。

如果記憶體中多數物件都是存活的,這種演算法將會產生大量的記憶體間複製的開銷,但對於多數物件都是可回收的情況,演算法需要複製的就是佔少數的存活物件,而且每次都是針對整個半區進行記憶體回收,分配記憶體時也就不用考慮有空間碎片的複雜情況,只要移動堆頂指標,按順序分配即可。這樣實現簡單,執行高效,不過其缺陷也顯而易見,這種複製回收演算法的代價是將可用記憶體縮小為了原來的一半,空間浪費未免太多了。

標記-壓縮 Mark-Compact

標記-複製演算法在物件存活率較高時就要進行較多的複製操作,效率將會降低。更關鍵的是,如果不想浪費50%的空間,就需要有額外的空間進行分配擔保,以應對被使用的記憶體中所有物件都100%存活的極端情況,所以在老年代一般不能直接選用這種演算法。

標記-壓縮演算法其中的標記過程仍然與“標記-清除”演算法一樣,但後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向記憶體空間一端移動,然後直接清理掉邊界以外的記憶體:

標記-清除演算法與標記-整理演算法的本質差異在於前者是一種非移動式的回收演算法,而後者是移動式的。是否移動回收後的存活物件是一項優缺點並存的風險決策:如果移動存活物件,尤其是在老年代這種每次回收都有大量物件存活區域,移動存活物件並更新所有引用這些物件的地方將會是一種極為負重的操作,而且這種物件移動操作必須全程暫停使用者應用程式(STW問題)才能進行 。

垃圾處理器

基於上面的三種垃圾回收演算法,衍生出7種垃圾回收器:

Serial收集器

這個收集器是一個單執行緒工作的收集器,但它的“單執行緒”的意義並不僅僅是說明它只會使用一個處理器或一條收集執行緒去完成垃圾收集工作,更重要的是強調在它進行垃圾收集時,必須暫停其他所有工作執行緒,直到它收集結束。

迄今為止,它依然是HotSpot虛擬機器執行在客戶端模式下的預設新生代收集器,有著優於其他收集器的地方,那就是簡單而高效(與其他收集器的單執行緒相比),對於記憶體資源受限的環境,它是所有收集器裡額外記憶體消耗(Memory Footprint) [1] 最小的;對於單核處理器或處理器核心數較少的環境來說,Serial收集器由於沒有執行緒互動的開銷,專心做垃圾收集自然可以獲得最高的單執行緒收集效率。Serial收集器對於執行在客戶端模式下的虛擬機器來說是一個很好的選擇。

ParNew收集器

ParNew收集器實質上是Serial收集器的多執行緒並行版本,除了同時使用多條執行緒進行垃圾收集之
外,其餘的行為包括Serial收集器可用的所有控制引數(例如:-XX:SurvivorRatio、-XX:
PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集演算法、Stop The World、物件分配規
則、回收策略等都與Serial收集器完全一致,在實現上這兩種收集器也共用了相當多的程式碼。

ParNew收集器除了支援多執行緒並行收集之外,其他與Serial收集器相比並沒有太多創新之處,但它
卻是不少執行在服務端模式下的HotSpot虛擬機器,尤其是JDK 7之前的遺留系統中首選的新生代收集
器,其中有一個與功能、效能無關但其實很重要的原因是:除了Serial收集器外,目前只有它能與CMS
收集器配合工作,另一方面CMS的出現鞏固了ParNew的地位

ParNew收集器在單核心處理器的環境中絕對不會有比Serial收集器更好的效果,甚至由於存線上程
互動的開銷,該收集器在通過超執行緒(Hyper-Threading)技術實現的偽雙核處理器環境中都不能百分之百保證超越Serial收集器。當然,隨著可以被使用的處理器核心數量的增加,ParNew對於垃圾收集時
系統資源的高效利用還是很有好處的。

Parallel Scavenge收集器

Parallel Scavenge收集器也是一款新生代收集器,它同樣是基於標記-複製演算法實現的收集器,也是能夠並行收集的多執行緒收集器……Parallel Scavenge的諸多特性從表面上看和ParNew非常相似,那它有什麼特別之處呢?

Parallel Scavenge收集器的特點是它的關注點與其他收集器不同,CMS等收集器的關注點是儘可能地縮短垃圾收集時使用者執行緒的停頓時間,而Parallel Scavenge收集器的目標則是達到一個可控制的吞吐量(Throughput)。所謂吞吐量就是處理器用於執行使用者程式碼的時間與處理器總消耗時間的比值,即:

\[吞吐量 = \frac{執行使用者程式碼時間}{執行使用者程式碼時間+執行垃圾收集器的時間} \]

如果虛擬機器完成某個任務,使用者程式碼加上垃圾收集總共耗費了100分鐘,其中垃圾收集花掉1分鐘,那吞吐量就是99%。停頓時間越短就越適合需要與使用者互動或需要保證服務響應質量的程式,良好的響應速度能提升使用者體驗;而高吞吐量則可以最高效率地利用處理器資源,儘快完成程式的運算任務,主要適合在後臺運算而不需要太多互動的分析任務。

由於與吞吐量關係密切,Parallel Scavenge收集器也經常被稱作“吞吐量優先收集器”。

Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同樣是一個單執行緒收集器,使用標記-整理演算法。這個收集器的主要意義也是供客戶端模式下的HotSpot虛擬機器使用。如果在服務端模式下,它也可能有兩種用途:一種是在JDK 5以及之前的版本中與Parallel Scavenge收集器搭配使用,另外一種就是作為CMS收集器發生失敗時的後備預案,在併發收集發生Concurrent Mode Failure時使用。

Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,支援多執行緒併發收集,基於標記-整理演算法實現。這個收集器是直到JDK 6時才開始提供的,在此之前,新生代的Parallel Scavenge收集器一直處於相當尷尬的狀態,原因是如果新生代選擇了Parallel Scavenge收集器,老年代除了Serial Old(PSMarkSweep)收集器以外別無選擇,其他表現良好的老年代收集器,如CMS無法與它配合工作。

由於老年代Serial Old收集器在服務端應用效能上的“拖累”,使用Parallel Scavenge收集器也未必能在整體上獲得吞吐量最大化的效果。

同樣,由於單執行緒的老年代收集中無法充分利用伺服器多處理器的並行處理能力,在老年代記憶體空間很大而且硬體規格比較高階的執行環境中,這種組合的總吞吐量甚至不一定比ParNew加CMS的組合來得優秀。直到Parallel Old收集器出現後,“吞吐量優先”收集器終於有了比較名副其實的搭配組合,在注重吞吐量或者處理器資源較為稀缺的場合,都可以優先考慮Parallel Scavenge加Parallel Old收集器這個組合。

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。目前很大一部分的Java應用集中在網際網路網站或者基於瀏覽器的B/S系統的服務端上,這類應用通常都會較為關注服務的響應速度,希望系統停頓時間儘可能短,以給使用者帶來良好的互動體驗。CMS收集器就非常符合這類應用的需求。

從名字(包含“Mark Sweep”)上就可以看出CMS收集器是基於標記-清除演算法實現的,它的運作過程相對於前面幾種收集器來說要更復雜一些,整個過程分為四個步驟,包括:
1)初始標記(CMS initial mark)
2)併發標記(CMS concurrent mark)
3)重新標記(CMS remark)
4)併發清除(CMS concurrent sweep)

其中初始標記、重新標記這兩個步驟仍然需要“Stop The World”。初始標記僅僅只是標記一下GCRoots能直接關聯到的物件,速度很快;併發標記階段就是從GC Roots的直接關聯物件開始遍歷整個物件圖的過程,這個過程耗時較長但是不需要停頓使用者執行緒,可以與垃圾收集執行緒一起併發執行;而重新標記階段則是為了修正併發標記期間,因使用者程式繼續運作而導致標記產生變動的那一部分物件的標記記錄,這個階段的停頓時間通常會比初始標記階段稍長一些,但也遠比並發標記階段的時間短;最後是併發清除階段,清理刪除掉標記階段判斷的已經死亡的物件,由於不需要移動存活物件,所以這個階段也是可以與使用者執行緒同時併發的。由於在整個過程中耗時最長的併發標記和併發清除階段中,垃圾收集器執行緒都可以與使用者執行緒一起工作,所以從總體上來說,CMS收集器的記憶體回收過程是與使用者執行緒一起併發執行的。

優點:併發收集、低停頓
缺點:1.對處理器資源非常敏感

​ 2.無法處理“浮動垃圾”(Floating Garbage)

​ 3.空間碎片

Garbage First收集器

Garbage First(簡稱G1)收集器是垃圾收集器技術發展歷史上的里程碑式的成果,它開創了收集器面向區域性收集的設計思路和基於Region的記憶體佈局形式。G1是一款主要面向服務端應用的垃圾收集器。

在G1收集器出現之前的所有其他收集器,包括CMS在內,垃圾收集的目標範圍要麼是整個新生代(Minor GC),要麼就是整個老年代(Major GC),再要麼就是整個Java堆(Full GC)。而G1跳出了這個樊籠,它可以面向堆記憶體任何部分來組成回收集(Collection Set,一般簡稱CSet)進行回收,衡量標準不再是它屬於哪個分代,而是哪塊記憶體中存放的垃圾數量最多,回收收益最大,這就是G1收集器的Mixed GC模式。G1開創的基於Region的堆記憶體佈局是它能夠實現這個目標的關鍵。雖然G1也仍是遵循分代收集理論設計的,但其堆記憶體的佈局與其他收集器有非常明顯的差異:G1不再堅持固定大小以及固定數量的分代區域劃分,而是把連續的Java堆劃分為多個大小相等的獨立區域(Region),每一個Region都可以根據需要,扮演新生代的Eden空間、Survivor空間,或者老年代空間。收集器能夠對扮演不同角色的Region採用不同的策略去處理,這樣無論是新建立的物件還是已經存活了一段時間、熬過多次收集的舊物件都能獲取很好的收集效果。

Region中還有一類特殊的Humongous區域,專門用來儲存大物件。G1認為只要大小超過了一個Region容量一半的物件即可判定為大物件。每個Region的大小可以通過引數-XX:G1HeapRegionSize設定,取值範圍為1MB~32MB,且應為2的N次冪。而對於那些超過了整個Region容量的超級大物件,將會被存放在N個連續的Humongous Region之中,G1的大多數行為都把Humongous Region作為老年代的一部分來進行看待,如圖3-12所示。

雖然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它們都是一系列區域(不需要連續)的動態集合。G1收集器之所以能建立可預測的停頓時間模型,是因為它將Region作為單次回收的最小單元,即每次收集到的記憶體空間都是Region大小的整數倍,這樣可以有計劃地避免在整個Java堆中進行全區域的垃圾收集。更具體的處理思路是讓G1收集器去跟蹤各個Region裡面的垃圾堆積的“價值”大小,價值即回收所獲得的空間大小以及回收所需時間的經驗值,然後在後臺維護一個優先順序列表,每次根據使用者設定允許的收集停頓時間(使用引數-XX:MaxGCPauseMillis指定,預設值是200毫秒),優先處理回收價值收益最大的那些Region,這也就是“Garbage First”名字的由來。這種使用Region劃分記憶體空間,以及具有優先順序的區域回收方式,保證了G1收集器在有限的時間內獲取儘可能高的收集效率。

垃圾處理器總結

目前是新生代老年代垃圾回收器組合方式:

相關文章