1. 程式人生 > 實用技巧 >JVM初探(二):垃圾回收機制

JVM初探(二):垃圾回收機制

一、概述

我們知道自動的垃圾回收機制是Java語言一個特點,它讓我們在寫程式的時候不再需要考慮記憶體管理問題。記憶體管理實際上就是分配記憶體回收記憶體這兩個問題,在上一篇文章我大概介紹了jvm是如何劃分記憶體空間以合理的分配記憶體的,而這篇文章就介紹一下jvm是如何回收記憶體的。

對於執行緒私有的程式計數器,虛擬機器棧和本地方法棧三塊資料區域而言,生命週期是和執行緒繫結的,執行緒結束時自然就回收記憶體了;而對於棧,每一個方法代表每一個棧幀,方法結束的時候就出棧,這時記憶體也跟著回收了。這些區域的記憶體回收都是具有確定性的,而堆就不同。

我們知道,堆主要用與存放物件例項,而只有執行時才知道要建立那些物件,而只有物件完全不被使用時才能回收其佔用的記憶體空間。對於這塊內容,我們需要明確三個問題:

  • 哪些物件可以回收?(引用計數法、可達性演算法)
  • 這些記憶體什麼時候回收?(新生代、老年代、永久代,MinorGC和FullGC)
  • 這些記憶體怎麼回收?(三種垃圾收集演算法和分代收集演算法,七種垃圾收集器)

二、判斷物件是否可回收

我們要判斷物件是否可以回收,最有效的方式就是判斷這個物件是否正在被別的物件引用。針對這個問題,有兩種演算法:引用計數演算法可達性分析演算法

1.引用計數演算法

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

簡單的來說,就是為每一個物件例項配置一個計數器:

  • 當一個例項被建立並分配一個物件引用的時候,計數器為1;
  • 每當該物件被分配給一個物件例項的時候計數器就加一;
  • 當物件例項的某個引用超過了生命週期或者被設定為別的例項時,計數器就減一
  • 當計數器為0時例項就會被回收

引用計數器實現簡單而且效率高,但是無法解決迴圈引用問題

public class A {
    public B b;
}

public class B {
    public A a;
}

public void test(){
    A a = new A();
    B b = new B();
    a.b = b;
    b.a = a;
}

當A例項中引用了B例項,而B例項中又引用了A例項,他們的極速器就永遠不能為0,也就無法回收。

2.可達性演算法

可達性演算法通過判斷物件的引用鏈是否可達來判斷物件是否可以被回收。

通過一系列的名為 “GC Roots” 的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈(Reference Chain)。當一個物件到 GC Roots 沒有任何引用鏈相連時,則證明此物件是不可用的。

在java中,可以作為GC Roots的物件包括:

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

3.強引用、軟引用、弱引用、虛引用

我們知道以上兩種演算法都需要判斷物件是否被引用,實際上,如果reference型別的資料中儲存的數值代表的是另外一塊記憶體的起始地址,就稱這塊記憶體代表著一個引用,因此物件往往只有兩種狀態:被引用或者未被引用。

如果我們希望有這樣一類物件:當記憶體空間足夠時,能保留在記憶體中;如果記憶體空間在進行垃圾回收後還是非常緊張,就拋棄這些物件。比如一些系統快取。

因此為了做出區分,JDK1.2之後Java的引用被分為強引用、軟引用、弱引用、虛引用4種。這4種引用強度依次逐漸減弱。

  • 強引用指類似Object obj=new Object()這類的引用,只要強引用還存在,垃圾回收器永遠不會回收掉被引用的物件。
  • 軟引用用來描述一些還有用但並非必須的物件。在系統將要發生記憶體溢位異常之前,將會把這些物件列進回收範圍中進行第二次回收。用SoftReference類實現。
  • 弱引用也描述非必需物件,只能存活到下一次垃圾回收之前。用WeakReference類實現。
  • 虛引用也被稱為幽靈引用,為一個物件設定虛引用的唯一目的就是能在這個物件被垃圾回收時收到一個系統通知。用PhantomReference類實現。

這塊內容具體可以參考:Java 的強引用、弱引用、軟引用、虛引用

二、垃圾收集演算法

要理解垃圾回收時機,我們需要理解分代演算法,在這之前我們需要對四種垃圾收集演算法有大概的印象:

1.標記清除演算法

首先標記出所有需要回收的物件,在標記完成之後統一回收所有比標記的物件。

標記清除演算法有兩個問題:

  • 效率問題:標記和清除兩個過程的效率都不高;
  • 空間問題:標記清除之後會產生大量不連續的記憶體碎片

2.複製演算法

把記憶體分為大小相等的兩塊,每次只用其中一塊。當這一塊的用完了,就把還存活的物件複製到另一塊,然後再把已經用過的記憶體空間一次清理掉。

複製演算法常用於回收新生代

如我們之前在介紹堆的記憶體結構的時候,jvm會將堆分外新生代和老年代。

而將新生代記憶體又分為一塊較大的eden空間和兩塊較小的survivor空間,每次使用eden和其中一塊survivor。當回收時,將edensurvivor中還存活著的物件一次地複製到另外一塊survivor空間上,最後清理掉eden和剛才用過的survivor空間。

3.標記-整理演算法

與標記清除類似,但是不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉端邊界以外的記憶體。

標記-整理演算法常用於老年代。

複製演算法在物件存活率較高時就要進行較多的複製操作,效率將會變低。更關鍵的是,如果不想浪費50%的空間,就需要有額外的空間進行分配擔保,以應對被使用的記憶體中所有物件都100%存活的極端情況,所以在老年代一般不能直接選用這種演算法。

4.分代收集演算法

根據物件存活週期的不同將記憶體劃分為幾塊。一般是把Java堆分為新生代和老年代,這樣就可以根據各個年代的特點採用最適當的收集演算法。

不同的物件的生命週期(存活情況)是不一樣的,而不同生命週期的物件位於堆中不同的區域,因此對堆記憶體不同區域採用不同的策略進行回收可以提高 JVM 的執行效率:

  • 在新生代每次垃圾收集時都有大批物件死去,只有少量存活,所以使用複製演算法;
  • 在老生代中代中存活率高,使用標記清理或者標記整理演算法來回收。

三、分代收集演算法的記憶體回收策略

正如之前所說,由於java物件例項儲存於堆中,所以堆就是GC的主要場所。

根據分代收集演算法,堆會分為新生代和老年代。

1.新生代和老年代

新生代的目標就是儘可能快速的收集掉那些生命週期短的物件,一般情況下,所有新生成的物件首先都是放在新生代的。新生代發生的GC叫MinorGC

老年代存放的都是一些生命週期較長的物件,就在新生代中經歷了N次垃圾回收後仍然存活的物件會被放到老年代中。老年代發生的GC叫FullGC

新生代記憶體按照 8:1:1 的比例分為一個eden區和兩個survivor(survivor0,survivor1)區,大部分物件在eden區中生成。

在進行垃圾回收時:

  • 一般先將eden區存活物件複製到survivor0區,然後清空eden區;
  • survivor0區也滿了時,則將eden區和survivor0區存活物件複製到survivor1區,然後清空eden和這個survivor0區,此時survivor0區是空的,然後交換survivor0區和survivor1區的角色。下次垃圾回收時掃描eden區和survivor1區,然後再交換survivor0區和survivor1,如此反覆;
  • survivor1區也不足以存放eden區和survivor0區的存活物件時,就將存活物件直接存放到老年代。

2.分代收集演算法的記憶體分配策略

這裡再提一下記憶體分配策略:

  • 物件優先分配給eden區域。當eden區域沒有足夠空間時,發起一次MinorGC。
  • 需要大量連續記憶體空間的大物件直接進入老年代。比如巨長的陣列或者字串,還有非常高的樹之類的。
  • 長期存活的物件會進入老年代。物件在新生代活過一定次數GC後會移入老年代。
  • 動態物件年齡判定。如果在survivor空間中相同年齡所有物件大小的總和大小大於survivor空間的一半,年齡大於或等於該年齡的物件直接進入老年代

四、垃圾收集器

1.Serial收集器

新生代收集器,單執行緒。

2.ParNew收集器

Serial收集器的多執行緒版本.

3. Serial Old收集器

Serial收集器用於老年代的多執行緒版本。

4.Parallel Scavenge收集器

新生代收集器,多執行緒。它的關注點與其他收集器不同,其他的關注點是儘可能縮短垃圾收集時使用者執行緒的停頓時間,而它的目標則是達到一個高吞吐量。

5.Parallel Old收集器

Parallel Scavenge收集器的老年代版本。

6.CMS收集器

老年代並行,以獲取最短回收停頓時間為特點的收集器。

7.G1收集器

G1收集器是JDK7提供的一個新收集器。

G1收集器不同於之前的收集器的一個重要特點是:G1回收的範圍是整個Java堆(包括新生代,老年代),而前六種收集器回收的範圍僅限於新生代或老年代