1. 程式人生 > >Java垃圾回收總結

Java垃圾回收總結

Java的垃圾回收(Garbage Collection, GC)機制一直面試最常見的問題,暑假在家看了深入理解Java虛擬機器一書,對著方面有了一定的瞭解,最近又在部落格上看見別人的總結,所幸自己也試著總結一下,談談自己的理解。

談到垃圾回收,首先應該想到的就是三個大問題:

  • 那些記憶體需要回收
  • 如何回收
  • 什麼時候回收

瞭解了這三個問題,就對jvm的記憶體機制有了整體的瞭解。接下來分別討論下這三個問題。

一、哪些記憶體需要回收?

Java中幾乎所有物件例項都存放在堆中,垃圾收集器在垃圾回收之前,首先要確定,哪些物件需要被回收。

1、引用計數法

記得以前在高階作業系統的課上就背過引用計數法:

給物件中新增一個引用計數器,每當有一個地方引用它時,計數值就加1;檔引用失效時,計數值就減1;任何時刻計數器為0的物件就是不可能再被使用的。

這種方法簡單高效,但是Java虛擬機器中普遍沒有采用這種方法,因為他無法解決物件間相互迴圈引用的問題

        ListNode a=new ListNode();
        ListNode b=new ListNode();
        a.next=b;
        b.next=a;
        a=null;
        b=null;

在上面的程式碼中,物件a、b的next相互指向對方,除此之外再沒有任何引用,實際上這兩個物件已經不可能再被訪問,然而如果採用引用計數法,兩個物件的計數都不為0,因為他們互相指向對方,導致垃圾回收器無法回收他們。
但是實際Java虛擬機器中並不會出現這個問題,因為他們普遍採用的都是下面的方法。

2、可達性分析演算法

在主流的商用程式語言的主流實現中,都是採用可達性分析演算法來判定物件是否存活的。

這個演算法的基本思想就是通過一系列成為”GC Roots“的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑成為引用鏈,檔一個物件到GC Roots沒有任何引用鏈相連時,則證明此物件是不可用的。

可達性演算法

例如上圖所示,物件object5、object6、object7雖然互相有關聯,但是他們到GC Roots是不可達的,所以他們將會被判定為是可回收的物件。

Java中,可以作為GC Roots的物件包括下面幾種:

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

3、死前的掙扎

一個物件並不是被判定為不可達,就一定會被回收,因為它還會經歷一個兩次標記的過程

(1)第一次:判斷為不可達後,會進行第一次標記,並進行篩選以判斷該物件是否執行finalize()方法,篩選的條件是:

  • 物件沒有覆蓋finalize()方法
  • 該物件的finalize()方法已經被虛擬機器呼叫過

這兩種情況下,都不會再執行finalize()方法。
當然,即使判定為有必要執行,虛擬機器也不一定保證這個方法就一定會被完全執行。這裡是我自己的理解,因為書中寫到虛擬機器會把它放在一個優先順序比較低的執行緒中,並不承諾會等待它執行結束。
反正不管怎樣,finalize()方法都是物件逃脫死亡的最後機會。

(2)第二次:書上介紹的沒有看太懂,我個人理解就是在執行finalize()的物件中小規模的標記,如果還是沒有指向它,那麼就會被清除。

有一點值得注意的是很多書上都不推薦使用finalize()方法,所以我也沒有進行深入的學習。

二、如何回收

確定哪些記憶體需要回收以後,就要知道虛擬機器如何回收他們。這裡主要需要掌握堆裡面的新生代老年代和對應的回收演算法兩大部分。

由於Java中堆可以具體細分為:新生代和老年代;再向下還可以分為Eden,和Survivor空間,收集器針對不同代,採用不同的回收演算法,即分代收集演算法。

1、標記清除演算法

分為標記和清除兩個階段:首先標記出所有需要回收的物件,在標記完成後統一回收所有被標記的物件。
這裡寫圖片描述
該演算法的缺點是效率不高並且會產生不連續的記憶體碎片。所以基本沒有采用這種演算法的。

2、複製演算法和新生代

把記憶體空間劃為兩個區域,每次只使用其中一個區域。垃圾回收時,遍歷當前使用區域,把正在使用中的物件複製到另外一個區域中。次演算法每次只處理正在使用中的物件,因此複製成本比較小,同時複製過去以後還能進行相應的記憶體整理,不會出現“碎片”問題。
這裡寫圖片描述
優點:實現簡單,執行高效。

缺點:會浪費一定的記憶體。

現在虛擬機器普遍採用複製演算法來回收新生代。因為新生代的物件中,大部分都是“朝生夕死”的。所以,並不需要按照1:1的比例來分配。
這裡寫圖片描述
新生代分為一塊較大的Eden空間和兩塊較小的Survivor空間,兩個Survivor分別叫做Survivor1,Survivor2,也叫做from和to。

當Eden區滿執行GC的時候,將 Eden 區和 Survivor區中還存活著的物件一次性地複製到另一塊Survivor(to)空間上,最後清理掉Eden和過程用過的Survivor(from)的空間。然後from和to對調,下次繼續這樣執行。

HotSpot虛擬機器預設Eden和Survivor的大小比例是8:1:1,頁就是每次新生代中可用的記憶體空間是整個新生代的90%(Eden和一個Survivor, 80%+10%),只用10%的記憶體被“浪費”。

3、標記-整理演算法和老年代

複製演算法在物件存活率較高時就要進行較多的複製操作,效率會變低。所以在老年代一般不採用這個演算法。

根據老年代的特點,人們提出了“標記-整理”演算法。過程與標記-清除演算法一樣,但後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉邊界以外的記憶體。
這裡寫圖片描述

4、總結

上面介紹了分代回收演算法,簡單總結下自己的理解。

  • 堆記憶體分為新生代和老年代
  • GC的時候新生代採用複製演算法,老年代採用標記-整理演算法
  • 不同的回收演算法主要是針對兩個代各自的特點,新生代的物件死亡率比較高,二老年代的物件死亡率相對比較低
  • 關於如何判斷物件進入新生代還是老年代,在下面詳細介紹

5、各個收集器

收集演算法是記憶體回收的方法論,你們垃圾收集器就是記憶體回收的具體實現。書中詳細介紹了各個收集器的特點和工作方式,這裡參考其他部落格簡單整理記錄一下。

  • Serial收集器:新生代收集器,使用複製演算法,使用一個執行緒進行GC,序列,其它工作執行緒暫停。
  • ParNew收集器:新生代收集器,使用複製演算法,Serial收集器的多執行緒版,用多個執行緒進行GC,並行,其它工作執行緒暫停。使用-XX:+UseParNewGC開關來控制使用ParNew+Serial Old收集器組合收集記憶體;使用-XX:ParallelGCThreads來設定執行記憶體回收的執行緒數。
  • Parallel Scavenge 收集器:吞吐量優先的垃圾回收器,作用在新生代,使用複製演算法,關注CPU吞吐量,即執行使用者程式碼的時間/總時間。使用-XX:+UseParallelGC開關控制使用Parallel Scavenge+Serial Old收集器組合回收垃圾。
  • Serial Old收集器:老年代收集器,單執行緒收集器,序列,使用標記整理演算法,使用單執行緒進行GC,其它工作執行緒暫停。
  • Parallel Old收集器:吞吐量優先的垃圾回收器,作用在老年代,多執行緒,並行,多執行緒機制與Parallel Scavenge差不錯,使用標記整理演算法,在Parallel Old執行時,仍然需要暫停其它執行緒。
  • CMS(Concurrent Mark Sweep)收集器:老年代收集器,致力於獲取最短回收停頓時間(即縮短垃圾回收的時間),使用標記清除演算法,多執行緒,優點是併發收集(使用者執行緒可以和GC執行緒同時工作),停頓小。使用-XX:+UseConcMarkSweepGC進行ParNew+CMS+Serial Old進行記憶體回收,優先使用ParNew+CMS(原因見Full GC和併發垃圾回收一節),當用戶執行緒記憶體不足時,採用備用方案Serial Old收集。
  • G1(Garbage-First)收集器,太多了,不想寫了。

三、什麼時候回收

比較常見的說法就是虛擬機器會在系統空閒的時候進行垃圾回收,但這不夠準確。其實上面時候進行回收確實是系統自身決定的,程式碼不可控制,System.gc()方法也僅僅是建議,但不一定會保證一定發生。

Java的GC分為兩種:新生代GC和老年代GC。

新生代GC(Minor):指發生在新生代的垃圾收集動作,因為這裡面對象都是“朝生夕死”的,所以Minor GC非常頻繁,一般回收速度也比較快。

老年代GC(Major GC/Full GC):指發生在老年代的GC,出現Major GC經常會伴隨至少一次的Minor GC(但不是絕對的)。

接下來通過研究記憶體分配與回收策略,來了解一下記憶體如何分配以及何時會發生上面的兩種GC。

1、Eden區與Minor GC

大多數情況下,物件會在新生代的Eden區分配,當Eden沒有足夠的空間進行分配時,虛擬機器將發起一次Minor GC。

值得注意的是,由於新生代採用的是複製演算法,且兩部分的記憶體是9:1,所以當較大的物件無法裝入剩餘的Survivor區時,該物件會通過分配擔保機制提前被直接放入到老年代去。

2、大物件直接進入老年代

這裡的大物件指的是大量連續記憶體空間的Java物件,最典型的大物件就是那種很長的字串以及陣列。如果把它放在新生代,就會發生大量的記憶體複製。

所以可以設定引數 PretenureSizeThreshold,大於這個值的物件會直接分配到老年代中。

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

這裡終於要講到為什麼要叫新生代和老年代了。

Java虛擬機器給每個物件定義了一個年齡計數器。如果物件在Eden出生並經過一次Minor GC之後仍然存活,物件年齡設為1,此後每經過一次Minor GC,也就是被複制一次,年齡就加一。

當年齡超過設定的引數值MaxTenuringThreshold,預設值為15,就會被晉升到老年代中。

4、動態物件年齡判定

如果在Survivor空間中相同年齡的所有物件的大小綜合超過了Survivor空間的一半,年齡大於或等於該年齡的物件就可以直接進入老年代,無需等到MaxTenuringThreshold。

5、空間擔保分配

上面討論的一直都是Minor GC,那麼什麼時候才會執行Full GC呢,執行Full GC由於消耗的資源比較大,所以會比較謹慎,進行兩次判斷。

第一次判斷:在發生Minor GC之前,虛擬機器會檢查老年代最大可用連續空間是否大於新生代所有物件的總空間,如果大於,那麼Minor GC可以安全執行,沒有必要執行Full GC。

但是,如果不成立,那麼就可能有必要執行一次Full GC以清除老年代的垃圾。

那麼,是不是一定就會執行Full GC呢,還是不一定的,因為雖然現在老年代無法容納所有新生代的記憶體,但是大部分情況下新生代的記憶體都會被清理很多的,所以萬一新生代需要進入老年代的記憶體只佔其中一小部分呢,那麼就不用執行Full GC了啊,當然,這在執行時無法確定,需要冒險。

第二次判斷:有一個引數HandlePromotionFailure的作用就是設定是不是要冒險,如果設定為可以冒險,那麼會那麼會繼續檢查老年代最大可用的連續空間,判斷它是否大於歷次晉升到老年代物件的平均大小,也就是大致評估一下這次冒險的成功率,如果如果大於,說明冒險的成功率比較高,那麼就不會執行Full GC。

如果設定為不可以冒險,或者冒險的成功率比較低,那麼久會老老實實的執行Full GC。

最後,如果冒險失敗,那麼最後還是會進行Full GC。

可以看出,虛擬機器執行Full GC是非常謹慎的,不到萬不得已絕不執行,就是因為這個過程非常浪費資源,Full GC的速度一般會比Minor GC慢10倍以上。