Java垃圾收集的藝術

卡巴拉的樹發表於2018-01-03

垃圾收集(Garbage Collection),簡稱GC,是Java語言一個成名特性,使它擺脫了C、C++那樣手動管理記憶體的痛苦,提到垃圾收集,必然想到它是幹什麼的?簡單來說,它是我們管理堆記憶體和方法區上的空間的好助手,要想對垃圾收集建立最基本的認識,最起碼能夠回答:

1 .垃圾收集什麼時候發生?

2 .垃圾收集回收什麼物件?

3 .垃圾回收時做了什麼事情?

回答這些問題必須知道Java的垃圾回收是按代的垃圾回收機制。Java裡面沒有顯示的登出記憶體的方式,有人可能說Java裡面有finalize()方法,但是這個方法絕對不是C++中的解構函式,而且執行的時機也是不確定甚至是否執行也是未知的,也有可能使用System.gc(),但是這個方法會顯著的影響系統效能,不建議過多使用。

首先簡略的回答下上面的三個問題。1.一般發現空間不夠或者其它時機會觸發GC,GC又分為minor GC/full GC,下面會詳細展開說。 2. Java回收那些從GC roots開始不可達的物件3. 主要做的就是停止執行緒,標記記憶體,有的會複製清理,有的會標記清理,取決於具體的垃圾回收演算法。

上面只是一個粗淺的印象,下面來說說按代的垃圾回收機制。

按代的垃圾回收機制

我在淺析JVM記憶體分割槽中提到過Java堆分為新生代和老年代。

新生代(Young Gen)

新生代的目標就是儘可能快速的收集掉那些生命週期短的物件,大多數物件可謂是朝生夕死,GC的頻率也比較高,總的來說它有3個空間。

  • 1個Eden空間(伊甸園)
  • 2個Survivor空間(倖存者)

Java垃圾收集的藝術
物件儲存在Eden和from survivor區,minor GC執行時,Eden中的倖存物件會被複制到to Survivor(同時物件年齡會增加1)。而from survivor區中的倖存物件會考慮物件年齡,如果年齡沒達到閾值,物件依然複製到to survivor中。如果物件達到閾值那麼將被移到老年代。複製階段完成後,Eden和From倖存區中只儲存死物件,可以視為清空。如果在複製過程中to倖存區被填滿了,剩餘的物件將被放到老年代。最後,From survivor和to survivor會調換一下名字,下次Minor GC時,To survivor變為From Survivor。

Eden空間和Survior空間的空間比例預設是8:1,通過引數-XX: SurvivorRatio=8來控制,也可以設定為別的值。

老年代(Old Gen)

物件沒有變得不可達(後面會說到不可達即代表物件還保持使用),並且能夠從新生代的多次GC中存活下來,就會被拷貝到老年代,其佔用的空間也比新生代要多,所以老年代內發生GC的次數明顯要少得多,老年代的GC事件一般是在空間已滿時發生,執行的過程根據GC型別的不同而有所區別。老年代滿時觸發FullGC(Major GC),因為老年代中的物件比較“能活”,所以FullGC觸發的頻率較低。

具體來說,虛擬機器給每個物件定義了一個物件年齡(Age)計數器。如果物件在Eden出生並且經歷過一次minor GC仍然存活就會被轉移到Survivor,這時候它的Age也增加1,物件在Survivor區每熬過一次minor GC,年齡就增加1,當增長到一定程度(預設15歲),就會晉升到老年代中,這個程度可以通過-XX: MaxTenuringThreshold設定。

當然按照年齡進入老年代也不是絕對的,虛擬機器還支援動態年齡判定,**當Survivor空間中相同年齡所有物件大小的綜合大於Survivor空間的一半,年齡大於等於該年齡的物件就可以直接進入老年代,無需等到MaxTenuringThreshold設定的年齡。**還有一個特例是一般大物件直接在老年代分配,避免大物件在各個區域間來回拷貝,造成效能損失,不過最好的方法是不要new過多的大物件。

永久代(Permanent Gen)

在很多地方我們還能看到永久代的說法,其實就是JVM記憶體分割槽裡的方法區,HotSpot在1.7以前把方法區和堆放在一起做垃圾收集的,所以方法區又叫永久代。主要存放靜態檔案,如Java類、方法等。永久代對垃圾回收沒有顯著影響,但是現如今,例如Spring或者JSP都大量利用反射,動態代理,CGLib生成大量的Class,這時候我們需要設定一個比較大的永久代空間,防止方法區發生記憶體溢位。至於1.8已經使用Metaspace了。

上面說的是分代垃圾收集的思想,但是有個經常提到卻還沒有解答的問題,我們在每一次GC都會保留存活的物件,那麼如何判斷出哪些物件時存活的,哪些物件又是要清理的呢?這也是我們一開始提的問題中的一個,即垃圾收集回收什麼物件?

物件判活演算法

引用計數法

顧名思義,引用計數法就是在每一個物件上繫結一個計數器,當有一個地方引用該物件時,引用計數值就加1,引用失效時,計數值就會減1,當計數值為0時,說明物件不再被使用,這時候就可以看作無效物件了。就我所知C++的智慧指標和Objective C中ARC都利用了引用計數,有關智慧指標可以參考我的C++11 智慧指標

但是在Java虛擬機器的實現中並沒有採用引用計數法,其核心原因就是因為物件間的相互迴圈引用

class C{
  public Object x;
}
C obj1、obj2 = new C();
obj1.x = obj2;
obj2.x = obj1;
obj1、obj2 = null;
複製程式碼

obj1和obj2相互持有對方的引用,所以GC收集器無法回收它們。

可達性分析演算法

在主流的支援GC的語言中,都是通過可達性分析來判斷物件是否存活的。演算法思想就是通過一系列的GC Roots作為起始點,然後往下搜尋,能夠連線到GC Roots的,都證明還是活的,如果斷鏈子了,則證明不可達,也就是物件不是存活的。

Java垃圾收集的藝術
如圖所示,藍色的全部能夠直接或者間接的連結到GC Roots,Obj6、Obj7、Obj8雖然彼此相連,但是無法連結到GC Roots,所以他們都是不可達的,將會被判定為可回收的

那麼哪些物件會成為GC Roots呢,可以分為以下幾種:

  • 虛擬機器棧(棧幀的本地變數表)中引用的物件
  • 方法區中類靜態屬性引用的物件
  • 方法區中常量引用的物件
  • 本地方法棧中JNI(即一般說的Native方法)引用的物件

知道什麼物件需要回收,上面也說了分代回收的思想,那麼具體在回收的時候,記憶體是怎麼做的呢?那就不得不整理一下Java垃圾回收的常見演算法。

垃圾回收演算法

標記清除演算法

標記清除即Mark-Sweep,是一種最簡單的收集演算法。在經歷過物件判活以後,我們把需要回收的物件標記出來,然後在統一時刻回收所有被標記的物件。如圖所示:

Java垃圾收集的藝術

黑色標記的可回收物件在回收後全部變成未使用空間,但是這樣回收後有木有發現空間碎片很多,碎片太多就會導致再分配稍微大點的空間時,找不到這樣的連續記憶體,從而導致GC會被頻繁呼叫,所以標記清除是一種基礎的垃圾收集演算法,其它演算法基本都是以它為基礎優化產生。

複製演算法

複製演算法的思想就是把記憶體分為兩塊,每次只在一邊分配記憶體,當一邊的記憶體用完了,就把所有還存活的物件複製到另一半去,這時候把原來使用過的這一邊的所有空間一次性清理掉,所以也就不存在記憶體碎片的問題了,基本思路如圖:

Java垃圾收集的藝術

其實前面提到的分代GC演算法在新生代區域就用了複製演算法,並且也沒有分成1:1,而是8:1,也就是所謂的Eden區和survivor區,大多數物件都是“朝生夕死”的,所以在minorGC時,只把存活下來的物件全部複製到survivor區,具體的賦值過程前文中也提到過,在此不再複述。

標記整理演算法

上面提到的賦值演算法也有它的弱點,就是當物件存活率很高的時候,就會存在很多的複製操作,從而影響了效率。所以這種演算法運用在老年代的話很明顯不合適,於是又有了標記整理演算法,這種演算法的主要思路就是把活躍物件標記出來,之後再向記憶體的一側移動,然後直接清理掉端邊界以外的記憶體,具體思路如下:

Java垃圾收集的藝術
因為老年代需要清理的物件比較少,所以這種移動也會比較少。

分代收集演算法

分代收集演算法是前面提到的演算法的綜合之作,當前的商業虛擬機器的垃圾收集都採用分代收集,一般新生代採用複製演算法,老年代採用“標記-清理”或者“標記-整理”。

知道這麼所垃圾收集演算法,它們的實現也是繁多的,這裡介紹幾種主流實現,很多都是屹立多年的經典垃圾收集器了,當然新的收集器一直不斷的在被開發中。

垃圾收集器

目前看主流的垃圾收集器也就下面這些,其中Serial、ParNew、Parallel Scavenge主要應用在新生代,CMS、SerialOld、Parallel Old主要應用在老年代,而G1的回收範圍是整個Java堆(包括新生代和老年代)。有連線的說明彼此之間可以結合使用。

Java垃圾收集的藝術

  • Serial收集器(複製演算法): 新生代單執行緒收集器,標記和清理都是單執行緒,優點是簡單高效;

  • Serial Old收集器 (標記-整理演算法): 老年代單執行緒收集器,Serial收集器的老年代版本;

  • ParNew收集器 (複製演算法): 新生代收並行集器,實際上是Serial收集器的多執行緒版本,在多核CPU環境下有著比Serial更好的表現;

  • Parallel Scavenge收集器 (複製演算法): 新生代並行收集器,追求高吞吐量,高效利用 CPU。吞吐量 = 使用者執行緒時間/(使用者執行緒時間+GC執行緒時間),高吞吐量可以高效率的利用CPU時間,儘快完成程式的運算任務,適合後臺應用等對互動相應要求不高的場景;

  • Parallel Old收集器 (標記-整理演算法): 老年代並行收集器,吞吐量優先,Parallel Scavenge收集器的老年代版本;

  • CMS(Concurrent Mark Sweep)收集器(標記-清除演算法): 老年代並行收集器,以獲取最短回收停頓時間為目標的收集器,具有高併發、低停頓的特點,追求最短GC回收停頓時間。

  • G1(Garbage First)收集器 (標記-整理演算法): Java堆並行收集器,G1收集器是JDK1.7提供的一個新收集器,G1收集器基於“標記-整理”演算法實現,也就是說不會產生記憶體碎片。此外,G1收集器不同於之前的收集器的一個重要特點是:G1回收的範圍是整個Java堆(包括新生代,老年代),而前六種收集器回收的範圍僅限於新生代或老年代。

有空再整理每個垃圾收集器具體的實現。

相關文章