JVM資料區域與垃圾收集<深入理解JVM讀書筆記>

呼延十發表於2019-08-12

目錄

前言

周志明老師所著的《深入瞭解JAVA虛擬機器》(後文簡稱"書中")可謂是java工程師進階的必讀書籍了.最近讀了書中的第一二部分,也就是前五章,有很多收穫.因此想要寫一篇文章.來用自己理解到的知識來總結一下前五章.

雖然說是總結,但是仍然強烈推薦大家去看原著.原著並沒有"多出什麼東西導致需要我進行總結",而是每個小節都讓我有所收穫.但是我並不能全部記住書中所寫,只能按照自己的思路記錄,串聯起來. 再次推薦一下大家閱讀原著

自動記憶體管理機制

書中多次提到:

Java和C++之間有一堵由記憶體動態分配和垃圾收集所圍成的高牆,牆外的人想進去,牆裡的人想出來.

C/C++程式設計師對每一個物件的記憶體分配擁有絕對的控制權,但是這樣就會很繁瑣.Java程式設計師不用處理記憶體的分配,有JVM動態進行,在 出現記憶體洩漏的時候卻比較難以排查.

根據所new的物件動態的進行記憶體分配,以及在合適的時間回收/釋放掉不需要的物件,這就是JVM的自動記憶體管理機制.

執行時資料區域

JVM在執行java程式碼的時候,會將系統分配給他的記憶體劃分為幾個區域,來方便管理.比較經典的執行時資料區域圖如下:

2019-08-08-17-04-29

程式計數器:

程式計數器是一塊比較小的執行緒獨立的記憶體空間,它可以看成是當前執行緒執行的位元組碼的行號指示器.

虛擬機器棧

虛擬機器棧也是執行緒私有記憶體.每個方法在執行的時候都會建立一個"棧幀",裡面儲存了區域性變數表,運算元棧,動態連結,方法出口等資訊.可以理解為虛擬機器棧儲存了方法執行時需要的一些額外資訊,一個"棧幀"的入棧出棧對應了一個方法的執行開始與結束.

本地方法棧

如果我們將上面的虛擬機器棧理解為"為了java方法的執行而記錄一些內容",那麼本地方法棧就是為了Native方法二記錄的.其他方面基本一致.虛擬機器規範中對這一塊的規定不嚴格,因此各個虛擬機器的實現不同.著名的"HotSpot"把虛擬機器棧和本地方法棧進行了合併.

堆(Heap)是JVM記憶體中最大的一塊,也是垃圾收集的主要工作區域.這塊區域唯一的目的就是存放類的例項.堆中根據虛擬機器的不同還有不同的區域劃分,以便垃圾收集進行工作. 其中的詳細區域劃分在後面垃圾收集的地方會詳細說明.

方法區

方法區也是一塊執行緒共享區域,用於儲存已經載入了的類資訊,常量,靜態變數,即時編譯器編譯後的程式碼等等.

他有一個更加響亮的名字"永久代",HotSpot虛擬機器將方法區實現成了永久代,來避免單獨為方法區實現垃圾收集.這一舉動的利弊不是我個小菜雞可以分析的,但是我們要理解為什麼叫做永久代?因為這一區域存放的內容,垃圾收集的效率是比較低的(常量,靜態變數等較少需要被回收),所以當資料進入此區域,就好像永久存在了一下.

這一區域裡面還有一個單獨的區域,執行時常量池,當類載入後,各種字面量和符號引用會進入此區域. 在程式執行期間,也是可以將新的常量放入常量池的,比如string.intern()方法.

直接記憶體

直接記憶體並沒有在上圖的JVM執行時資料區域中體現,而是一塊額外的記憶體區域.在JDK1.4中引入的NIO中,可以直接通過Native方法在堆外分配記憶體.這樣可以提高效能.

這塊區域的大小不受到給虛擬機器分配的記憶體大小的限制,但是總歸也是受到物理機的記憶體限制的,因此,當出現OutOfMemoryError,且程式碼中有大量使用到NIO的時候,可以考慮到是這一塊記憶體產生了溢位.

記憶體分配

虛擬機器上物件的建立過程

說到物件的建立過程,也許我們都會想到那個很經典的題目:一個父類一個子類,幾個靜態方法幾個普通方法,幾個構造方法,問這些方法中的列印順序.

但是不要誤會,那些東西在現在並不重要了,需要機建立物件的過程要遠比這複雜的多.簡單概括如下:

  1. 當遇到new關鍵字的時候,首先檢查常量池中是否可以找到,並且檢查該類是否已經載入.如果沒有,先載入類.
  2. 按照確定的大小去獲取記憶體,獲取的方法分為指標碰撞空閒列表. 指標碰撞:如果記憶體是整齊的,左邊是使用過的,右邊是空閒的,那麼在分配空間的時候只需要移動一下指標即可. 空閒列表:如果記憶體是不規整的,使用過的和未使用的相互交錯,那麼JVM必須維護一個列表來記錄哪些空間是可用的. 具體使用哪種方法來分配記憶體取決於使用的垃圾收集器,因為有些垃圾收集器帶有整理記憶體的功能.那麼就可以使用指標碰撞了.
  3. 拿到分配的空間之後,要將記憶體全部初始化為零值.(不包括物件頭)
  4. 虛擬機器設定物件資訊,比如物件屬於的類的資訊,類的後設資料資訊,雜湊碼.GC分代資訊等.
  5. 現在才是執行構造方法,依次設定各個欄位的值.

在第二步其實還有一個問題,那就是併發問題,如果只有一個指標指在已經使用和未使用的記憶體之間,那麼在頻繁的建立過程中,一定有併發問題.虛擬機器解決這個問題的辦法主要有兩種:

  1. CAS加上失敗重試機制.
  2. TLAB. 本地執行緒分配緩衝,每個執行緒先從堆中申請一小塊記憶體,然後在這一塊記憶體上進行分配,這樣只需要在申請TLAB的時候才需要進行同步,增大了處理併發的能力.

建立的物件都包括了哪些資訊?

在HotSpot中, 物件資訊包括: 物件頭,例項資料和對齊填充.

物件頭: 物件頭中包括兩部分資訊,物件的執行資料(hash碼,GC年齡等),型別指標(指明它是哪個類的例項). 例項資料: 這塊的資料就是我們在程式碼中定義的那些欄位等等. 對齊填充: 這塊資料並不是必然存在的,當物件例項資料不是8位元組的整數倍的時候,用空白字元對齊一下.

物件記憶體的分配機制

物件記憶體分配其實與選擇的垃圾收集器,虛擬機器啟動引數等有很大的關係,因此並不能確定的說:XXX在XXX上分配.但是總歸是有一些普適性的規則的.

優先在Eden分配

大多數的情況下,物件首先在Eden區域分配,當Eden區域空間不足的時候,虛擬機器將會進行一次Minor GC(新生代GC).

大物件直接進入老年代

大物件(虛擬機器提供了引數:-XX:PretenureSizeThreshold來調整大物件的閾值)會直接分配在老年代.由於新生代使用複製的垃圾收集演算法,如果將大物件分配到新生代,可能會造成在兩個Survivor區域之間發生大量的記憶體複製.影響垃圾收集的效率.

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

每個物件都有一個年齡的計數器,當物件在eden出生並且經過一次minor GC還在新生代的話,年齡就加1. 當年齡到了15(預設值)時,會晉升到老年代中.

動態的年齡判斷

上面到達年齡之後晉升到老年代並不是唯一的規則, 當Survivor空間中的相同年齡的物件的總大小的綜合大於Survivor空間的一半,虛擬機器會認為這個年齡是一個更加合適的閾值,會將年齡大於或者等於這個值的物件全部移到老年代中去.

分配擔保

當minor GC即將發生時,虛擬機器會檢查老年代是否可以作為此次的分配擔保(老年代中的連續記憶體大於新生代中存活所有物件的總和),如果成立,那麼說明可以作為擔保,進行minorGC.

如果不成立,那就檢查虛擬機器設定裡面HandlePromotionFailure是否允許進行冒險,如果允許的話,則進行minorGC,否則則進行FullGC. 如果冒險失敗了,那就進行一次FullGC來在老年代騰出足夠的空間.

垃圾收集

說起垃圾收集,我們總是可以零碎的說上一些,因為JVM的應用太廣泛了,除了Java開發者還有許多其他基於JVM的開發者也需要了解這些. 但是我們有沒有系統的整理過這裡呢?

垃圾收集,即將無用的記憶體釋放掉,以提供給後續的程式使用.那麼就有三個問題:

  1. 對哪些記憶體進行回收?
  2. 什麼時候進行回收?
  3. 怎麼進行回收?

我們一個一個問題的來看.

對哪些記憶體進行回收?

當然是對死掉的,即再也不會用到的物件進行回收.

怎麼判斷一個物件再也不會被用到了呢?

引用計數法

首先就是引用計數法,它的思想是給每個物件設定一個計數器,每當有一個別的地方引用到了這個物件,計加器就加1.當其他地方釋放掉對它的引用時,就減1.那麼計數器等於0的物件,就是不可能再被引用的物件了.

這個演算法其實還可以,實現簡單,判斷速度快,但是主流的JVM實現裡面沒有使用這個方法的,因為它有一個比較致命的問題,就是無法解決迴圈引用的問題.

當兩個物件互相引用,除此之外沒有其他引用的時候,他們應該被回收,但是此時他們的計數器都為1.導致他們沒有辦法被回收.

我們用以下程式碼進行一下測試:

public class ReferenceCountTest {

    public static final byte[] MB1 = new byte[1024 * 1024];
    public ReferenceCountTest reference;

    public static void main(String[] args) {

        ReferenceCountTest a = new ReferenceCountTest();
        ReferenceCountTest b = new ReferenceCountTest();

        a.reference = b;
        b.reference = a;

        a = null;
        b = null;

        System.gc();

    }
}
複製程式碼

執行引數為:+XX:PrintGC,輸出結果[GC (System.gc()) 7057K->2294K(125952K), 0.0024641 secs],可以看到,記憶體被回收掉了,說明我使用的HotSpot虛擬機器使用的不是引用計數法來判斷物件存活與否.

可達性演算法

這個演算法的基本思想就是,通過一系列的GC ROOT來作為起點,從這些節點開始沿著引用鏈進行搜尋,當一個物件到GCROOTS沒有任何的可達路徑,就認為此物件是可被回收的.

2019-08-10-11-25-11

在上圖中,object5,6,7雖然互相之間還有引用,但是由於從GCROOTS不可達,也是死掉的物件.

在Java中GCROOTS一般包括以下幾種:

  • 虛擬機器棧中的棧幀中的本地變數表
  • 常量引用
  • 靜態屬性的引用
  • 本地方法棧中Native方法的引用

什麼時候進行回收?

這個問題其實比較複雜,且很多JVM的實現並不相同,我們粗略的以HotSpot為例說明一下.

首先我們要知道,垃圾收集是需要"Stop The World"的,因為如果整個JVM不暫停,那麼就無法在某一瞬間確定哪些記憶體需要回收.就好像你媽媽給你打掃房間的時候會把你趕出去,因為如果你不斷製造垃圾,是沒有辦法打掃乾淨的.

目前所有的JVM實現,在進行根節點的列舉(也就是確定哪些記憶體是需要回收的)這一步驟的時候都需要停頓,大家在做的只是儘可能的減少GC停頓來降低對系統的影響.

既然GC需要"Stop The World",但是一個執行中的先生並不是可以在隨時隨地停下來配合GC的.

所以當需要GC停頓的時候,需要給出一點時間,讓所有執行緒執行到最近的"安全點"上.此外,為了解決在GC時有些執行緒處在掛起狀態,安全點概念還有一個擴充套件的概念,安全區域,當執行緒進入到安全區域,就會掛起一個牌子,告訴別人在我摘下牌子之前,GC不用問我.而當執行緒想離開安全區域的時候,需要檢查是否自己可以安全離開的標識.

怎麼進行回收呢?

不同虛擬機器的實現不一樣,同一個虛擬機器在堆上不同的區域執行的可能也不一樣,不過總的來說,演算法思想都是下面這幾種.

標記清除

最基礎的就是**標記-清除(Mark-Sweep)**了,該演算法的過程和名字一樣,首先標記所有需要回收的物件,之後對他們統一進行回收.如下圖所示.

2019-08-10-11-53-57

他的優點是: 思路簡單且實現方便 缺點主要有兩個:

1.效率不太高
2.在圖中回收後的狀態裡,由於是直接的清除,所以可用記憶體不連續,全是碎片化的,這樣當後續需要分配大物件而無法找到連續足夠的空間,就會提前觸發下一次GC

後續的演算法主要就是對 標記-清除演算法的改進.

複製演算法

為了解決上面的問題,出現了"複製"演算法,複製演算法將記憶體分為容量相等的兩塊,每次只使用其中的一塊,當用完了,將其中存活的物件copy到另外一塊記憶體上,然後對已經使用的這一塊記憶體進行整體的回收. 這樣可以使得回收和分配時不用考慮碎片問題,效率極大的提升了,但是,代價是永遠只能使用一半的記憶體,這個代價太過於高昂了.

複製演算法的執行過程如下圖:

2019-08-10-16-17-37

現代的商業虛擬機器基本上都採用這個演算法來回收新生代.因為新生代的垃圾回收比較的頻繁,對於效率的要求更加高一些.

同時對複製演算法進行了一些改良.經過統計,新生代的物件98%都是朝生夕死的,所以複製演算法中的記憶體不需要按照1:1進行劃分,而是劃分為Eden:Survivor1:Survivor2=8:1:1(比例可調整)三塊空間,每次使用Eden和一個 Survivor區域,當需要垃圾回收時,將其中存活的物件copy到另一個survivor中.然後對eden和已經使用survivor進行統一回收.這樣相比於普通的複製演算法,每次可以使用到90%的空間,浪費較小.

但是,survivor的記憶體大小是我們進行估算得到的,我們沒有辦法確保每次垃圾回收時存活的物件都小於10%,所以需要老年代進行分配擔保.分配擔保是指,如果survivor的空間不夠用,可以在老年代裡申請空間存放物件.

標記-整理演算法(Mark-Compact)

複製演算法在物件存活率較低是一種可靠的演算法,但是當物件存活率較高,極端情況下,一次gc的時候,100%的物件都存活,那麼複製演算法的效率就不高了.因此在HotSpot的老年代中使用另外一種演算法.即標記-整理演算法.

標記-整理演算法,首先仍然是和標記-清除演算法一樣的標記過程,但是之後並不進行直接的清除,而是將存活的物件整理的整齊一點,然後以邊界為限,回收掉邊界以外的記憶體.示意圖如下:

2019-08-10-16-32-17

分代收集演算法

在上面的垃圾收集演算法中也提到了新生代,老年代等概念,這就是由於現在的虛擬機器都使用分代收集的演算法.

分代的主要目的是:根據物件的存活週期不同,把記憶體區域分為幾塊,存放不同生命週期的物件,以方便根據特點使用不同的垃圾收集演算法來提高記憶體回收效率.

比如新生代中物件存活率低,那麼可以使用複製演算法,每次copy少量的物件即可,且效率較高.

而老年代中的物件存活率高,並且沒有人能為他做分配擔保,因此必須使用標記-整理或者標記-清除演算法.

所以在 HotSpot中,整個Java堆大致是如下的樣子(新生代和老年代的比例預設為1:2):

2019-08-10-16-41-44

垃圾收集器

Serial收集器

這是最基本也是最古老的的垃圾收集器,是一個單執行緒收集的過程,目前仍然是Client模式下的JVM的預設新生代收集器.

下圖是他的收集過程:

2019-08-10-21-08-36

ParNew

ParNew收集器是Serial收集器的多執行緒版本,除了使用多條執行緒進行垃圾收集之外,其餘行為和Serial收集器一模一樣.下圖是他的收集過程:

2019-08-10-21-10-01

Parallel Scavenge收集器

這個收集器在定義上和ParNew非常相似,但是它主要關注的是提高系統的吞吐量.他的收集過程和ParNew相似.

Serial Old

Serial Old收集器是Serial收集器的老年代版本,使用了標記-整理演算法.他的收集過程和Serial一樣.

Parallel Old

這是Parallel Scaevnge收集器的老年代版本,使用多執行緒進行標記-整理演算法進行收集.

他的收集過程和Parallel Scavenge收集器一樣.

CMS收集器

Concurrent Mark Sweep 是一個以最短停頓時間為目的的收集器,他的收集過程更加複雜一點,分為四個步驟:

  • 出師表及
  • 併發標記
  • 重新標記
  • 併發清除

他的收集過程如下所示:

2019-08-10-21-19-28

G1收集器

G1收集器是發展的比較好的收集器,他的收集步驟大概有以下幾個部分:

  • 初始標記
  • 併發標記
  • 最終標記
  • 篩選回收

他的收集過程圖如下:

2019-08-10-21-27-56

總結

垃圾收集器並不是可以無限搭配的,下面是他們的搭配圖:

2019-08-10-21-29-50

這裡對垃圾收集器的介紹比較簡略,主要是垃圾收集器實際上是一個很複雜的東西,但是是一個封裝的很好的東西,裡面的複雜不太需要知道,大部分時間我們用穩定的最新的研究成果即可....

但是,我們應該瞭解一下,在感覺瓶頸出在了垃圾收集器的時候,有可以去詳細研究的能力以及基礎知識即可.

參考文章

深入理解JVM


完。



ChangeLog

2019-08-11 完成

以上皆為個人所思所得,如有錯誤歡迎評論區指正。

歡迎轉載,煩請署名並保留原文連結。

聯絡郵箱:huyanshi2580@gmail.com

更多學習筆記見個人部落格------>呼延十

相關文章