垃圾收集機制與內存分配策略

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

引一句周志明老師對 Java 中的內存管理機制的描述:git

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

各有各的優點,沒有誰會替代誰,只是應用在不一樣的場合下,誰更適合而已。算法

可達性分析算法

Java 中使用「可達性分析算法」來斷定堆中的垃圾,可是不少其餘的編程語言都採用「引用計數算法」判斷對象是否依然存活。例如,Python,C++ 以及一些遊戲腳本語言就採用的「引用計數算法」來斷定對象的存活與否。編程

引用計數算法:給每個引用對象增長一個計數器,每當有一個地方引用了該對象,就使該對象的計數器加一,每當一個引用失效時就使該計數器減一。當進行垃圾斷定的時候,若是某個對象的計數器爲零即說明了該對象無人引用,是垃圾。數組

這種算法設計簡單,效率高,但 Java 裏爲何沒有采用呢?安全

主要是引用計數算法存在一個很致命的問題,循環引用。咱們看一段代碼:bash

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
相關文章
相關標籤/搜索