垃圾回收與內存分配策略

  • 垃圾回收與內存分配策略

“垃圾”的定義

對象是否爲“垃圾”

判斷對象是否已成爲“垃圾”的兩種方法: 引用計數法可達性分析算法

  • 引用計數法

如果一個對象被引用一次,則加1,如果沒人引用則被回收;存在問題:如果兩個對象循環引用,但是沒有任何外部對象引用他們倆,則那兩個對象無法被回收。

  • 可達性分析算法(主流JVM採用)

沒有被根對象(GC ROOT)直接或簡介引用的對象則會被回收

根對象--肯定不能對回收的對象

GC ROOT對象:system class、同步鎖、線程類、本地方法類

何爲“引用”--四種引用類型

JDK1.2以後將引用分爲:強引用、軟引用、弱引用和虛引用4種,強度依次減弱。

  • 強引用
    被GC ROOT直接引用(等號賦值
  • 軟引用
    被GC ROOT間接引用;當內存不足時被回收,內存充足時不會被回收
  • 弱引用
    沒有GC ROOT直接引用,當發生垃圾回收時, 不管內存是否充足都會被回收
  • 虛引用
    沒有GC ROOT直接引用,虛引用使用時必須配合引用隊列進行管理。

比如創建一個ByteBuffer實現類對象時,會創建個一個Cleaner對象,當ByteBuffer實現類對象沒有再被引用時,ByteBuffer實現類對象會被回收,Cleaner對象則會進入引用隊列,這時候一個referencehandles線程會查找引用隊列中是否存在cleaner對象,如果有則調用Cleaner.clean方法,clean方法則根據記錄的直接內存的地址,調用unsafe.freememory方法釋放直接內存

  • 補充:引用隊列

軟引用、弱引用本身也要佔用一定內存,當軟引用、弱引用的引用對象都被回收時,則進入引用隊列,會對引用隊列進行後續管理;虛引用引用的對象被釋放後,虛引用會進入引用隊列

最後的掙扎--finalize()方法

即使可達性分析後,對象被判定爲“垃圾”,也並非非死不可。一個對象的死亡至少需要兩次標記:

沒有與GC Root的引用鏈,標記一次

對象沒有重寫finalize()方法,或finalize()重寫但已被調用過一次,標記第二次

如果重寫了finalize()方法,且還沒有被調用,那麼對象會被放置在F-Queue的隊列中,會有一條虛擬機自建的、優先度較低的線程Finalizer線程去執行對象的finalize()方法,但爲了防止finalize()方法出現死循環等異常,並不會保證等待finalize()方法執行結束。在此期間,若對象建立了引用鏈,則對象可以存活一次,否則就“死定了”。

不建議使用該finalize()方法

回收方法區

方法區的垃圾回收主要包含兩部分:廢棄的常量、不再使用的類型

常量的回收類似與Java堆中的對象,當沒有引用時,則允許回收

類型的回收相對比較苛刻,需要同時滿足以下條件,才 允許 被回收

  • 該類所有實例都已被回收
  • 該類的類加載器已被回收
  • 該類對應的java.lang.Class對象沒有被引用,且在任何地方都不可以通過反射訪問該類方法

垃圾回收算法

從判定垃圾消亡的角度出發,垃圾回收算法可以劃分爲“引用計數式垃圾收集”、“追蹤式垃圾收集”兩類。在Java虛擬機中的討論都在追蹤式垃圾收集的範疇中。

回收的前置--分代理論

分代設計的理論建立在兩個分代假說之上:

  1. 弱分代假說:新生對象都是朝生夕死
  2. 強分代假說:熬過越多次垃圾回收的對象,就越難以消亡

    設計原則:

    垃圾收集器應該依據對象的年齡,把Java堆劃分爲不同的區域。

    • 新生代
      朝生夕滅的對象集中在一個區域,每次回收只需關注少量需要存活的對象即可
    • 老年代
      難以消亡的對象集中在一個區域,可以使用較低的頻率去觸發回收機制

    但是,在對新生代進行垃圾收集的時候,不免會出現新生代的中的對象被老年代引用的情況。所以,爲了確定新生代區域的存活對象,除了GC Root之外還需要遍歷整個老年代中所有對象來獲得準確的可達性分析。基於此,引入第三條經驗法則:

  3. 跨代引用假說:跨代引用相對於同代引用來說只佔少數

    跨代引用一般傾向於兩個對象同時生存或同時消亡的

    設計原則:

    在新生代建立全局數據結構(記憶集),把老年代分爲若干小塊,記錄老年代中哪一塊內存存在跨代引用

    此後,發生minor gc時只有包含了跨代引用的小塊內存中的對象纔會被加入到GC Root進行掃描

標記-清除算法(Mark Sweep)

先標記需要回收的對象,再統一清除

效率不穩定,隨着對象數量增多,標記、清除兩個過程的執行效率降低

內存碎片化,導致存入大對象時無法獲得足夠的連續內存空間,觸發另一次垃圾收集動作

標記-複製算法

將可用內存劃分爲兩個完全相等空間,每次只使用其中的一塊。如果其中的一塊內存用完,則將存活的對象完全複製到另一塊,再對原來的空間進行統一清除回收。

  • 缺點
    內存空間的浪費
    若空間內大量對象都是存活的,複製的開銷增大
  • 優點
    簡單高效
    不用考慮內存空間碎片化
    PS.
    現商用Java虛擬機多在新生代中採用該方法
  • Appel式回收

    HotSpot虛擬機中的Serial、ParNew等新生代收集器均採取該策略。具體如下:

    把新生代分爲一塊較大的Eden空間和兩塊較小的Survivor空間,每次分配只使用Eden和一塊Survivor,發生垃圾回收時,將存活的對象一次性複製給另一塊Survivor空間內,然後清理已用的空間。

    HotSpot虛擬機給Eden和Survivor默認大小比例爲8:1,也就是說會有10%的空間會被浪費。當預留的10%的內存空間存不下存活的對象時,,就需要依賴其它內存空間(大多爲老年代)進行內存分配。

標記-整理算法(Mark Compact)

區別與標記--清除算法,標記--整理算法,在標記後將存活的對象移向一端,然後將另一端的空間整體回收,是一種 移動式 的算法。

  • 優點
    不存在碎片化內存,則無需依賴複雜的內存分配器
  • 缺點
    對象的移動操作需要觸發“Stop The World”耗時較久

標記?清除:整理

標記-清除是一種非移動式算法、標記-整理是一種移動式算法,兩者比較說明:

  • 吞吐量比較

    吞吐量定義:賦值器和收集器效率之和

    不移動會使得收集器效率增大,但是內存分配和訪問會比垃圾回收頻率高得多,所以整體吞吐量還是降低的。

  • 舉例說明

    HotSpot虛擬機中關注吞吐量的Parallel Scavenger收集器基於標記-整理算法;關注低延遲的CMS收集器基於標記-清除算法

  • 混合方案

    使虛擬機多數時間採用標記-清除算法,暫時容忍碎片的存在,等到碎片化程度開始影響對象的內存分配時,在採用標記-整理算法收集一次(CMS就採取該方式)

經典垃圾回收器

所謂“經典”垃圾回收器是指區別於實驗室階段的、已通過應用實踐的垃圾回收器。

Serial收集器

Serial:新生代:標記-複製算法

Serial Old:老年代:標記-整理算法

HotSpot虛擬機運行在客戶端模式下的默認新生代收集器

簡單高效、內存消耗最小

ParNew收集器

ParNew:新生代:標記-複製算法

Serial Old:老年代:標記-整理算法

激活CMS後,默認的新生代收集器

Serial的多線程版本,默認開啓的線程數與CPU核心數相同

Parallel Scavenge蒐集器

標記-複製算法,與ParNew相似

關注點在於達成可控制的吞吐量(吞吐量=用戶代碼運行時間/總時間;總時間=用戶代碼運行時間+垃圾回收時間)

參數說明

  • -XXMaxGCPauseMillis 更關注停頓時間

    一個大於0的毫秒數,儘量使回收時間不超過這個值

    實現原理:犧牲吞吐量和新生代空間獲取,小內存新生代空間的回收速度一定由於高內存速度,但是回收頻率也會增加

  • -XXGCTimeRatio 更關注吞吐量

    0到100之間的整數,代表垃圾回收時間佔總時間的比率,相當於吞吐量的倒數

  • -UserAdaptiveSizePolicy
    開關函數,激活後虛擬機會根據當前運行情況自動調整Eden與Survivor的內存比例、老年代內存大小等參數,已提供合適的停頓時間和最大吞吐量

Serial Old 收集器

serial 收集器的老年版本,標記-整理算法

在CMS收集器併發失敗時的預備方案

Parallel Old 收集器

Parallel Scavenge 收集器的老年版本,標記-整理算法

在注重吞吐量或處理器資源稀缺時使用

CMS收集器

獲取最短停頓時間的爲目標,採用併發-清除算法

  • 工作步驟
    1. 初始標記

      標記GC Roots能直接關聯的對象,速度很快

    2. 併發標記

      從GC Roots直接關聯到的對象開始遍歷整個對象圖,耗時較長

    3. 重新標記

      修正併發標記期間,因用戶繼續運作導致標記產生變動的部分對象的標記記錄

    4. 併發清除

      清除掉標記的已死亡的對象

整個過程中,併發標記和併發清除耗時最久

  • 關鍵問題
    1. 併發過程中會佔用部分資源

      當處理器核心數大於4時,默認回收線程數不超過25%(處理器核心數+3)/4

      但是當處理器核心數小於4時,用戶線程執行速度會大幅降低

    2. “浮動垃圾”與併發失敗

      與用戶程序運行併發運行就必然產生新的垃圾只有等下一次回收時才清理,這部分垃圾稱爲“浮動垃圾”,所以需要給用戶線程預留足夠空間。因此,CMS不能等老年代滿了才進行收集,必須預留一部分作爲併發時使用。如果CMS運行期間預留的內存無法滿足程序分配新對象的需求,就會出現“併發失敗”,這時候需要STW,臨時啓用Serial Old收集器對老年代的垃圾進行收集

    3. 內存碎片

      基於標記-清除算法必然產生內存碎片,導致大對象分配時出現內存不足進而觸發Full GC。CMS提供 -XX:UseCMSCompactAtFullCollection 開關參數(默認開啓),當不得不進行Full GC時進行內存碎片整合,即移動存活對象。會使得停頓時間延長

Garbage First 收集器

建立可預測的停頓時間模型,開創了面向局部收集的內存設計思路,基於Region的內存佈局形式。默認停頓時間爲200毫秒

  • 基於Region的內存佈局

    把連續的Java堆內存劃分爲多個大小相等的獨立空間,每個空間都可以扮演Eden、Survivor空間或者老年代空間,其中Humongous區域轉爲收集大對象(大小超過了一個Region的對象,Region的大小可通過參數調整),G1大多會把Humongous當做老年代看待。收集器可以根據不同的角色採取不同的收集策略。

  • 局部收集思想

    Region作爲每次回收的最小內存單位,每次收集到的空間都是Region的整倍數,G1會跟蹤Region堆積的“價值”大小(回收所獲空間/回收所需時間的經驗值),再後臺維護一個優先級列表,優先回收價值大的Region

  • 工作步驟
    1. 初始標記
      標記GC Roots能直接關聯的對象,並修改TAMS指針的值,是藉助Minor GC完成,所以不會造成額外的時間成本。
    2. 併發標記
      從GC Roots開始對堆中對象進行可達性分析,可併發執行,掃描完成時重新處理SATB記錄的引用變動
    3. 最終標記
      處理併發標記時的發生變動的對象,STW,併發完成
    4. 篩選回收
      更新Region的統計數據,根據用戶期望的停頓時間結合回收價值,確定需要回收Region集合。把需要回收的Region中存活的對象複製到空Region中,再清理需要回收的全部Region區域。
  • 關鍵問題
    1. 跨Region引用的處理辦法

      每個Region都維護一張自己的記憶集,記錄別的Region指向自己的指針,並標記這些指針在哪些卡頁範圍之內。其存儲結構本質上是一種哈希表,key是別的Region的起始地址,value是一個集合,存儲卡表的索引號。G1要耗費大越10%到20%的額外內存來維持收集器的工作。

    2. 併發干擾問題

      CMS在併發標記時採用增量更新的算法實現,而G1則通過原始快照(SATB)算法實現。此外,G1在回收過程中創建新對象的內存分配上也做了改動,G1爲每個Region設計了兩個名爲TAMS(Top At Mark Start)的指針,併發標記中新分配的對象都要在這兩個指針位置以上。G1收集器默認這部分對象是隱式標記過的,默認爲存活

    3. 可靠地停頓預測

      -XX:MaxGCPauseMillis 參數指用戶期望的停頓時間,具體實現是以“衰減均值”爲理論基礎:在垃圾回收過程中,會記錄每個Region的回收耗時、記憶集中裏的髒卡數量等各個可測量的步驟所花費的成本。“衰減均值”更能體現“最近”一段時間的平均狀態,更能在當下使回收不超過預期。(有點活在當下的感覺)

  • G1與CMS
    • 優點:
      可以指定最大停頓時間、分Region的內存佈局、按收益動態回收、不會產生內存碎片、回收完成後可提供規整的可用內存
    • 缺點:
      內存佔用、程序執行的額外負載都較高
      G1的卡表更爲複雜;運行負載方面,CMS使用寫後屏障來更細維護卡表,而G1爲了實現原始搜索(SATB)快照算法,還需要寫前屏障來跟蹤併發時的指針變化情況,G1能減少併發標記和重新標記的消耗,避免像CMS那樣在最終標記階段停頓時間過長。CMS直接同步處理,而G1異步處理
  • 總結

    小內存上使用CMS有優勢,而大內存狀態下使用G1有更多優勢,而Java堆內存容量平衡點大約在6-8GB之間(經驗數據)

低延遲垃圾收集器

Shenandoah 收集器

ZGC 收集器

相關文章