JVM之垃圾回收機制詳解分析

進擊的java程式設計師k發表於2019-03-30

如何判定物件為垃圾物件

在堆裡面存放著Java世界中幾乎所有的物件例項, 垃圾收集器在對堆進行回收前, 第一件事就是判斷哪些物件已死(可回收).

引用計數法

在JDK1.2之前,使用的是引用計數器演算法。
在物件中新增一個引用計數器,當有地方引用這個物件的時候,引用計數器的值就+1,當引用失效的時候,計數器的值就-1,當引用計數器被減為零的時候,標誌著這個物件已經沒有引用了,可以回收了!


問題:如果在A類中呼叫B類的方法,B類中呼叫A類的方法,這樣當其他所有的引用都消失了之後,A和B還有一個相互的引用,也就是說兩個物件的引用計數器各為1,而實際上這兩個物件都已經沒有額外的引用,已經是垃圾了。但是該演算法並不會計算出該型別的垃圾。

可達性分析法

在主流商用語言(如Java、C#)的主流實現中, 都是通過可達性分析演算法來判定物件是否存活的: 通過一系列的稱為 GC Roots 的物件作為起點, 然後向下搜尋; 搜尋所走過的路徑稱為引用鏈/Reference Chain, 當一個物件到 GC Roots 沒有任何引用鏈相連時, 即該物件不可達, 也就說明此物件是不可用的, 如下圖:雖然E和F相互關聯, 但它們到GC Roots是不可達的, 因此也會被判定為可回收的物件。

注: 即使在可達性分析演算法中不可達的物件, VM也並不是馬上對其回收, 因為要真正宣告一個物件死亡, 至少要經歷兩次標記過程: 第一次是在可達性分析後發現沒有與GC Roots相連線的引用鏈, 第二次是GC對在F-Queue執行佇列中的物件進行的小規模標記(物件需要覆蓋finalize()方法且沒被呼叫過).

在Java, 可作為GC Roots的物件包括:
  1. 方法區: 類靜態屬性引用的物件;
  2. 方法區: 常量引用的物件;
  3. 虛擬機器棧(本地變數表)中引用的物件.
  4. 本地方法棧JNI(Native方法)中引用的物件。

如何回收

回收策略

垃圾收集策略有分代收集和分割槽收集。

分代收集演算法

標記-清除演算法(老年代)

該演算法分為“標記”和“清除”兩個階段: 首先標記出所有需要回收的物件(可達性分析), 在標記完成後統一清理掉所有被標記的物件.

該演算法會有兩個問題:

  1. 效率問題,標記和清除效率不高。
  2. 空間問題: 標記清除後會產生大量不連續的記憶體碎片, 空間碎片太多可能會導致在執行過程中需要分配較大物件時無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集。

所以它一般用於"垃圾不太多的區域,比如老年代"。

複製演算法(新生代)

該演算法的核心是將可用記憶體按容量劃分為大小相等的兩塊, 每次只用其中一塊, 當這一塊的記憶體用完, 就將還存活的物件(非垃圾)複製到另外一塊上面, 然後把已使用過的記憶體空間一次清理掉.

優點:不用考慮碎片問題,方法簡單高效。
缺點:記憶體浪費嚴重。

現代商用VM的新生代均採用複製演算法, 但由於新生代中的98%的物件都是生存週期極短的, 因此並不需完全按照1∶1的比例劃分新生代空間, 而是將新生代劃分為一塊較大的Eden區和兩塊較小的Survivor區(HotSpot預設Eden和Survivor的大小比例為8∶1), 每次只用Eden和其中一塊Survivor. 當發生MinorGC時, 將Eden和Survivor中還存活著的物件一次性地拷貝到另外一塊Survivor上, 最後清理掉Eden和剛才用過的Survivor的空間. 當Survivor空間不夠用(不足以儲存尚存活的物件)時, 需要依賴老年代進行空間分配擔保機制, 這部分記憶體直接進入老年代。

複製演算法的空間分配擔保:
在執行Minor GC前, VM會首先檢查老年代是否有足夠的空間存放新生代尚存活物件, 由於新生代使用複製收集演算法, 為了提升記憶體利用率, 只使用了其中一個Survivor作為輪換備份, 因此當出現大量物件在Minor GC後仍然存活的情況時, 就需要老年代進行分配擔保, 讓Survivor無法容納的物件直接進入老年代, 但前提是老年代需要有足夠的空間容納這些存活物件. 但存活物件的大小在實際完成GC前是無法明確知道的, 因此Minor GC前, VM會先首先檢查老年代連續空間是否大於新生代物件總大小或歷次晉升的平均大小, 如果條件成立, 則進行Minor GC, 否則進行Full GC(讓老年代騰出更多空間).
然而取歷次晉升的物件的平均大小也是有一定風險的, 如果某次Minor GC存活後的物件突增,遠遠高於平均值的話,依然可能導致擔保失敗(Handle Promotion Failure, 老年代也無法存放這些物件了), 此時就只好在失敗後重新發起一次Full GC(讓老年代騰出更多空間).

標記-整理演算法(老年代)

標記清除演算法會產生記憶體碎片問題, 而複製演算法需要有額外的記憶體擔保空間, 於是針對老年代的特點, 又有了標記整理演算法. 標記整理演算法的標記過程與標記清除演算法相同, 但後續步驟不再對可回收物件直接清理, 而是讓所有存活的物件都向一端移動,然後清理掉端邊界以外的記憶體.

方法區回收(永久代)

在方法區進行垃圾回收一般”價效比”較低, 因為在方法區主要回收兩部分內容: 廢棄常量無用的類.

回收廢棄常量與回收其他年代中的物件類似, 但要判斷一個類是否無用則條件相當苛刻:

  1. 該類所有的例項都已經被回收, Java堆中不存在該類的任何例項;
  2. 該類對應的Class物件沒有在任何地方被引用(也就是在任何地方都無法通過反射訪問該類的方法);
  3. 載入該類的ClassLoader已經被回收.
    但即使滿足以上條件也未必一定會回收, Hotspot VM還提供了-Xnoclassgc引數控制(關閉CLASS的垃圾回收功能). 因此在大量使用動態代理、CGLib等位元組碼框架的應用中一定要關閉該選項, 開啟VM的類解除安裝功能, 以保證方法區不會溢位.

分割槽收集

分割槽演算法則將整個堆空間劃分為連續的不同小區間, 每個小區間獨立使用, 獨立回收. 這樣做的好處是可以控制一次回收多少個小區間

在相同條件下, 堆空間越大, 一次GC耗時就越長, 從而產生的停頓也越長. 為了更好地控制GC產生的停頓時間, 將一塊大的記憶體區域分割為多個小塊, 根據目標停頓時間, 每次合理地回收若干個小區間(而不是整個堆), 從而減少一次GC所產生的停頓

垃圾回收器

Serial

Serial收集器是Hotspot執行在Client模式下的預設新生代收集器, 它在進行垃圾收集時,會暫停所有的工作程式,用一個執行緒去完成GC工作

特點:簡單高效,適合jvm管理記憶體不大的情況(十兆到百兆)。

Parnew

ParNew收集器其實是Serial的多執行緒版本,回收策略完全一樣,但是他們又有著不同。

我們說了Parnew是多執行緒gc收集,所以它配合多核心的cpu效果更好,如果是一個cpu,他倆效果就差不多。(可用-XX:ParallelGCThreads引數控制GC執行緒數)

Cms

CMS(Concurrent Mark Sweep)收集器是一款具有劃時代意義的收集器, 一款真正意義上的併發收集器, 雖然現在已經有了理論意義上表現更好的G1收集器, 但現在主流網際網路企業線上選用的仍是CMS(如Taobao),又稱多併發低暫停的收集器。

由他的英文組成可以看出,它是基於標記-清除演算法實現的。整個過程分4個步驟:

  1. 初始標記(CMS initial mark):僅只標記一下GC Roots能直接關聯到的物件, 速度很快
  2. 併發標記(CMS concurrent mark: GC Roots Tracing過程)
  3. 重新標記(CMS remark):修正併發標記期間因使用者程式繼續執行而導致標記產生變動的那一部分物件的標記記錄
  4. 併發清除(CMS concurrent sweep: 已死物件將會就地釋放)

可以看到,初始標記、重新標記需要STW(stop the world 即:掛起使用者執行緒)操作。因為最耗時的操作是併發標記和併發清除。所以總體上我們認為CMS的GC與使用者執行緒是併發執行的。

優點:併發收集、低停頓

缺點:

  1. CMS預設啟動的回收執行緒數=(CPU數目+3)*4
    當CPU數>4時, GC執行緒最多佔用不超過25%的CPU資源, 但是當CPU數<=4時, GC執行緒可能就會過多的佔用使用者CPU資源, 從而導致應用程式變慢, 總吞吐量降低.
  2. 無法清除浮動垃圾(GC執行到併發清除階段時使用者執行緒產生的垃圾),因為使用者執行緒是需要記憶體的,如果浮動垃圾施放不及時,很可能就造成記憶體溢位,所以CMS不能像別的垃圾收集器那樣等老年代幾乎滿了才觸發,CMS提供了引數-XX:CMSInitiatingOccupancyFraction來設定GC觸發百分比(1.6後預設92%),當然我們還得設定啟用該策略-XX:+UseCMSInitiatingOccupancyOnly
  3. 因為CMS採用標記-清除演算法,所以可能會帶來很多的碎片,如果碎片太多沒有清理,jvm會因為無法分配大物件記憶體而觸發GC,因此CMS提供了-XX:+UseCMSCompactAtFullCollection引數,它會在GC執行完後接著進行碎片整理,但是又會有個問題,碎片整理不能併發,所以必須單執行緒去處理,所以如果每次GC完都整理使用者執行緒stop的時間累積會很長,所以XX:CMSFullGCsBeforeCompaction引數設定隔幾次GC進行一次碎片整理(預設為0)。

G1

同優秀的CMS垃圾回收器一樣,G1也是關注最小時延的垃圾回收器,也同樣適合大尺寸堆記憶體的垃圾收集,官方也推薦使用G1來代替選擇CMS。G1最大的特點是引入分割槽的思路,弱化分代的概念,合理利用垃圾收集各個週期的資源,解決了其他收集器甚至CMS的眾多缺陷。

因為每個區都有E、S、O代,所以在G1中,不需要對整個Eden等代進行回收,而是尋找可回收物件比較多的區,然後進行回收(雖然也需要STW操作,但是花費的時間是很少的),保證高效率。

新生代收集

G1的新生代收集跟ParNew類似,如果存活時間超過某個閾值,就會被轉移到S/O區。

年輕代記憶體由一組不連續的heap區組成, 這種方法使得可以動態調整各代區域的大小

老年代收集

分為以下幾個階段:

  1. 初始標記 (Initial Mark: Stop the World Event)
    在G1中, 該操作附著一次年輕代GC, 以標記Survivor中有可能引用到老年代物件的Regions.
  2. 掃描根區域 (Root Region Scanning: 與應用程式併發執行)
    掃描Survivor中能夠引用到老年代的references. 但必須在Minor GC觸發前執行完
  3. 併發標記 (Concurrent Marking : 與應用程式併發執行)
    在整個堆中查詢存活物件, 但該階段可能會被Minor GC中斷
  4. 重新標記 (Remark : Stop the World Event)
    完成堆記憶體中存活物件的標記. 使用snapshot-at-the-beginning(SATB, 起始快照)演算法, 比CMS所用演算法要快得多(空Region直接被移除並回收, 並計算所有區域的活躍度).
  5. 清理 (Cleanup : Stop the World Event and Concurrent)
    在含有存活物件和完全空閒的區域上進行統計(STW)、擦除Remembered Sets(使用Remembered Set來避免掃描全堆,每個區都有對應一個Set用來記錄引用資訊、讀寫操作記錄)(STW)、重置空regions並將他們返還給空閒列表(free list)(Concurrent)

最後

後續會持續更新干貨技術分享,大家覺得不錯的可以點個贊關注下,謝謝支援!


相關文章