JVM垃圾回收和記憶體分配策略

小路偶爾愛coding發表於2021-01-04

JVM垃圾回收器和記憶體分配策略

JAVA中虛擬機器的講解,涉及「類載入機制,執行時區域,執行引擎,垃圾回收等」及對voliate, synchronized的JVM層面實現機制等。持續更新中…。 最新文章公眾號持續更新中… 歡迎騷擾,分享技術,探討生活。
adsf.png

前言

程式計數器、虛擬機器棧、本地方法棧 3 個區域隨執行緒生滅「執行緒私有」,棧中的棧幀隨著方法的進入和退出而有條不紊地執行著出棧和入棧操作。

Java 和方法區則不一樣,一個介面中的多個實現類需要的記憶體可能不一樣,一個方法中的多個分支需要的記憶體也可能不一樣,只有在程式處於執行期才知道那些物件會建立,這部分記憶體的分配和回收都是動態的,垃圾回收期所關注的就是這部分記憶體。

物件是否已死

1.引用計數器計算:給物件新增一個引用計數器,每次引用這個物件時計數器加一,引用失效時減一,計數器等於0時就是不會再次使用的。這個方法有一種情況就是出現物件的迴圈引用時GC沒法回收。

image-20210102135408703

2.可達性分析計算:這是一種類似於二叉樹的實現,將一系列的GC ROOTS作為起始的存活物件集,從這個節點往下搜尋,搜尋所走過的路徑成為引用鏈,把能被該集合引用到的物件加入到集合中。搜尋當一個物件到GC Roots沒有使用任何引用鏈時,則說明該物件是不可用的。主流的商用程式語言Java,C#等都是靠這個思想去判定物件是否存活的。

image-20210102135433862

可作為 GC Roots 的物件

  • 虛擬機器棧「棧幀中的區域性變數表」中引用的物件

  • 方法區中類靜態屬性引用的物件

  • 方法區中常量引用的物件

  • 本地方法棧中 JNI「即一般說的 Native 方法」 引用的物件

這兩種方式判斷存活時都與‘引用’有關。但是 JDK 1.2 之後,引用概念進行了擴充,下面具體介紹。

下面四種引用強度一次逐漸減弱

強引用

類似於 Object obj = new Object(); 建立的,只要強引用在就不回收

軟引用

SoftReference 類實現軟引用。在系統要發生記憶體溢位異常之前,將會把這些物件列進回收範圍之中進行二次回收

弱引用

WeakReference 類實現弱引用。物件只能生存到下一次垃圾收集之前。在垃圾收集器工作時,無論記憶體是否足夠都會回收掉只被弱引用關聯的物件

虛引用

PhantomReference 類實現虛引用。無法通過虛引用獲取一個物件的例項,為一個物件設定虛引用關聯的唯一目的就是能在這個物件被收集器回收時收到一個系統通知

物件是否真的死亡

首先必須要提到的是一個名叫 finalize() 的方法

finalize()是Object類的一個方法、一個物件的finalize()方法只會被系統自動呼叫一次,經過finalize()方法逃脫死亡的物件,第二次不會再呼叫。

但並不提倡在程式中呼叫finalize()來進行自救。因為它執行的時間不確定,甚至是否被執行也不確定(Java程式的不正常退出),而且執行代價高昂,無法保證各個物件的呼叫順序(甚至有不同執行緒中呼叫)。在Java9中已經被標記為 deprecated ,且java.lang.ref.Cleaner(也就是強、軟、弱、幻象引用的那一套)中已經逐步替換掉它,會比finalize來的更加的輕量及可靠。

1.如果物件進行可達性分析之後沒發現與GC Roots相連的引用鏈,那它將會第一次標記並且進行一次篩選。判斷的條件是決定這個物件是否有必要執行finalize()方法。如果物件有必要執行finalize()方法,則被放入F-Queue佇列中

2.GC對F-Queue佇列中的物件進行二次標記。如果物件在finalize()方法中重新與引用鏈上的任何一個物件建立了關聯,那麼二次標記時則會將它移出“即將回收”集合如果此時物件還沒成功逃脫,那麼只能被回收了

方法區的回收

在堆中,尤其是在新生代中,一次垃圾回收一般可以回收 70% ~ 95% 的空間,而永久代的垃圾收集效率遠低於此。

永久代垃圾回收主要兩部分內容:廢棄的常量和無用的類。

判斷廢棄常量:一般是判斷沒有該常量的引用。

判斷無用的類:要以下三個條件都滿足

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

回收演算法「物件死了垃圾怎麼回收」

主要有,標記清除複製演算法標記整理分帶回收

標記清除

標記清除演算法就是分為“標記”和“清除”兩個階段。標記出所有需要回收的物件,標記結束後統一回收。

其實它就是把已死亡的物件標記為空閒記憶體,然後記錄在一個空閒列表中,當我們需要new一個物件時,記憶體管理模組會從空閒列表中尋找空閒的記憶體來分給新的物件

缺點:標記和清除的效率比較低下。讓記憶體中的碎片非常多。導致瞭如果我們需要使用到較大的記憶體塊時,無法分配到足夠的連續記憶體

image-20210102142423115

複製演算法

把空間分成兩塊,每次只對其中一塊進行 GC。當這塊記憶體使用完時,就將還存活的物件複製到另一塊上面。

解決前一種方法的不足,但是會造成空間利用率低下。因為大多數新生代物件都不會熬過第一次 GC。所以沒必要 1 : 1 劃分空間。可以分一塊較大的 Eden 空間兩塊較小的 Survivor 空間,每次使用 Eden 空間和其中一塊 Survivor。當回收時,將 Eden 和 Survivor 中還存活的物件一次性複製到另一塊 Survivor 上最後清理 Eden 和 Survivor 空間。大小比例一般是 8 : 1 : 1,每次浪費 10% 的 Survivor 空間。但是這裡有一個問題就是如果存活的大於 10% 怎麼辦?這裡採用一種分配擔保策略:多出來的物件直接進入老年代。

image-20210102144854755

標記整理

不同於針對新生代的複製演算法,針對老年代的特點,建立該演算法。主要是把存活物件移到記憶體的一端

複製演算法在物件存活率高的時候會有一定的效率問題,標記過程仍然與“標記-清除”演算法一樣,但後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉邊界以外的記憶體

image-20210102145532141

分代回收

這種演算法並沒有什麼新的思想,只是根據物件存活週期的不同將記憶體劃分為幾塊。一般是把Java堆分為新生代和老年代,這樣就可以根據各個年代的特點採用最適當的收集演算法。

在新生代中,每次垃圾收集時都發現有大批物件死去,只有少量存活,那就選用複製演算法,只需要付出少量存活物件的複製成本就可以完成收集

老年代中因為物件存活率高、沒有額外空間對它進行分配擔保,就必須使用“標記-清理”或者“標記-整理”演算法來進行回收

垃圾回收器總覽

收集演算法是記憶體回收的理論,垃圾回收器是記憶體回收的實踐

image-20210102145929416

注:連線部分是可以進行搭配使用

收集器序列 並行 併發新/老年代演算法目標適用場景
Serial序列新生代複製響應速度優先單cpu環境下的Client模式
Serial Old序列老年代標記-整理響應速度優先單cpu環境下的Client模式,CMS預備方案
Par New並行新生代複製響應速度優先多cpu環境時在server模式下與CMS配合
Parallel Scavenge並行新生代複製吞吐量優先在後臺運算而不需要太多互動任務
Parallel Old並行老年代標記-整理吞吐量優先在後臺運算而不需要太多互動任務
CMS併發老年代標記-清除響應速度優先集中在網際網路站,B/S系統服務端應用
G1併發both標記-整理 + 複製響應速度優先面向服務端應用,將來替換CMS

到jdk8為止,預設的垃圾收集器是Parallel Scavenge 和 Parallel Old

從jdk9開始,G1收集器成為預設的垃圾收集器

目前來看,G1回收器停頓時間最短而且沒有明顯缺點,非常適合Web應用。

在jdk8中測試Web應用,堆記憶體6G,新生代4.5G的情況下,Parallel Scavenge 回收新生代停頓長達1.5秒。G1回收器回收同樣大小的新生代只停頓0.2秒。

垃圾回收器具體介紹

並行:Parallel

指多條垃圾收集執行緒並行工作,此時使用者執行緒處於等待狀態

併發:Concurrent

指使用者執行緒和垃圾回收執行緒同時執行(不一定是並行,有可能是交叉執行),使用者程式在執行,而垃圾回收執行緒在另一個 CPU 上執行。

Serial 收集器

這是一個單執行緒收集器。意味著它只會使用一個 CPU 或一條收集執行緒去完成收集工作並且在進行垃圾回收時必須暫停其它所有的工作執行緒直到收集結束

img

Serial Old 收集器

收集器的老年代版本,單執行緒,使用 標記 —— 整理

img

ParNew 收集器

可以認為是 Serial 收集器的多執行緒版本。

img

Parallel Scavenge 收集器

這是一個新生代收集器,也是使用複製演算法實現,同時也是並行的多執行緒收集器。

CMS 等收集器的關注點是儘可能地縮短垃圾收集時使用者執行緒所停頓的時間,而 Parallel Scavenge 收集器的目的是達到一個可控制的吞吐量(Throughput = 執行使用者程式碼時間 / (執行使用者程式碼時間 + 垃圾收集時間))。

作為一個吞吐量優先的收集器,虛擬機器會根據當前系統的執行情況收集效能監控資訊,動態調整停頓時間。這就是 GC 的自適應調整策略(GC Ergonomics)。

Parallel Old 收集器

Parallel Old 是 Parallel Scavenge 收集器的老年代版本。多執行緒,使用 標記 —— 整理

img

CMS 收集器

CMS (Concurrent Mark Sweep) 收集器是一種以獲取最短回收停頓時間為目標的收集器。基於 標記 —— 清除 演算法實現。

運作步驟:

  1. 初始標記(CMS initial mark):標記 GC Roots 能直接關聯到的物件
  2. 併發標記(CMS concurrent mark):進行 GC Roots Tracing
  3. 重新標記(CMS remark):修正併發標記期間的變動部分
  4. 併發清除(CMS concurrent sweep)

img

優點:並行執行,低停頓

缺點:1、不停頓耗執行緒,耗記憶體,整體效率低 2、標記清除法會產生垃圾碎片 容易FGC 3、會產生浮動垃圾容易FGC

G1 收集器

面向服務端的垃圾回收器。

優點:並行與併發、分代收集、空間整合、可預測停頓。

運作步驟:

  1. 初始標記(Initial Marking)
  2. 併發標記(Concurrent Marking)
  3. 最終標記(Final Marking)
  4. 篩選回收(Live Data Counting and Evacuation)

img

G1優點

1、空間整合:g1使用Region獨立區域概念,g1利用的是標記複製法,不會產生垃圾碎片

2、分代收集:g1可以自己管理新生代和老年代

3、並行於併發:g1可以通過機器的多核來併發處理 stop - The - world停頓,減少停頓時間,並且可不停頓java執行緒執行GC動作,可通過併發方式讓GC和java程式同時執行。

4、可預測停頓:g1除了追求停頓時間,還建立了可預測停頓時間模型,能讓制定的M毫秒時間片段內,消耗在垃圾回收器上的時間不超過N毫秒

最大的區別是出現了Region區塊概念,可對回收價值和成本進行排序回收,根據GC期望時間回收,還出現了member set概念,

將回收物件放入其中,避免全堆掃描

  • 新生代回收器:Serial、ParNew、Parallel Scavenge
  • 老年代回收器:Serial Old、Parallel Old、CMS
  • 整堆回收器:G1

記憶體分配與回收策略

本身記憶體分配的策略流程是這樣的。

image-20210102162017618

先說下物件不一定全部是在堆中分配也有可能是在棧中「JVM通過逃逸分析, 將執行緒私有的物件打散分配在棧上,也就是逃不出方法的物件會在棧上分配

逃逸分析(Escape Analysis),是一種可以有效減少Java 程式中同步負載和記憶體堆分配壓力的跨函式全域性資料流分析演算法。通過逃逸分析,Hotspot編譯器能夠分析出一個新的物件的引用的使用範圍,從而決定是否要將這個物件分配到堆上。

逃逸分析是指分析指標動態範圍的方法,它同編譯器優化原理的指標分析和外形分析相關聯。當變數(或者物件)在方法中分配後,其指標有可能被返回或者被全域性引用,這樣就會被其他方法或者執行緒所引用,這種現象稱作指標(或者引用)的逃逸(Escape)。通俗點講,如果一個物件的指標被多個方法或者執行緒引用時,那麼我們就稱這個物件的指標發生了逃逸。

// 逃逸分析例子
public class EscapeAnalysisTest {

    public static Object object;
   
    public StringBuilder  escape(String a, String b) {
        StringBuilder str = new StringBuilder();
        str.append(a);
        str.append(b);
        //StringBuilder可能被其他方法改變,逃逸到了方法外部。
        return str;
    }

    
    public String notEscape(String a, String b) {
        StringBuilder str = new StringBuilder();
        str.append(a);
        str.append(b);
        //不直接返回StringBuffer,不發生逃逸
        return str.toString();
    }

    //外部執行緒可見object,發生逃逸
    public void objectEscape(){
        object = new Object();
    }

    //僅方法內部可見,不發生逃逸
    public void objectNotEscape(){
        Object object = new Object();
    }
  
}
// 棧上分配,可以降低垃圾收集器執行的頻率且分配速度快,提高系統效能

// 同步消除,如果發現某個物件只能從一個執行緒可訪問,那麼在這個物件上的操作可以不需要同步。

// 標量替換,把物件分解成一個個基本型別,並且記憶體分配不再是分配在堆上,而是分配在棧上。這樣的好處有,
// 一、減少記憶體使用,因為不用生成物件頭。 二、程式記憶體回收效率高,並且GC頻率也會減少。

//注意開啟逃逸分析和標量替換 -XX:+DoEscapeAnalysis  -XX:+EliminateAllocations

在說下TLAB上分配

逃逸分析和棧上分配只是針對於單執行緒環境來說的,如果在多執行緒環境中,不可避免的會有多個執行緒同時在堆空間中分配物件的情況。這種情況提升效能就引入了TLAB

TLAB,全稱Thread Local Allocation Buffer, 即:執行緒本地分配快取。這是一塊執行緒專用的記憶體分配區域。TLAB佔用的是eden區的空間。在TLAB啟用的情況下(預設開啟),JVM會為每一個執行緒分配一塊TLAB區域

TLAB這是為了加速物件的分配由於物件一般分配在堆上,而堆是執行緒共用的,因此可能會有多個執行緒在堆上申請空間,而每一次的物件分配都必須執行緒同步「有衝突同步降低效率」,會使分配的效率下降。考慮到物件分配幾乎是Java中最常用的操作,因此JVM使用了TLAB這樣的執行緒專有區域來避免多執行緒衝突,提高物件分配的效率。

​ 侷限性: TLAB空間一般不會太大(佔用eden區),所以大物件無法進行TLAB分配,只能直接分配到堆上。

分配策略:

一個100KB的TLAB區域,如果已經使用了80KB,當需要分配一個30KB的物件時,TLAB是如何分配?

一,廢棄當前的TLAB(會浪費20KB的空間);

二,將這個30KB的物件直接分配到堆上,保留當前TLAB「當有小於20KB的物件請求TLAB分配時可以直接使用該TLAB區域」

JVM選擇的策略是:在虛擬機器內部維護一個叫refill_waste的值,當請求物件大於refill_waste時,會選擇在堆中分配,反之,則會廢棄當前TLAB,新建TLAB來分配新物件。

引數作用備註
-XX:+UseTLAB啟用TLAB預設啟用
-XX:TLABRefillWasteFraction設定允許空間浪費的比例預設值:64,即:使用1/64的TLAB空間大小作為refill_waste值
-XX:-ResizeTLAB禁止系統自動調整TLAB大小
-XX:TLABSize指定TLAB大小單位:B

再說是否直接進入老年代

-XX:PretenureSizeThreshold

指定大於該數值的物件直接進入老年代,避免在新生代的Eden和兩個Survivor區域來回複製,產生大量記憶體複製操作。

具體說一下物件優先在 Eden 分配

物件主要分配在新生代的 Eden 區上,如果啟動了本地執行緒分配緩衝區,將執行緒優先在 (TLAB) 上分配。少數情況會直接分配在老年代中。

img

預設的,新生代 ( Young ) 與老年代 ( Old ) 的比例的值為 1:2 ( 該值可以通過引數 –XX:NewRatio 來指定 ),即:新生代 ( Young ) = 1/3 的堆空間大小。老年代 ( Old ) = 2/3 的堆空間大小。

其中,新生代 ( Young ) 被細分為 Eden 和 兩個 Survivor 區域Eden 和倆個Survivor 區域比例是 = 8 : 1 : 1 ( 可以通過引數 –XX:SurvivorRatio 來設定 ),

但是JVM 每次只會使用 Eden 和其中的一塊 Survivor 區域來為物件服務,所以無論什麼時候,總是有一塊 Survivor 區域是空閒著的「為了做清理轉移年齡升級用的下邊會細說」。

新生代、老年代、永久代的區別

在 Java 中,堆被劃分成兩個不同的區域:新生代 ( Young )、老年代 ( Old )。而新生代 ( Young ) 又被劃分為三個區域:Eden、From Survivor、To Survivor。這樣劃分的目的是為了使 JVM 能夠更好的管理堆記憶體中的物件,包括記憶體的分配以及回收。

新生代中一般儲存新出現的物件,所以每次垃圾收集時都發現大批物件死去,只有少量物件存活,便採用了複製演算法,只需要付出少量存活物件的複製成本就可以完成收集 。

老年代中一般儲存存活了很久的物件,他們存活率高、沒有額外空間對它進行分配擔保,就必須採用“標記-清理”或者“標記-整理”演算法。

永久代就是JVM的方法區/元空間。在這裡都是放著一些被虛擬機器載入的類資訊,靜態變數,常量等資料。這個區中的東西比老年代和新生代更不容易回收,效率特別低,文章上部分寫的有這個永久帶回收。垃圾回收不會發生在永久代,如果永久代滿了或者是超過了臨界值,會觸發完全垃圾回收(Full GC)。如果仔細檢視垃圾收集器的輸出資訊,就會發現永久代也是被回收的。這就是為什麼正確的永久代大小對避免Full GC是非常重要的原因。

一般是下面這三種GC方式

新生代 GC (Minor GC)

發生在新生代的垃圾回收動作,頻繁,速度快。「一般採用複製演算法回收垃圾」

老年代 GC (Major GC / Full GC)

發生在老年代的垃圾回收動作,出現了 Major GC 經常會伴隨至少一次 Minor GC(非絕對)。Major GC 的速度一般會比 Minor GC 慢十倍以上「可採用標記清楚法和標記整理法」。Full GC是清理整個堆空間,包括年輕代和老年代。

Minor GC 觸發條件一般為:

  1. eden區滿時,觸發MinorGC。即申請一個物件時,發現eden區不夠用,則觸發一次MinorGC。
  2. 新建立的物件大小 > Eden所剩空間時觸發Minor GC

Major GC和Full GC 觸發條件一般為: Major GC通常是跟full GC是等價的

  1. 每次晉升到老年代的物件平均大小>老年代剩餘空間
  2. MinorGC後存活的物件超過了老年代剩餘空間
  3. 永久代空間不足
  4. 執行System.gc()
  5. CMS GC異常
  6. 堆記憶體分配很大的物件

為什麼新生代要分Eden和兩個 Survivor 區域

  • 如果沒有Survivor,Eden區每進行一次Minor GC,存活的物件就會被送到老年代。老年代很快被填滿,觸發Major GC.老年代的記憶體空間遠大於新生代,進行一次Full GC消耗的時間比Minor GC長得多,所以需要分為Eden和Survivor。

  • Survivor的存在意義,就是減少被送到老年代的物件,進而減少Full GC的發生,Survivor的預篩選保證,只有經歷15次Minor GC還能在新生代中存活的物件,才會被送到老年代這裡的存活判斷是15次,對應到虛擬機器引數為 -XX:MaxTenuringThreshold 。為什麼是15,因為HotSpot會在物件頭中的標記欄位裡記錄年齡,分配到的空間僅有4位,所以最多隻能記錄到15」。有些大物件可以直接進老年代,-XX:PretenureSizeThreshold引數指定大於該數值的物件直接進入老年代避免在新生代的Eden和兩個Survivor區域來回複製,產生大量記憶體複製操作。但是隻對Serial和ParNew兩個新生代收集器有用

  • 設定兩個Survivor區最大的好處就是解決了碎片化,剛剛新建的物件在Eden中,經歷一次Minor GC,Eden中的存活物件就會被移動到第一塊survivor space S0,Eden被清空;等Eden區再滿了,就再觸發一次Minor GC,Eden和S0中的存活物件又會被複制送入第二塊survivor space S1(這個過程非常重要,因為這種複製演算法保證了S1中來自S0和Eden兩部分的存活物件佔用連續的記憶體空間,避免了碎片化的發生)

長期存活的物件將進入老年代

虛擬機器採用分代收集的思想來管理記憶體,那麼記憶體回收時就必須判斷哪些物件應該放在新生代,哪些物件應該放在老年代。因此虛擬機器給每個物件定義了一個物件年齡的計數器**,如果物件在 Eden 區出生,並且能夠被 Survivor 容納,將被移動到 Survivor 空間中,這時設定物件年齡為 1。物件在 Survivor 區中每「熬過」一次 Minor GC 年齡就加 1**,當年齡達到一定程度(預設 15) 就會被晉升到老年代。

關鍵字:引用記數 可達性分析 標記整理 標記清除 複製演算法 分代回收 垃圾收集器 Eden Form To Minor Major Full GC 逃逸分析 TLAB 年輕代 老年代 方法區回收

相關文章