垃圾收集機制與記憶體分配策略

YangAM發表於2019-02-26

Java 語言與其他程式語言有一個非常突出的特點,自動化記憶體管理機制。而這種機制離不開高效率的垃圾收集器(Garbage Collection)與合理的記憶體分配策略,這也是本篇文章將要描述的兩個核心點。

引一句周志明老師對 Java 中的記憶體管理機制的描述:

Java 與 C++ 之間有一堵有記憶體動態分配和垃圾收集技術所圍成的「高牆」,牆外面的人想進去,牆裡面的人卻想出來。

各有各的優勢,沒有誰會替代誰,只是應用在不同的場合下,誰更適合而已。

可達性分析演算法

Java 中使用「可達性分析演算法」來判定堆中的垃圾,但是很多其他的程式語言都採用「引用計數演算法」判斷物件是否依然存活。例如,Python,C++ 以及一些遊戲指令碼語言就採用的「引用計數演算法」來判定物件的存活與否。

引用計數演算法:給每一個引用物件增加一個計數器,每當有一個地方引用了該物件,就使該物件的計數器加一,每當一個引用失效時就使該計數器減一。當進行垃圾判定的時候,如果某個物件的計數器為零即說明了該物件無人引用,是垃圾。

這種演算法設計簡單,效率高,但 Java 裡為什麼沒有采用呢?

主要是引用計數演算法存在一個很致命的問題,迴圈引用。我們看一段程式碼:

public class A {
    private B bRef;

    public B getbRef() {
        return bRef;
    }

    public void setbRef(B bRef) {
        this.bRef = bRef;
    }
}
複製程式碼
public class B {
    private A aRef;

    public A getaRef() {
        return aRef;
    }

    public void setaRef(A aRef) {
        this.aRef = aRef;
    }
}
複製程式碼

產生迴圈引用:

public static void main(String[] args){
    A obj1 = new A();
    B obj2 = new B();
    obj1.setbRef(obj2);
    obj2.setaRef(obj1);
    
    obj1 = null;
    obj2 = null;
}
複製程式碼

他們的記憶體佈局如下:

image

依照引用計數演算法,棧中 obj1 對堆中 A 的物件有一個引用,因此計數器增一,obj2 對堆中 B 的物件有一個引用,計數器增一。然後這兩個物件中的欄位又互相引用了,各自的計數器增一。

然後我們讓 obj1 和 obj2 分別失去對堆中的引用,按照常理來說,堆中的這兩個物件已經無用了,應該被回收記憶體。但是你會發現,採用引用計數演算法的程式語言不會回收這兩個物件的記憶體空間,因為它們內部互相引用,計數器都不為零。

這就是「迴圈引用」問題,引用計數演算法是無法辨別堆中的這兩個物件已經無用了,所以程式中如果大量互相引用的程式碼,收集器將無法回收這部分無用的垃圾,即產生記憶體洩露問題。

但是,如果上述邏輯由 Java 語言實現,執行結果會告訴你,GC 回收了這部分垃圾。看看 GC 日誌:

image

粗糙點來說,原先堆中的兩個物件加上堆中一些其他物件總共佔用了 2302K 記憶體空間,經過 GC 後,顯然這兩個物件所佔的記憶體空間被釋放了。

既然如此,那麼 Java 採用的「可達性分析演算法」是如何避免這一類問題的呢?

可達性分析演算法:從「GC Roots」為起始點,遍歷引用鏈,所有能夠直接或者間接被「GC Roots」引用的物件都判定為存活,其他所有物件都將在 GC 工作時被回收。

那麼這些根結點(GC Roots)的如何選擇將直接決定了 GC 收集效率的高低。Java 中,規定以下的物件可以作為 GC Roots:

  • 虛擬機器棧中引用的物件
  • 方法區中類屬性引用的物件
  • 方法區常量引用的物件
  • 本地方法棧中 Native 方法引用的物件

整體上來看,這幾種物件都是隨時可能被使用的,不能輕易釋放,或者說,這些物件的存活性極高,所以它們關聯著的物件都不能被回收記憶體。

HotSpot 中可達性演算法的實現

可達性分析的第一步就是列舉出所有的根結點(GC Roots),然後才能去遍歷標記所有不可達物件。而實際上,HotSpot 的實現並沒有按序列舉所有的虛擬機器棧,方法區等區域進行根結點查詢,而是使用了 OopMap 這種資料結構來實現列舉操作的。

堆中的每個物件在自己的型別資訊中都儲存有一個 OopMap 結構,記錄了物件內引用型別的偏移量,也就是說,通過該物件可以得到該物件內部引用的所有其他物件的引用。

對於虛擬機器棧來說,編譯器會在每個方法的某些特殊位置使用 OopMap 記錄當前時刻棧中哪些位置存放有引用。

於是 GC 在進行可達性分析的時候,無需遍歷所有的棧和方法區,只需要遍歷一下各個執行緒當前的 OopMap 即可完成根結點列舉操作,接著遞迴標記可達物件就行了。

理解了 HotSpot 是如何列舉根結點的,那麼對於安全點這個概念就很好理解了,所有生成 OopMap 更新的位置就叫做安全點。當系統發起 GC 請求的時候,需要中斷所有執行緒的活動,而並不是執行緒的任何狀態下都適合 GC 的,必須在停下來之前完成 OopMap 的更新,這樣會方便 GC 列舉跟結點。

所以,我們說執行緒收到中斷請求的時候,需要「跑」到最近的安全點才能停下,這是因為安全點的位置會完成 OopMap 的更新,以保證各個位置的物件引用關係不再改變。(你想啊,GC 根據 OopMap 進行根結點列舉,離上一次 OopMap 你已經做了一大堆事情了,改變了棧上很多物件的引用關係,難道你在停下來被 GC 之前不應該把你所做的這些操作記錄下來嗎?不然 GC 哪知道哪些物件已經不用了,哪些物件你又重新引用了?)

那安全區域又是一個什麼樣的概念呢?

安全區域是指,一段程式碼的執行不會更改引用關係,這段程式碼所處的範圍可以理解為一個區域,某個執行緒在這個區域中執行的時候,只要標誌自己進入了安全區域,就不用理會系統發起的 GC 請求而可以繼續執行。

程式離開安全區域之前,會檢查系統是否已經完成了 GC 過程,如果沒有則等待,否則「走」出安全區域,繼續執行後續指令。

安全區域實際上是安全點的一個擴充套件,安全區域中執行的執行緒可以與 GC 垃圾收集執行緒併發工作,這是它最大的一個特點。

四大引用

Java 裡的引用本質上類似於 C 語言中的指標,變數中的值是記憶體中另一塊的地址,而並非實際的資料。Java 中有四種引用,它們各自有不同的生命範圍。

  • 強引用,類似於 String s = new String(); 這類引用,s 就是一種強引用,只要 s 通過這種方式強引用堆中物件,GC 永遠都不能回收被引用的物件的記憶體
  • 軟引用,用於描述某些還有用但並非必必需的物件,某次 GC 操作後,如果記憶體還是不足以用於當前分配,也就是即將發生記憶體溢位,那麼將回收所有軟引用所佔用的記憶體空間
  • 弱引用,用於描述一些非必需的物件引用,當垃圾收集器工作時,不論當前記憶體空間是否充足,都會回收這一部分記憶體空間
  • 虛引用,又稱幽靈引用,這是一種最弱的引用,即便 GC 沒有工作,我也無法拿到這類引用指向的物件了

除了強引用,其他的三類引用實際中很少使用,關於它們的測試程式碼,將隨著本篇文章一起脫管在我的 GitHub 上,感興趣的可以去 fork 回去執行一下,此處不再贅述。

垃圾收集演算法

垃圾收集演算法的實現是很複雜的,並且不同平臺的虛擬機器也有著不同的實現,但是單看收集演算法本身而言,還是相對容易理解的。

標記-清除演算法

標記清除演算法實現思路包含兩個階段,第一個階段,根據可達性分析演算法標記所有不可達的「垃圾」,第二階段,直接釋放這些物件所佔用的記憶體空間。

image

但是,它的缺點也很明顯,做一次清除操作至少要遍歷兩次堆,一次用於標記,一次用於清除。並且整個堆記憶體會存在大量的記憶體碎片,一旦遇到大物件,將無法提供連續的記憶體空間而不得不提前觸發一次 Full GC。

複製演算法

複製演算法將記憶體劃分為兩份大小相等的塊,每次只使用其中的一塊,當系統發起 GC 收集動作時,將當前塊中依然存活的物件全部複製到另一塊中,並整塊的釋放當前塊所佔記憶體空間。

image

這種演算法不需要挨個去遍歷清除,整體上釋放記憶體,相對而言,效率是提高了,但是需要浪費一半的記憶體空間,有點浪費。

根據 IBM 公司的研究表明,「新生代」中的物件往往都是「朝生夕死」的,也就是說,我們完全沒有必要舍掉一半的記憶體用於轉移 GC 後存活的物件,因為活著的物件很少。

主流的商業虛擬機器都採用複製演算法對新生代進行垃圾收集,但是卻將記憶體劃分三個塊,一塊較大的 Eden 區和兩塊較小的 Survivor 區。

image

Eden 和 From 區域用於分配新生代物件的記憶體空間,當發生 Minor GC 的時候,虛擬機器會將 Eden 和 From 中所有存活的物件全部移動到 To 區域並釋放 Eden 和 From 的記憶體空間。

這樣不僅解決了效率問題,也解決了空間浪費的問題,但是存在的問題是,如果不巧,某次 Minor GC 後,活著的物件很多,To 區放不下怎麼辦?

虛擬機器的做法是,將這些物件往老年代晉升,具體的後文詳細介紹。

標記-整理演算法

標記整理演算法一般用在老年代,它在標記清除演算法的基礎上,增加了一個步驟用於對將所有存活著的物件往一端移動以解決記憶體碎片問題。這種演算法適用於老年代的垃圾回收,因為老年代的物件存活性高,每次只需要移動很少的次數即能完成垃圾的清理。

image

垃圾收集器

從可達性分析演算法判定哪些物件不可達,標記為「垃圾」,到回收演算法實現記憶體的釋放操作,這些都是理論,而垃圾收集器才是這些演算法的實際實現。虛擬機器中使用不同的垃圾收集器收集不同分代中的「垃圾」,每種垃圾收集器都具有各自的特點,也適用於不同的場合,需要適時組合使用。但並不是任意的兩個收集器都能組合工作的:

image

可以看到,新生代主要有三款收集器,老年代也有三款收集器,G1(Garbage First)是一款號稱能一統所有分代的收集器,當然還不成熟。

收集器很多,本文限於篇幅不可能每一個都詳細的介紹,只能簡單的描述一下各個收集器的特點和優劣之處。

  • Serial:新生代的單執行緒垃圾收集器,適用於單 CPU,待收集記憶體不大的場景下,速度快高效率,是客戶端模式下虛擬機器首選的新生代收集器
  • ParNew:是 Serial 收集器的多執行緒版本,適用於多 CPU 多執行緒下的垃圾收集,是服務端虛擬機器的首選收集器
  • Parallel Sacenge:類似於 ParNew,但卻是一個注重吞吐量的收集器,可以顯式指定收集器達到什麼層次的吞吐量
  • Serial Old:Serial 的老年代版本,採用的標記整理演算法收集垃圾
  • Parallel Old:Parallel 的老年代版本
  • CMS:這是一款基於標記清除演算法收集新生代的收集器,主要特點是,低停頓時間,容易產生浮動垃圾

關於垃圾收集器的細節內容,很多,文章中不可能描述清楚,大家可以參閱相關書籍及論文進行學習。

記憶體分配策略

Java 物件的記憶體都分配在堆中,準確來說,新生的物件都分配在新生代的 Eden 區中,如果 Eden 區域不足以存放一些物件的時候,系統將發起一次 Minor GC 清除並複製依然存活的物件到 Survivor 區,一旦 Survivor 區域不夠存放,將通過記憶體擔保機制將這些物件移入老年代。下面我們用程式碼具體看一看:

//VM:-XX:+PrintGCDetails -Xms10M -Xmx10M -Xmn5M
//限制了 10M 的堆記憶體,其中新生代和老年代分別佔 5M
byte[] buffer = new byte[2 * 1024 * 1024];
複製程式碼
image

新生代收集器預設 Eden 與 Survivor 的比例為是 8:1。這裡我們看到新生代已使用空間 4032K,其中一部分是我們兩兆的位元組陣列,其餘的是一些系統的物件記憶體分配。

如果我們還要再分配一兆大小的記憶體空間呢?

//VM:-XX:+PrintGCDetails -Xms10M -Xmx10M -Xmn5M
byte[] buffer = new byte[2 * 1024 * 1024];
byte[] buffer1 = new byte[1 * 1024 * 1024];
複製程式碼
image

虛擬機器首先會檢查一下新生代還能不能再分出一兆的記憶體空間出來,發現不能,於是發起 MinorGC 回收新生代堆空間,並將依然存活的物件複製到另一塊 Survivor 空間(to),發現 512K 根本放不下 buffer,於是通過擔保機制將 buffer 送入老年代,接著為 buffer1 分配一兆的記憶體空間。

接著,我們來看看這個擔保機制是怎樣的?

當實際發生 MinorGC 之前,虛擬機器會檢視老年代最大可用的連續空間是否能容納新生代當前所有物件,因為它假設此次 MinorGC 後,新生代所有物件都能夠存活下來。

如果條件能夠成立,虛擬機器認為此次 GC 毫無風險,將直接進行 MinorGC 對新生代進行垃圾回收,否則虛擬機器會去檢視 HandlePromotionFailure 引數設定的值是否允許「擔保失敗」。

如果允許,那麼虛擬機器將繼續判斷老年代最大可用連續空間是否大於歷屆晉升過來的新生代物件的平均大小

如果大於,那麼虛擬機器將冒著風險去進行 MinorGC 操作,否則將改為一次 FullGC。

取平均值的這種概率方法能大概率的保證安全擔保,但也不乏擔保失敗的情況出現,一旦擔保失敗,虛擬機器將發起 FullGC 對整個堆進行掃描回收。看一段程式碼:

//VM:-XX:+PrintGCDetails -Xms10M -Xmx10M -Xmn5M
//系統物件佔用大約 2M 堆空間
byte[] buffer = new byte[1 * 1024 * 1024];
byte[] buffer1 = new byte[1 * 1024 * 1024];
//此時新生代所剩下的空間大約 512K
byte[] buffer2 = new byte[1 * 1024 * 1024];
複製程式碼

當我們的 buffer 和 buffer1 分配進 Eden 區之後,新生代剩下不足一兆的記憶體空間,但是當我們分配一個一兆的位元組陣列時,系統檢視老年代空間為 5M 能夠容納新生代所有存活物件(4M 左右),於是直接發起 MinorGC,回收了新生代中部分物件並嘗試著將活著的物件複製到 to 區塊中。

顯然,to 區域不能容納這麼多物件,於是全部晉升進入老年代。

接著為 buffer2 分配 1M 記憶體空間在 Eden 區,GC 日誌如下:

image

可以看到,buffer 和 buffer1 已經被擔保進入老年代了,而 buffer2 則被分配在了新生代中。MinorGC 之前,新生代中大約 4M 的物件在 MinorGC 後只剩下 504K 了,其中 2M 左右的物件被擔保進入了老年代,還有一部分則被回收了記憶體。

總結一下,本篇文章介紹了虛擬機器判定垃圾的「可達性分析演算法」,幾種垃圾回收演算法,還簡單的描述不同垃圾收集器各自的特點及應用場景。最後我們通過一些程式碼瞭解了虛擬機器是如何分配記憶體給新生物件的。

總的來說,這隻能算做一篇科普類文章,幫助你瞭解相關概念,其他的相關深入細節之處,還有待深入學習。


文章中的所有程式碼、圖片、檔案都雲端儲存在我的 GitHub 上:

(https://github.com/SingleYam/overview_java)

歡迎關注微信公眾號:撲在程式碼上的高爾基,所有文章都將同步在公眾號上。

image

相關文章