Java回收機制概述

  • 2019 年 10 月 3 日
  • 筆記

Java技術體系中所提倡的 自動記憶體管理 最終可以歸結為自動化地解決了兩個問題:給對象分配記憶體 以及 回收分配給對象的記憶體,而且這兩個問題針對的記憶體區域就是Java記憶體模型中的 堆區。

垃圾回收機制的引入可以有效的防止記憶體泄露、保證記憶體的有效使用,也大大解放了Java程式設計師的雙手,使得他們在編寫程式的時候不再需要考慮記憶體管理。本文著重介紹了判斷一個對象是否可以被回收的兩種經典演算法,並詳述了四種典型的垃圾回收演算法的基本思想及其直接應用——垃圾收集器,最後結合記憶體回收策略介紹了記憶體分配規則。
本文內容是基於 JDK 1.6 的,不同版本虛擬機之間也許會有些許差異,但不影響我們對JVM垃圾回收機制的整體把握和了解。

一、垃圾回收機制的意義

在上篇《JVM記憶體模型概述》中知道:JVM 記憶體模型一共包括三個部分:堆 ( Java程式碼可及的 Java堆 和 JVM自身使用的方法區)、棧 ( 服務Java方法的虛擬機棧 和 服務Native方法的本地方法棧 ) 和 保證程式在多執行緒環境下能夠連續執行的程式計數器。特別地,我們當時就提到Java堆是進行垃圾回收的主要區域,故其也被稱為GC堆;而方法區也有一個不太嚴謹的表述,就是永久代。總的來說,堆 (包括Java堆 和 方法區)是 垃圾回收的主要對象,特別是Java堆。

  實際上,Java技術體系中所提倡的 自動記憶體管理 最終可以歸結為自動化地解決了兩個問題:給對象分配記憶體 以及回收分配給對象的記憶體,而且這兩個問題針對的記憶體區域就是Java記憶體模型中的堆區。關於對象分配記憶體問題,筆者的博文《JVM 記憶體模型概述》已經闡述了 如何劃分可用空間及其涉及到的執行緒安全問題,本文將結合垃圾回收策略進一步給出 記憶體分配規則。另外,我們知道垃圾回收機制是Java語言一個顯著的特點,其可以有效的防止記憶體泄露、保證記憶體的有效使用,從而使得Java程式設計師在編寫程式的時候不再需要考慮記憶體管理問題。Java 垃圾回收機制要考慮的問題很複雜,本文闡述了其三個核心問題,包括:

  • 那些記憶體需要回收?(對象是否可以被回收的兩種經典演算法: 引用計數法 和 可達性分析演算法)
  • 什麼時候回收? (堆的新生代、老年代、永久代的垃圾回收時機,MinorGC 和 FullGC)
  • 如何回收?(三種經典垃圾回收演算法(標記清除演算法、複製演算法、標記整理演算法)及分代收集演算法 和 七種垃圾收集器)

在探討Java垃圾回收機制之前,我們首先應該記住一個單詞:Stop-the-World。Stop-the-world意味著 JVM由於要執行GC而停止了應用程式的執行,並且這種情形會在任何一種GC演算法中發生。當Stop-the-world發生時,除了GC所需的執行緒以外,所有執行緒都處於等待狀態直到GC任務完成。事實上,GC優化很多時候就是指減少Stop-the-world發生的時間,從而使系統具有 高吞吐 、低停頓 的特點。


 

二. 如何確定一個對象是否可以被回收?

1、 引用計數演算法:判斷對象的引用數量

  引用計數演算法是通過判斷對象的引用數量來決定對象是否可以被回收。

  引用計數演算法是垃圾收集器中的早期策略。在這種方法中,堆中的每個對象實例都有一個引用計數。當一個對象被創建時,且將該對象實例分配給一個引用變數,該對象實例的引用計數設置為 1。當任何其它變數被賦值為這個對象的引用時,對象實例的引用計數加 1(a = b,則b引用的對象實例的計數器加 1),但當一個對象實例的某個引用超過了生命周期或者被設置為一個新值時,對象實例的引用計數減 1。特別地,當一個對象實例被垃圾收集時,它引用的任何對象實例的引用計數器均減 1。任何引用計數為0的對象實例可以被當作垃圾收集。

  引用計數收集器可以很快的執行,並且交織在程式運行中,對程式需要不被長時間打斷的實時環境比較有利,但其很難解決對象之間相互循環引用的問題。如下面的程式所示,對象objA和objB之間的引用計數永遠不可能為 0,那麼這兩個對象就永遠不能被回收。

 public class ReferenceCountingGC {              public Object instance = null;            public static void testGC(){                ReferenceCountingGC objA = new ReferenceCountingGC ();              ReferenceCountingGC objB = new ReferenceCountingGC ();                // 對象之間相互循環引用,對象objA和objB之間的引用計數永遠不可能為 0              objB.instance = objA;              objA.instance = objB;                objA = null;              objB = null;                System.gc();      }  }

上述程式碼最後面兩句將objA和objB賦值為null,也就是說objA和objB指向的對象已經不可能再被訪問,但是由於它們互相引用對方,導致它們的引用計數器都不為 0,那麼垃圾收集器就永遠不會回收它們。

2、 可達性分析演算法:判斷對象的引用鏈是否可達

  可達性分析演算法是通過判斷對象的引用鏈是否可達來決定對象是否可以被回收。

  可達性分析演算法是從離散數學中的圖論引入的,程式把所有的引用關係看作一張圖,通過一系列的名為 “GC Roots” 的對象作為起始點,從這些節點開始向下搜索,搜索所走過的路徑稱為引用鏈(Reference Chain)。當一個對象到 GC Roots 沒有任何引用鏈相連(用圖論的話來說就是從 GC Roots 到這個對象不可達)時,則證明此對象是不可用的。在Java中,可作為 GC Root 的對象包括以下幾種:

  • 虛擬機棧(棧幀中的局部變數表)中引用的對象;
  • 方法區中類靜態屬性引用的對象;
  • 方法區中常量引用的對象;
  • 本地方法棧中Native方法引用的對象;

 

三. 垃圾收集演算法

1、標記清除演算法

  標記-清除演算法分為標記和清除兩個階段。該演算法首先從根集合進行掃描,對存活的對象對象標記,標記完畢後,再掃描整個空間中未被標記的對象並進行回收,如下圖所示。

從圖中我們就可以發現,該演算法最大的問題是記憶體碎片化嚴重,後續可能發生大對象不能找到可利用空間的問題。 

  • 效率問題:標記和清除兩個過程的效率都不高;
  • 空間問題:標記-清除演算法不需要進行對象的移動,並且僅對不存活的對象進行處理,因此標記清除之後會產生大量不連續的記憶體碎片,空間碎片太多可能會導致以後在程式運行過程中需要分配較大對象時,無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。

2、複製演算法

  複製演算法將可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的記憶體用完了,就將還存活著的對象複製到另外一塊上面,然後再把已使用過的記憶體空間一次清理掉。這種演算法適用於對象存活率低的場景,比如新生代。這樣使得每次都是對整個半區進行記憶體回收,記憶體分配時也就不用考慮記憶體碎片等複雜情況,只要移動堆頂指針,按順序分配記憶體即可,實現簡單,運行高效。該演算法示意圖如下所示:

事實上,現在商用的虛擬機都採用這種演算法來回收新生代。因為研究發現,新生代中的對象每次回收都基本上只有10%左右的對象存活,所以需要複製的對象很少,效率還不錯。實踐中會將新生代記憶體分為一塊較大的Eden空間和兩塊較小的Survivor空間 (如下圖所示),每次使用Eden和其中一塊Survivor。當回收時,將Eden和Survivor中還存活著的對象一次地複製到另外一塊Survivor空間上,最後清理掉Eden和剛才用過的Survivor空間。HotSpot虛擬機默認Eden和Survivor的大小比例是 8:1,也就是每次新生代中可用記憶體空間為整個新生代容量的90% ( 80%+10% ),只有10% 的記憶體會被“浪費”。

3、標記整理演算法

  複製收集演算法在對象存活率較高時就要進行較多的複製操作,效率將會變低。更關鍵的是,如果不想浪費50%的空間,就需要有額外的空間進行分配擔保,以應對被使用的記憶體中所有對象都100%存活的極端情況,所以在老年代一般不能直接選用這種演算法。標記整理演算法的標記過程類似標記清除演算法,但後續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然後直接清理掉端邊界以外的記憶體,類似於磁碟整理的過程,該垃圾回收演算法適用於對象存活率高的場景(老年代),其作用原理如下圖所示。

  標記整理演算法與標記清除演算法最顯著的區別是:標記清除演算法不進行對象的移動,並且僅對不存活的對象進行處理;而標記整理演算法會將所有的存活對象移動到一端,並對不存活對象進行處理,因此其不會產生記憶體碎片。

4、分代收集演算法

 分代收集法是目前大部分 JVM 所採用的方法,其核心思想是根據對象存活的不同生命周期將記憶體劃分為不同的域,一般情況下將 GC 堆劃分為老生代(Tenured/Old Generation)和新生代(YoungGeneration)。老生代的特點是每次垃圾回收時只有少量對象需要被回收,新生代的特點是每次垃圾回收時都有大量垃圾需要被回收,因此可以根據不同區域選擇不同的演算法 

4.1、新生代與複製演算法 

  目前大部分 JVM 的 GC 對於新生代都採取 Copying 演算法,因為新生代中每次垃圾回收都要回收大部分對象,即要複製的操作比較少,但通常並不是按照 1: 1 來劃分新生代。一般將新生代劃分為一塊較大的 Eden 空間和兩個較小的 Survivor 空間(From Space, To Space),每次使用Eden 空間和其中的一塊 Survivor 空間,當進行回收時,將該兩塊空間中還存活的對象複製到另一塊 Survivor 空間中。 

4.2、老年代與標記整理演算法

  而老年代因為每次只回收少量對象,因而採用 標記整理演算法(Mark-Compact) 演算法。

  1. JAVA 虛擬機提到過的處於方法區的永生代(Permanet Generation), 它用來存儲 class 類,常量,方法描述等。對永生代的回收主要包括廢棄常量和無用的類。

  2. 對象的記憶體分配主要在新生代的 Eden Space 和 Survivor Space 的 From Space(Survivor 目前存放對象的那一塊),少數情況會直接分配到老生代。

  3. 當新生代的 Eden Space 和 From Space 空間不足時就會發生一次 GC,進行 GC 後, EdenSpace 和 From Space 區的存活對象會被挪到 To Space,然後將 Eden Space 和 FromSpace 進行清理。  

  4. 如果 To Space 無法足夠存儲某個對象,則將這個對象存儲到老生代。

  5. 在進行 GC 後,使用的便是 Eden Space 和 To Space 了,如此反覆循環。

  6. 當對象在 Survivor 區躲過一次 GC 後,其年齡就會+1。 默認情況下年齡到達 15 的對象會被  移到老生代中。 

由於對象進行了分代處理,因此垃圾回收區域、時間也不一樣。垃圾回收有兩種類型,Minor GC 和 Full GC。

  • Minor GC:對新生代進行回收,不會影響到年老代。因為新生代的 Java 對象大多死亡頻繁,所以 Minor GC 非常頻繁,一般在這裡使用速度快、效率高的演算法,使垃圾回收能儘快完成。
  • Full GC:也叫 Major GC,對整個堆進行回收,包括新生代和老年代。由於Full GC需要對整個堆進行回收,所以比Minor GC要慢,因此應該儘可能減少Full GC的次數,導致Full GC的原因包括:老年代被寫滿、永久代(Perm)被寫滿和System.gc()被顯式調用等。

四. 垃圾收集器

  如果說垃圾收集演算法是記憶體回收的方法論,那麼垃圾收集器就是記憶體回收的具體實現。下圖展示了7種作用於不同分代的收集器,其中用於回收新生代的收集器包括Serial、PraNew、Parallel Scavenge,回收老年代的收集器包括Serial Old、Parallel Old、CMS,還有用於回收整個Java堆的G1收集器。不同收集器之間的連線表示它們可以搭配使用。

  • 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堆(包括新生代,老年代),而前六種收集器回收的範圍僅限於新生代或老年代。

五. 記憶體分配與回收策略

  Java技術體系中所提倡的自動記憶體管理最終可以歸結為自動化地解決了兩個問題:給對象分配記憶體 以及 回收分配給對象的記憶體。一般而言,對象主要分配在新生代的Eden區上,如果啟動了本地執行緒分配快取(TLAB),將按執行緒優先在TLAB上分配。少數情況下也可能直接分配在老年代中。總的來說,記憶體分配規則並不是一層不變的,其細節取決於當前使用的是哪一種垃圾收集器組合,還有虛擬機中與記憶體相關的參數的設置。

  1) 對象優先在Eden分配,當Eden區沒有足夠空間進行分配時,虛擬機將發起一次MinorGC。現在的商業虛擬機一般都採用複製演算法來回收新生代,將記憶體分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor。 當進行垃圾回收時,將Eden和Survivor中還存活的對象一次性地複製到另外一塊Survivor空間上,最後處理掉Eden和剛才的Survivor空間。(HotSpot虛擬機默認Eden和Survivor的大小比例是8:1)當Survivor空間不夠用時,需要依賴老年代進行分配擔保。

  2) 大對象直接進入老年代。所謂的大對象是指,需要大量連續記憶體空間的Java對象,最典型的大對象就是那種很長的字元串以及數組。

  3) 長期存活的對象將進入老年代。當對象在新生代中經歷過一定次數(默認為15)的Minor GC後,就會被晉陞到老年代中。

  4) 動態對象年齡判定。為了更好地適應不同程式的記憶體狀況,虛擬機並不是永遠地要求對象年齡必須達到了MaxTenuringThreshold才能晉陞老年代,如果在Survivor空間中相同年齡所有對象大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的對象就可以直接進入老年代,無須等到MaxTenuringThreshold中要求的年齡。

  需要注意的是,Java的垃圾回收機制是Java虛擬機提供的能力,用於在空閑時間以不定時的方式動態回收無任何引用的對象佔據的記憶體空間。也就是說,垃圾收集器回收的是無任何引用的對象佔據的記憶體空間而不是對象本身。


 

六. Java中的記憶體泄露問題 

 雖然Java擁有垃圾回收機制,但同樣會出現記憶體泄露問題,比如下面提到的幾種情況:

 (1). 諸如 HashMap、Vector 等集合類的靜態使用最容易出現記憶體泄露,因為這些靜態變數的生命周期和應用程式一致,所有的對象Object也不能被釋放,因為他們也將一直被Vector等應用著。

private static Vector v = new Vector();    public void test(Vector v){        for (int i = 1; i<100; i++) {          Object o = new Object();          v.add(o);          o = null;      }  }

在這個例子中,虛擬機棧中保存者 Vector 對象的引用 v 和 Object 對象的引用 o 。在 for 循環中,我們不斷的生成新的對象,然後將其添加到 Vector 對象中,之後將 o 引用置空。問題是雖然我們將 o 引用置空,但當發生垃圾回收時,我們創建的 Object 對象也不能夠被回收。因為垃圾回收在跟蹤程式碼棧中的引用時會發現 v 引用,而繼續往下跟蹤就會發現 v 引用指向的記憶體空間中又存在指向 Object 對象的引用。也就是說,儘管o 引用已經被置空,但是 Object 對象仍然存在其他的引用,是可以被訪問到的,所以 GC 無法將其釋放掉。如果在此循環之後, Object 對象對程式已經沒有任何作用,那麼我們就認為此 Java 程式發生了記憶體泄漏。

 (2). 各種資源連接包括資料庫連接、網路連接、IO連接等沒有顯式調用close關閉,不被GC回收導致記憶體泄露。

 (3). 監聽器的使用,在釋放對象的同時沒有相應刪除監聽器的時候也可能導致記憶體泄露。


 

 補充:

1、引用

 1). 引用概述

  無論是通過引用計數演算法判斷對象的引用數量,還是通過可達性分析演算法判斷對象的引用鏈是否可達,判定對象是否存活都與“引用”有關。在JDK 1.2之前,Java中的引用的定義很傳統:如果reference類型的數據中存儲的數值代表的是另外一塊記憶體的起始地址,就稱這塊記憶體代表著一個引用。這種定義很純粹,但是太過狹隘,一個對象在這種定義下只有被引用或者沒有被引用兩種狀態,對於如何描述一些“食之無味,棄之可惜”的對象就顯得無能為力。我們希望能描述這樣一類對象:當記憶體空間還足夠時,則能保留在記憶體之中;如果記憶體在進行垃圾收集後還是非常緊張,則可以拋棄這些對象。很多系統的快取功能都符合這樣的應用場景。

  為此,在JDK 1.2之後,Java對引用的概念進行了擴充,將引用分為強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)、虛引用(Phantom Reference)四種,這四種引用強度依次逐漸減弱。

 2). 引用的種類及其定義
  
  強引用就是指在程式程式碼之中普遍存在的,類似“Object obj = new Object()”這類引用。 只要強引用還存在,垃圾收集器就永遠不會回收掉被引用的對象。

  軟引用用來描述一些還有用,但並非必需的對象。對於軟引用關聯著的對象,在系統將要發生記憶體溢出異常之前,將會把這些對象列進回收範圍之中並進行第二次回收。如果這次回收還是沒有足夠的記憶體,才會拋出記憶體溢出異常。在JDK 1.2之後,提供了SoftReference類來實現軟引用。

  弱引用也是用來描述非必需對象的,但是它的強度比軟引用更弱一些,被弱引用關聯的對象只能生存到下一次垃圾收集發生之前。當垃圾收集器工作時,無論當前記憶體是否足夠,都會回收掉只被弱引用關聯的對象。在JDK 1.2之後,提供了WeakReference類來實現弱引用。

  虛引用是最弱的一種引用關係。一個對象是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個對象實例。為一個對象設置虛引用關聯的唯一目的就是希望能在這個對象被收集器回收時收到一個系統通知。在JDK 1.2之後,提供了PhantomReference類來實現虛引用。


 

2、方法區的回收

  方法區的記憶體回收目標主要是針對 常量池的回收 和 對類型的卸載。回收廢棄常量與回收Java堆中的對象非常類似。以常量池中字面量的回收為例,假如一個字元串“abc”已經進入了常量池中,但是當前系統沒有任何一個String對象是叫做“abc”的,換句話說是沒有任何String對象引用常量池中的“abc”常量,也沒有其他地方引用了這個字面量,如果在這時候發生記憶體回收,而且必要的話,這個“abc”常量就會被系統“請”出常量池。常量池中的其他類(介面)、方法、欄位的符號引用也與此類似。

  判定一個常量是否是“廢棄常量”比較簡單,而要判定一個類是否是“無用的類”的條件則相對苛刻許多。類需要同時滿足下面3個條件才能算是“無用的類”:

  • 該類所有的實例都已經被回收,也就是Java堆中不存在該類的任何實例;
  • 載入該類的ClassLoader已經被回收;
  • 該類對應的 java.lang.Class 對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

  虛擬機可以對滿足上述3個條件的無用類進行回收(卸載),這裡說的僅僅是“可以”,而不是和對象一樣,不使用了就必然會回收。特別地,在大量使用反射、動態代理、CGLib等bytecode框架的場景,以及動態生成JSP和OSGi這類頻繁自定義ClassLoader的場景都需要虛擬機具備類卸載的功能,以保證永久代不會溢出。

Tags: