JVM探究之 —— 垃圾回收

垃圾收集(Garbage Collection,GC),大部分人都把這項技術當作Java語言的伴生產物。事實上,GC的歷史比Java久遠,1960年誕生於MIT的Lisp是第一門真正使用內存動態分配和垃圾收集技術的語言。當Lisp還在胚胎時期時,人們就在思考GC須要完成的3件事情:html

  • 哪些內存須要回收?
  • 何時回收?
  • 如何回收?

Java內存運行時區域的各個部分,其中程序計數器、虛擬機棧、本地方法棧3個區域隨線程而生,隨線程而滅;棧中的棧幀隨着方法的進入和退出而有條不紊地執行着出棧和入棧操做。每個棧幀中分配多少內存基本上是在類結構肯定下來時就已知的(儘管在運行期會由JIT編譯器進行一些優化,但在本章基於概念模型的討論中,大致上能夠認爲是編譯期可知的),所以這幾個區域的內存分配和回收都具有肯定性,在這幾個區域內就不須要過多考慮回收的問題,由於方法結束或者線程結束時,內存天然就跟隨着回收了。而 Java堆和方法區則不同,一個接口中的多個實現類須要的內存可能不同,一個方法中的多個分支須要的內存也可能不同,咱們只有在程序處於運行期間時才能知道會建立哪些對象,這部份內存的分配和回收都是動態的,垃圾收集器所關注的是這部份內存。java

1. 判斷對象是否已經死亡

在堆裏面存放着Java世界中幾乎全部的對象實例,垃圾收集器在對堆進行回收前,第一件事情就是要肯定這些對象之中哪些還「存活」着,哪些已經「死去」。算法

1.1 引用計數算法

給對象中添加一個引用計數器,每當有一個地方引用它,計數器就加 1;當引用失效,計數器就減 1;任什麼時候候計數器爲 0 的對象就是不可能再被使用的。數組

這個方法實現簡單,效率高,可是目前主流的虛擬機中並無選擇這個算法來管理內存,其最主要的緣由是它很難解決對象之間相互循環引用的問題。 所謂對象之間的相互引用問題,以下面代碼所示:除了對象 objA 和 objB 相互引用着對方以外,這兩個對象之間再無任何引用。可是他們由於互相引用對方,致使它們的引用計數器都不爲 0,因而引用計數算法(Reference Counting)沒法通知 GC 回收器回收他們。緩存

public class ReferenceCountingGc {
    Object instance = null;
    public static void main(String[] args) {
        ReferenceCountingGc objA = new ReferenceCountingGc();
        ReferenceCountingGc objB = new ReferenceCountingGc();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null;

    }
}

1.2 可達性分析算法

這個算法的基本思路就是經過一系列的稱爲「GC Roots」的對象做爲起始點,從這些節點開始向下搜索,搜索所走過的路徑稱爲引用鏈(Reference Chain),當一個對象到GC Roots沒有任何引用鏈相連(用圖論的話來講,就是從GC Roots到這個對象不可達)時,則證實此對象是不可用的。Java是經過可達性分析(Reachability Analysis)來斷定對象是否存活的。安全

在Java語言中,可做爲GC Roots的對象包含如下幾種:post

  • 虛擬機棧(棧幀中的本地變量表)中引用的對象。(能夠理解爲:引用棧幀中的本地變量表的全部對象)
  • 方法區中靜態屬性引用的對象(能夠理解爲:引用方法區該靜態屬性的全部對象)
  • 方法區中常量引用的對象(能夠理解爲:引用方法區中常量的全部對象)
  • 本地方法棧中(Native方法)引用的對象(能夠理解爲:引用Native方法的全部對象)

能夠理解爲:測試

  • 第一種是虛擬機棧中的引用的對象,咱們在程序中正常建立一個對象,對象會在堆上開闢一塊空間,同時會將這塊空間的地址做爲引用保存到虛擬機棧中,若是對象生命週期結束了,那麼引用就會從虛擬機棧中出棧,所以若是在虛擬機棧中有引用,就說明這個對象仍是有用的,這種狀況是最多見的。
  • 第二種是咱們在類中定義了全局的靜態的對象,也就是使用了static關鍵字,因爲虛擬機棧是線程私有的,因此這種對象的引用會保存在共有的方法區中,顯然將方法區中的靜態引用做爲GC Roots是必須的。
  • 第三種即是常量引用,就是使用了static final關鍵字,因爲這種引用初始化以後不會修改,因此方法區常量池裏的引用的對象也應該做爲GC Roots。
  • 第四種是在使用JNI技術時,有時候單純的Java代碼並不能知足咱們的需求,咱們可能須要在Java中調用C或C++的代碼,所以會使用native方法,JVM內存中專門有一塊本地方法棧,用來保存這些對象的引用,因此本地方法棧中引用的對象也會被做爲GC Roots。

1.3 再談引用

不管是經過引用計數算法判斷對象的引用數量,仍是經過可達性分析算法判斷對象的引用鏈是否可達,斷定對象是否存活都與「引用」有關。在JDK 1.2之前,Java中的引用的定義很傳統:若是reference類型的數據中存儲的數值表明的是另一塊內存的起始地址,就稱這塊內存表明着一個引用。優化

在JDK1.2以後,Java對引用的概念作了擴充,將引用分爲:url

  • 強引用(Strong Reference)
  • 軟引用(Soft Reference)
  • 弱引用(Weak Reference)
  • 虛引用(Phantom Reference)

這四種引用從上到下,依次減弱。

  • 強引用就是指在程序代碼中廣泛存在的,相似 Object obj = new Object() 這相似的引用,只要強引用在,垃圾蒐集器永遠不會蒐集被引用的對象。也就是說,當內存空間不足,JVM寧願拋出 OutOfMemoryError 錯誤,使程序異常終止,也不會靠隨意回收具備強引用的對象來解決內存不足問題。
  • 軟引用是用來描述一些有用但並非必需的對象,在Java中用java.lang.ref.SoftReference類來表示。對於軟引用關聯着的對象,只有在內存不足的時候JVM纔會回收該對象。所以,這一點能夠很好地用來解決OOM的問題,而且這個特性很適合用來實現緩存:好比網頁緩存、圖片緩存等。
  • 弱引用也是用來描述非必需對象的,當JVM進行垃圾回收時,不管內存是否充足,都會回收被弱引用關聯的對象。在java中,用java.lang.ref.WeakReference類來表示。
  • 虛引用和前面的軟引用、弱引用不一樣,它並不影響對象的生命週期。在java中用java.lang.ref.PhantomReference類表示。若是一個對象與虛引用關聯,則跟沒有引用與之關聯同樣,在任什麼時候候均可能被垃圾回收器回收。要注意的是,虛引用必須和引用隊列關聯使用,當垃圾回收器準備回收一個對象時,若是發現它還有虛引用,就會把這個虛引用加入到與之關聯的引用隊列中。程序能夠經過判斷引用隊列中是否已經加入了虛引用,來了解被引用的對象是否將要被垃圾回收。若是程序發現某個虛引用已經被加入到引用隊列,那麼就能夠在所引用的對象的內存被回收以前採起必要的行動。

須要注意的是:在程序設計中通常不多使用弱引用與虛引用,使用軟引用的狀況較多,這是由於軟引用能夠加速 JVM 對垃圾內存的回收速度,能夠維護系統的運行安全,防止內存溢出(OutOfMemory)等問題的產生。

1.4 可達性分析算法中對象死亡過程

即便在可達性分析法中不可達的對象,也並不是是「非死不可」的,這時候它們暫時處於「緩刑階段」,要真正宣告一個對象死亡,至少要經歷兩次標記過程;可達性分析法中不可達的對象被第一次標記而且進行一次篩選,篩選的條件是此對象是否有必要執行 finalize 方法。當對象沒有覆蓋 finalize 方法,或 finalize 方法已經被虛擬機調用過期,虛擬機將這兩種狀況視爲沒有必要執行。

被斷定爲須要執行的對象將會被放在一個叫作F-Queue的隊列之中進行第二次標記,除非這個對象與引用鏈上的任何一個對象創建關聯,不然就會被真的回收。

1.5 判斷一個常量是廢棄常量

JDK1.7 及以後版本的 JVM 已經將運行時常量池從方法區中移了出來,在 Java 堆(Heap)中開闢了一塊區域存放運行時常量池。

假如一個字符串「abc」已經進入了常量池中,可是當前系統沒有任何一個String對象是叫作「abc」的,換句話說,就是沒有任何String對象引用常量池中的「abc」常量,也沒有其餘地方引用了這個字面量,若是這時發生內存回收,並且必要的話,這個「abc」常量就會被系統清理出常量池。常量池中的其餘類(接口)、方法、字段的符號引用也與此相似。

1.6 判斷「無用的類」

斷定一個類是不是「無用的類」的條件則相對苛刻許多。類須要同時知足下面3個條件才能算是「無用的類」:

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

虛擬機能夠對知足上述 3 個條件的無用類進行回收,這裏說的僅僅是「能夠」,而並非和對象同樣不使用了就會必然被回收。是否對類進行回收,HotSpot虛擬機提供了-Xnoclassgc參數進行控制,還可使用-verbose:class以及-XX:+TraceClassLoading、-XX:+TraceClassUnLoading查看類加載和卸載信息。

2. 內存分配與回收策略

Java技術體系中的自動內存管理分爲對象內存回收和內存分配。這裏研究一下對象內存分配的問題。

對象的內存分配,往大方向講,就是在堆上分配(但也可能通過JIT編譯後被拆散爲標量類型並間接地棧上分配),對象主要分配在新生代的Eden區上,若是啓動了本地線程分配緩衝,將按線程優先在TLAB上分配。少數狀況下也可能會直接分配在老年代中,分配的規則並非百分之百固定的,其細節取決於當前使用的是哪種垃圾收集器組合,還有虛擬機中與內存相關的參數的設置。

回顧一下以前在 JVM探究之 —— Java內存區域 提到的Java堆:Java 堆是垃圾收集器管理的主要區域,所以也被稱做GC 堆(Garbage Collected Heap)。從垃圾回收的角度,因爲如今收集器基本都採用分代垃圾收集算法,因此 Java 堆還能夠細分爲:新生代和老年代:再細緻一點,年輕代能夠劃分爲:Eden 空間、From Survivor (S0)、To Survivor (S1) 空間等。進一步劃分的目的是更好地回收內存,或者更快地分配內存。

上圖所示的 eden 區、s0("From") 區、s1("To") 區都屬於新生代,tentired 區屬於老年代。大部分狀況,對象都會首先在 Eden 區域分配,在一次新生代垃圾回收後,若是對象還存活,則會進入 s1("To"),而且對象的年齡還會加 1(Eden 區->Survivor 區後對象的初始年齡變爲 1),當它的年齡增長到必定程度(默認爲 15 歲),就會被晉升到老年代中。對象晉升到老年代的年齡閾值,能夠經過參數 -XX:MaxTenuringThreshold 來設置。通過此次GC後,Eden區和"From"區已經被清空。這個時候,"From"和"To"會交換他們的角色,也就是新的"To"就是上次GC前的「From」,新的"From"就是上次GC前的"To"。無論怎樣,都會保證名爲To的Survivor區域是空的。Minor GC會一直重複這樣的過程,直到「To」區被填滿,"To"區被填滿以後,會將全部對象移動到年老代中。

2.1 對象優先在Eden分配

首先了解一下常見的 Minor GC和Full GC的區別:

  • 新生代GC(Minor GC):指發生在新生代的垃圾收集動做,由於Java對象大多都具有朝生夕滅的特性,因此Minor GC很是頻繁,通常回收速度也比較快。
  • 老年代GC(Major GC/Full GC):指發生在老年代的GC,出現了Major GC,常常會伴隨至少一次的Minor GC(但非絕對的,在Parallel Scavenge收集器的收集策略裏就有直接進行Major GC的策略選擇過程)。Major GC的速度通常會比Minor GC慢10倍以上。

在大多數狀況下,對象在新生代Eden區中分配。當Eden區沒有足夠空間進行分配時,虛擬機將發起一次Minor GC。

虛擬機提供了-XX:+PrintGCDetails這個收集器日誌參數,告訴虛擬機在發生垃圾收集行爲時打印內存回收日誌,而且在進程退出的時候輸出當前的內存各區域分配狀況。

/**
 * Eden區對象內存分配測試
 * -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails
 */
public class EdenAllocationTest {
    private static final int _1MB =1024*1024;

    public static void main(String[] args) {
        byte[] allocation1, allocation2,allocation3,allocation4;
//        allocation1=new byte[2*_1MB];
//        allocation2=new byte[2*_1MB];
//        allocation3=new byte[2*_1MB];

    }
}

如上面代碼中,在運行時經過-Xms20M、-Xmx20M、-Xmn10M這3個參數限制了Java堆大小爲20MB,不可擴展,其中10MB分配給新生代,剩下的10MB分配給老年代。-XX:SurvivorRatio=8決定了新生代中Eden區與一個Survivor區的空間比例是8:1,從輸出的結果也能夠清晰地看到「eden space 8192K、from space 1024K、to space 1024K」的信息,新生代總可用空間爲9216KB(Eden區+1個Survivor區的總容量)。

 

 分配兩個2MB的對象以後,空間使用狀況以下:

 能夠看到,eden區新增使用空間 2*2*1MB(1024KB) = 4096KB,Eden區剩餘空間大小爲 8192KB-6620KB=1572KB < 2MB,此時再分配一個2MB的對象會怎麼樣呢?

能夠看到,當給allocation3對象分配內存時發生一次Minor GC,此次GC的結果是新生代6456KB變爲750KB,而總內存佔用量則幾乎沒有減小(由於allocation一、allocation2對象都是存活的,虛擬機幾乎沒有找到可回收的對象)。此次GC發生的緣由是給allocation3分配內存的時候,剩餘空間已不足以分配allocation3所需的2MB內存,所以發生Minor GC。GC期間虛擬機又發現已有的2個2MB大小的對象所有沒法放入Survivor空間(Survivor空間只有1MB大小),因此只好經過分配擔保機制提早轉移到老年代去。老年代上的空間足夠存放 allocation1和allocation2對象,因此不會出現 Full GC。執行 Minor GC 後,後面分配的對象若是可以存在 eden 區的話,仍是會在 eden 區分配內存。

2.2 大對象直接進入老年代

大對象是指,須要大量連續內存空間的Java對象,如:字符串以及數組。

虛擬機提供了一個 -XX:PretenureSizeThreshold參數,令大於這個設置值的對象直接在老年代分配。從而避免在Eden區及兩個Survivor區之間發生大量的內存複製。

/**
 * 大對象老年代分配測試 PretenureSizeThreshold
 * -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:+UseSerialGC -XX:PretenureSizeThreshold=3145728
 * 說明:
 * -XX:+UseSerialGC 使用SerialGC
 * -XX:PretenureSizeThreshold=3145728 設置PretenureSizeThreshold爲3MB,這個參數不能像-Xmx之類的參數同樣直接寫3MB
 */
public class PretenureSizeThresholdTest {
    private static final int _1MB =1024*1024;
    public static void main(String[] args) {
        byte[] allocation1;
        allocation1=new byte[4*_1MB];
    }
}

須要注意的是:PretenureSizeThreshold參數只對Serial和ParNew兩款收集器有效,Parallel Scavenge收集器不認識這個參數,Parallel Scavenge收集器通常並不須要設置。若是遇到必須使用此參數的場合,能夠考慮ParNew加CMS的收集器組合。下面示例代碼經過 -XX:UseSerialGC 來指定JVM使用Serial垃圾收集器演示。

從上面代碼中,能夠看出當給 allocation1對象分配空間時, Eden空間幾乎沒有被使用,而老年代的10MB空間被使用了4MB,allocation1對象直接就分配在老年代中,這是由於PretenureSizeThreshold被設置爲3MB(就是3145728,這個參數不能像-Xmx之類的參數同樣直接寫3MB),所以超過3MB的對象都會直接在老年代進行分配。

2.3 長期存活的對象將進入老年代

既然虛擬機採用了分代收集的思想來管理內存,那麼內存回收時就必須能識別哪些對象應放在新生代,哪些對象應放在老年代中。爲了作到這點,虛擬機給每一個對象定義了一個對象年齡(Age)計數器。

若是對象在Eden出生並通過第一次Minor GC後仍然存活,而且能被Survivor容納的話,將被移動到Survivor空間中,而且對象年齡設爲1。對象在Survivor區中每「熬過」一次Minor GC,年齡就增長1歲,當它的年齡增長到必定程度(默認爲15歲),就將會被晉升到老年代中。對象晉升老年代的年齡閾值,能夠經過參數-XX:MaxTenuringThreshold設置。

/**
 * 對象晉升老年代的年齡閾值測試 XX:MaxTenuringThreshold
 * -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:+UseSerialGC
 * -XX:MaxTenuringThreshold=1 -XX:+PrintTenuringDistribution
 * 說明:
 * -XX:+UseSerialGC   使用SerialGC
 * -XX:MaxTenuringThreshold=1  年齡閾值,對象每熬過一次Minor GC,它的age會加1,age達到此值對象就會晉升老年代
 * -XX:+PrintTenuringDistribution   輸出對象年齡
 */
public class MaxTenuringThresholdTest {
    private static final int _1MB =1024*1024;
    public static void main(String[] args) {
        byte[] allocation1,allocation2,allocation3;
        allocation1=new byte[_1MB/4];
        allocation2=new byte[4*_1MB];
        allocation3=new byte[4*_1MB];  //首次GC
        allocation3=null;
        allocation3=new byte[4*_1MB];  //第二次GC
    }
}

設置JVM參數-XX:MaxTenuringThreshold=1來查看執行結果:

能夠看出,上面執行結果中的allocation1對象須要256KB內存,在第一次GC時Survivor空間能夠容納。當MaxTenuringThreshold=1時,allocation1對象在第二次GC發生時進入老年代,新生代已使用的內存GC後很是乾淨地變成0KB。

 

TODO:MaxTenuringThreshold=15

 

2.4 動態對象年齡斷定

爲了能更好地適應不一樣程序的內存情況,虛擬機並非永遠地要求對象的年齡必須達到了MaxTenuringThreshold才能晉升老年代,若是在Survivor空間中相同年齡全部對象大小的總和大於Survivor空間的一半(-XX:TargetSurvivorRatio=50 即:50%),年齡大於或等於該年齡的對象就能夠直接進入老年代,無須等到MaxTenuringThreshold中要求的年齡。

JVM究竟是如何來計算S區對象晉升到Old區的呢? 首先介紹另外一個重要的JVM參數: -XX:TargetSurvivorRatio:一個計算指望s區存活大小(Desired survivor size)的參數。默認值爲50,即50%。 當一個S區中全部的age對象的大小若是大於等於Desired survivor size,則從新計算threshold,以age和MaxTenuringThreshold二者的最小值爲準。

 

 

2.5 空間分配擔保

在發生Minor GC以前,虛擬機會檢查老年代最大可用的連續空間是否大於新生代全部對象的總空間,

  • 若是大於,則這次Minor GC是安全的;
  • 若是小於,則虛擬機會查看HandlePromotionFailure設置值是否容許擔保失敗。若是HandlePromotionFailure=true,那麼會繼續檢查老年代最大可用連續空間是否大於歷次晉升到老年代的對象的平均大小,若是大於,則嘗試進行一次Minor GC,但此次Minor GC依然是有風險的;若是小於或者HandlePromotionFailure=false,則改成進行一次Full GC。

上面提到了Minor GC依然會有風險,是由於新生代採用複製收集算法,假如大量對象在Minor GC後仍然存活(最極端狀況爲內存回收後新生代中全部對象均存活),而Survivor空間是比較小的,這時就須要老年代進行分配擔保,把Survivor沒法容納的對象放到老年代。老年代要進行空間分配擔保,前提是老年代得有足夠空間來容納這些對象,但一共有多少對象在內存回收後存活下來是不可預知的,所以只好取以前每次垃圾回收後晉升到老年代的對象大小的平均值做爲參考。使用這個平均值與老年代剩餘空間進行比較,來決定是否進行Full GC來讓老年代騰出更多空間。

取平均值仍然是一種機率性的事件,若是某次Minor GC後存活對象陡增,遠高於平均值的話,必然致使擔保失敗,若是出現了分配擔保失敗,就只能在失敗後從新發起一次Full GC。雖然存在發生這種狀況的機率,但大部分時候都是可以成功分配擔保的,這樣就避免了過於頻繁執行Full GC。

相關文章
相關標籤/搜索