深入理解JVM記憶體回收機制(不包含垃圾收集器)

bmilk發表於2020-07-16

目錄

  • 垃圾回收發生的區域
  • 如何判斷物件是否可以被回收
  • HotSpot實現
  • 垃圾回收演算法
  • JVM中使用的垃圾收集演算法
  • GC的分類
  • 總結
  • 參考資料

垃圾回收發生的區域

堆是java建立物件的區域(String物件在常量池中),也是垃圾回收最多的地方。但是除了堆空間還有方法區存在需要回收的垃圾

回收方法區

廢棄的常量
在常量池中存在一個字面量A,如果系統中沒有一個地方引用`A``,這時候發生垃圾回收,如果有必要這個字面量就會被清理出常量池。

注意是如果有必要。比如上一篇文章中引用的例子,就沒有回收字串。

無用的類
當滿足以下條件時,這個類就可以被回收,而不是一定會回收。

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

如何判斷物件是否可以被回收

java有一個非常大的好處就是會自動進行垃圾回收,而不用手動釋放物件所佔用的記憶體。當以一個物件不再被引用的時候就可以進行垃圾回收,那麼如何判斷一個物件是否在被使用呢?

引用計數法

引用計數法很簡單,只需要在物件建立之初給物件加一個引用計數器,每當有一個地方引用他就+1,引用失效就-1,當引用計數器為0,則物件不再被引用。每次垃圾回收,
只需要遍歷一遍所有的引用計數器就可以。但是對於迴圈引用,引用計數法則無法釋這兩個物件。

可達性分析演算法

通過一系列被稱為GC Root的物件為起點,從這些節點往下搜尋,搜尋走過的路徑稱之為引用鏈,當一個物件到GC Root沒有任何引用鏈的時候,則證明此物件不可達。

深入理解JVM記憶體回收機制(不包含垃圾收集器)
圖1  可達性分析示例圖

JVM中,可以被用作GC Root的物件有:

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

HotSpot實現

列舉根節點

對於根節點的列舉有如下的問題:

  1. 可以作為根節點(GC Roots)的節點主要是全域性性的引用(方法去中靜態屬性引用的物件和方法區中常量引用的物件)與執行上下文(棧中引用的物件)

  2. 在一次可達性分析過程中,不能出現分析過程中物件引用關係還在不斷變化的情況,否則無法保證分析結果的準確性,為了達到這一目的,GC過程中就必須停頓所有的java執行緒

  3. 垃圾收集時,手機執行緒會對棧上的記憶體進行掃描,看看哪些位置儲存了Reference型別,如果發現某個位置確實存的是Reference型別,整個Reference所引用的物件就可以作為根節點,
    他所能到達的物件都不能被回收。

  4. 棧上的本地變數表中只有一部分是Reference型別,而那些非Reference型別的資料對於垃圾回收毫無用處,但是如果對於棧進行全棧掃描將會是一種對時間和資源的浪費,尤其是暫停了使用者執行緒

解決方法
是否可以用額外的空間記錄下每個Reference的位置,這樣的話GC的時候從這個結構中直接讀取這個結構,而不用進行全棧掃描。事實上,大部分主流的虛擬機器也確實是這樣做的,
HotSpot為例,它使用一種OopMap的資料結構來儲存這類資訊。

一個棧意味著一個執行緒,而一個棧楨代表了一個方法,每個被JIT編譯過後的方法會在一些特定的位置記錄下OopMap記錄了執行到該方法的某條指令的時候,棧上和暫存器的哪些位置是引用,
這樣GC在掃描到這些棧的時候就會查詢這些OopMap就知道哪裡是引用。這些位置主要在:

  • 迴圈的末尾
  • 方法臨返回前/呼叫方法的call指令之後
  • 可能丟擲異常的位置
    而這些位置就被稱之為“安全點”,之所以要選擇一些特定位置來記錄OopMap,是因為如果對每條指令的位置都記錄OopMap的話,這些記錄就會比較大,那麼空間開銷就會顯得不值得。

GC發生時,程式首先執行到最近的一個安全點停下來,然後更新自己的OopMap,列舉根節點時,遞迴遍歷每個棧楨的OopMap,通過棧中記錄的被引用的物件的記憶體地址,即可找到這些物件。

安全點與安全區域

安全點
程式在執行時並不是任何時間都可以進行GC,只有到達有OopMap記錄的位置才可以執行GC,整個位置稱之為安全點

安全點的選定基本是以程式“是否具有讓程式長時間執行的特徵”為標準選定的。程式一般不會因為指令流太長而長時間執行(每個指令執行的時間都很短)。“長時間執行”
的典型特徵就是指令序列的服用,例如:迴圈、遞迴、方法呼叫。所以具有這些功能的指令才會產生安全點。

安全區域
安全區域指在這一段程式碼之中,引用關係不會發生變化,在這一段程式碼之中,任一點都是安全點。任何一個地方都可以中斷執行緒開始GC

當執行緒執行到安全區域後,首先標識自己已經進入安全區域,那麼這段時間JVM要發起GC時就不用管標記自己進入安全區的執行緒。執行緒要離開安全區時,首先需要先檢查
系統是否已經完成了根節點的選舉,如果完成則執行緒繼續執行,否則要繼續等待收到可以安全離開安全區的訊號。

如何保證GC發生時,所有的執行緒都跑到了安全點上呢?
當要進行GC的時候,會讓所有的執行緒都在安全點中斷,就有兩種方式:

  • 搶佔式中斷:不需要程式碼配合。當GC發生時,讓所有的執行緒都終端,然後讓不在安全點的執行緒繼續執行到安全點上。不過一般不採用這種方式
  • 主動式中斷:當GC需要中斷執行緒時,不對執行緒進行操作,僅設定一個標識。各個執行緒輪詢這個標識,當發現這個標識被設定時,使得程式執行到最進的安全點時,主動掛起。
    標識的設定和安全點是重合的,標識的設定和安全點是重合的。除此之外還有一個建立物件需要分配記憶體的地方。

垃圾回收演算法

假設存在如下的記憶體區域:

深入理解JVM記憶體回收機制(不包含垃圾收集器)
圖2  原始情況記憶體中物件的分佈
下文將以這塊記憶體為例進行垃圾收集演算法的分析

標記-清除演算法

顧名思義,標記清除演算法會為兩個階段,1-標記,2-清除。

  1. 標記:垃圾收集器從GC Roots出發,進行搜尋,然後對所有可以訪問的物件打上標識,標記其為可達的物件,標記一般儲存在header中
深入理解JVM記憶體回收機制(不包含垃圾收集器)
圖3  標記階段
  1. 清除:垃圾收集器對堆記憶體進行線性遍歷,如果發現某個物件沒有被標記為可達,就會將其回收,回收後效果如下圖
深入理解JVM記憶體回收機制(不包含垃圾收集器)
圖4  標記清除演算法進行垃圾回收

優點

  1. 實現簡單
  2. 與保守式GC演算法相容

缺點

  1. 記憶體碎片化嚴重
  2. 分配速度緩慢,由於空閒塊的維護是用連結串列實現的,分塊可能不連續,每次分配都需要遍歷連結串列,極端情況下要遍歷震整個連結串列。
  3. 標記和清除的效率都不高,

複製演算法

複製演算法,就是將記憶體劃分為相等的兩塊,每次只是用其中一塊,當這塊記憶體使用完了就將還存活的物件複製到另一塊,然後將這塊空間清理掉,這樣使得每次對記憶體的回收都是半區回收。
複製演算法的示意圖如下圖:

深入理解JVM記憶體回收機制(不包含垃圾收集器)
圖5  複製演算法

優點

  1. 記憶體分配時不用考慮碎片的情況只需要移動棧頂指標分配記憶體即可
  2. 實現簡單,高效

缺點

  1. 可用記憶體縮小為原來的一半

標記—整理演算法

複製演算法在物件存活較多的時候會進行較多的操作,如果物件全部存活複製將會進行100%,並且浪費50%的記憶體空間作為擔保。

標記—整理演算法和標記—清除演算法前半部分一樣,只是後續不是清理,而是讓所有存活的物件都向一端移動,然後清理掉邊界以外的記憶體。

深入理解JVM記憶體回收機制(不包含垃圾收集器)
圖6  標記整理演算法

JVM中使用的垃圾收集演算法

在當前主流的垃圾收集器當中(g1除外),基本都採用一種分代收集演算法。根據物件存活週期,將java堆分為新生堆和老年堆。對於新生堆,採用複製演算法,對於老年堆採用標記-清除或者標記-整理演算法。

研究人員發現大多數的物件都是“朝生夕滅”,對於這樣的物件,生存週期很短,可以將其放入新生堆,因為其生存時間很短,所以新生堆採用複製演算法的時候沒有必要使用1:1的比例劃分記憶體。
而是分為較大的Eden空間和兩塊較小的Suvivor空間;HotSpotEdenSuvivor的比例為8:1。回收時將Eden和一塊Suvivor上還存活的物件,一次性copy到另一塊Suvivor
上,然後清理掉以前的兩塊區域。這樣每次新生代可用的記憶體空間佔整個新生堆的90%,只有10%會被浪費。

我們沒有辦法保證新生代回收的時候只剩下不多於10%的物件存活。當Suvivor空間不夠用時,就需要依賴其他記憶體(老年堆)進行分配擔保。對於存活過一定gc次數的物件放進老年堆。

老年堆物件存活率高,使用複製演算法可能就需要1:1的空間,這樣就會浪費記憶體,因此使用的是標記-清除或者標記-整理演算法。

GC的分類

保守式GC

HotSpot虛擬機器在棧上使用OopMap記錄下了哪些位置是引用型別,根據記錄的型別型別開始查詢堆中存活的物件。

虛擬機器最初的實現當中是沒有記錄每個資料的型別的,JVM也無法區分記憶體裡某個位置的資料到底應該解讀為引用型別還是其他資料型別,這種條件下,實現出來的GC
就是“保守式GC”。在進行GC時,JVM開始從一些已知的位置(例如棧)開始掃描記憶體,掃描的時候每看到一個數字就看看它“像不像是一個指向GC堆中的指標”。
這裡會涉及上下邊界檢查(GC堆的上下界是已知的)、對齊檢查(通常分配空間的時候會有對齊要求,假如說是4位元組對齊,那麼不能被4整除的數字就肯定不是指標),之類的。然後遞迴的這麼掃描出去。

優點

  1. 實現簡單
    缺點
  2. 會有部分物件本來應該已經死了,但有疑似指標指向它們,使它們逃過GC的收集。會有一部分已經不需要的資料佔用著GC堆空間,但是所有應該存活的物件都會活著,對程式語義來說時安全的
  3. 由於是疑似指標,那麼就不知道這個到底是不是指標,所以這些值就都不能改寫。移動物件就需要改寫指標,也就是說物件不可移動,因此一般使用標記-清除的方式來進行垃圾回收。
    有一種辦法可以在使用保守式GC的同時支援物件的移動,那就是增加一個間接層,不直接通過指標來實現引用,而是新增一層“控制程式碼”(handle)在中間,所有引用先指到一個控制程式碼表裡,再從控制程式碼表找到實際物件。這樣,要移動物件的話,只要修改控制程式碼表裡的內容即可。

半保守式GC

保守式GC沒有在JVM中記錄任何型別資訊,半保守式GC會在物件上記錄型別資訊,這樣的話,掃描棧的時候仍然和保守式GC一樣,但是掃描到堆上的時候,物件上帶了足夠的型別資訊,
JVM就能判斷出棧中這個位置是不是一個指向堆中物件的指標,以及這個物件內什麼位置資料是引用型別,這種是“半保守式GC”,也稱之為“根上保守”。

由於半保守式GC在堆內部的資料是準確的,所以它可以在直接使用指標來實現引用的條件下支援部分物件的移動,方法是隻將保守掃描能直接掃到的物件設定為不可移動(pinned),而從它們出發再掃描到的物件就可以移動了。

準確式GC

對於垃圾回收,JVM關心的就是掃描的根節點是不是一個指向堆記憶體的指標,那麼就是在棧上記錄下那個位置式引用型別,是指向堆上物件的指標,在HotSpot虛擬機器中這個資料結構就是OopMap

總結

  1. 垃圾回收不止是發生在堆區,對於方法區中產生的垃圾有可能會被回收。在之前的從JDK原始碼理解java引用一文中舉了不會被回收的例子
  2. 虛擬機器一般採用引用可達性分析演算法來尋找不被使用的物件,其實尋找到的是正在被使用的物件,剩下的就是不再被使用的物件。
  3. 除了g1垃圾收集器。其他的垃圾收集器都有明顯的區分老年代和新生代進行垃圾回收,由於老年代和新生代物件存貨時間不一樣,採用不同的垃圾回收演算法

參考資料

相關文章