1. 程式人生 > 程式設計 >一篇文章徹底瞭解Java垃圾收集(GC)機制

一篇文章徹底瞭解Java垃圾收集(GC)機制

垃圾收集(Garbage Collection,GC),是一個長久以來就被思考的問題,當考慮GC的時候,我們必須思考3件事情:

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

那麼在Java中,我們要怎麼來考慮GC呢?首先回想以下記憶體區域的劃分,其中程式計數器、本地方法棧、虛擬機器器棧三個區域隨執行緒而生,隨執行緒釋放,棧中的棧幀隨著方法的進入和退出執行著出棧和入棧的操作,每一個棧幀分配多少記憶體基本是在類結構確定時就已經固定的(可能會進行一些優化,但是大體上已知),因此這幾個區域就不需要考慮回收的問題,因為方法結束或者執行緒結束時,記憶體自然都被回收。不需要額外的GC演演算法等。

然而Java堆和方法區則不一樣,一個介面所對應的多個實現類所需要的記憶體可能不一樣,一個方法中的多個分支所需要的記憶體也可能不一樣,我們只有在程式處於執行期間才能知道程式需要建立那些物件

,這部分的記憶體的分配和回收是動態的,因此,垃圾收集器關注的是這方面的記憶體。

一. 如何確定物件可以回收

1.引用計數演演算法

  最容易想到與理解的演演算法,即對於每一個物件,每當該物件被引用時,計數器值就+1,引用失效時,計數器就-1。因此,當物件的引用計數為0時,即為不可再被使用的。該演演算法也在一些領域被使用來進行記憶體管理,但是JAVA虛擬機器器中並沒有選用該演演算法。主要是因為不能很好的解決迴圈引用的問題。

舉個簡單的例子來說明迴圈引用:

class Container{    public Object obj ;
}public class ReferTest {    public static void main(String[] args){
        Container c1 =new Container();
        Container c2 =new Container();
        c1.obj = c2 ;
        c2.obj = c1 ;
        
        c1 = null ;
        c2 = null ;        //此時c1 c1會被判定為死亡物件麼?    }
}
複製程式碼

事實上會被判定為死亡物件,因為JAVA虛擬機器器不是採用引用計數來進行判斷的,因此如果發生垃圾回收,c1,c2 都會被回收記憶體。

2.可達性分析

Java、C#的主流實現都是採用該種方式,來判斷物件是否存活。

這個演演算法的基本思路就是一系列“GC Roots”作為起始點,從這些節點向下搜尋,搜尋到的所有引用鏈中的物件都是可達的,其餘的物件都是不可達的,如上例,即使c1,c2互相引用,但是c1,c2都不屬於GC Roots物件,因此都不可達。

Java中,以下幾種物件可以作為GC Roots:

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

瞭解了GC Roots之後,我們可能會希望存在這麼一種物件,記憶體夠的時候不進行回收,當需要記憶體時再將其回收。JDK 1.2 中對引用進行了擴充。將引用分為了4種,從強到弱依次為;

強引用(Strong Reference)

我們一般情況下使用的都是強引用,如Object o = new Object(),之類的程式碼。只要強引用還在,垃圾收集器就永遠不會回收被引用的物件。

軟引用(Soft Reference)

SoftReference類來實現,用來描述一些還有用但是不必須的物件,在系統如果不回收就會發生OOM時才會對軟引用進行記憶體回收。

弱引用(Weak Reference)

WeakReference類來實現,描述非必需的物件,強度弱,只能活到下一次發生垃圾回收前,無論那時記憶體是否短缺,都會對軟引用物件進行記憶體回收

虛引用(Phantom Reference)

PhantomReference類實現,不會對生存時間發生任何影響,唯一目的時能在這個物件被收集器回收時得到一個通知。

4.其他

及其不建議使用finalize()方法,雖然可以在回收時被呼叫,但是finalize()方法的執行代價高昂,不確定性大,無法保證各個物件的呼叫順序。使用finalize()能做的工作,使用try()finally()或其他方式可以執行的更好。大家可以忘記JAVA中有這個方法的存在。本身就是在JAVA剛誕生時向C/C++程式設計師做的妥協,但是未得到優化。

方法區(永久代)進行GC的效率極低,花費較大,但是在大量使用反射、動態代理等場景都需要虛擬機器器具備類解除安裝的功能,以保證永生代的空間。

二.垃圾收集演演算法

1.標記清除演演算法(Mark-Sweep)

演演算法分為兩個階段,標記與清除。

標記階段:標記出所有需要回收的物件。回收階段:將所有標記區域回收。由於該演演算法不對空間進行整理,因此會產生大量的記憶體碎片,記憶體空間碎片過多會導致在分配較大的物件時,因為沒有連續的記憶體而不得不提前觸發一個GC。另外,標記與清除的過程效率都不高。這也是最基礎的GC演演算法。

2.複製演演算法(Copying)

將記憶體的總容量分為兩塊,每次只使用其中的一塊,當這一塊用完了,觸發GC,此時將還存活的物件轉移到另一塊記憶體中,之前使用的那一塊記憶體完全清理掉。這樣每次對一個半區進行回收,也不會存在記憶體碎片,實現簡單,執行高效,但是一次只能使用半塊記憶體可能會造成浪費。

在新生代中,絕大部分的物件時“朝生夕死”的,因此,不需要按照1:1來劃分空間。而是將記憶體分為一塊較大的Eden區以及兩個Survivor區,HotSpot虛擬機器器中,Eden:Survivor=8:1 ,每次使用一個Eden區以及一個Survivor區,90%的空間,觸發GC後,將剩餘的物件轉移到未使用的Survivor中,然後清理Eden區和用過的Survivor區,空間不夠時,會擔保分配到老年代。這樣一次可以使用90%的記憶體空間,極大的提高了記憶體的使用率。因此,新生代一般採用這種演演算法來回收。

3.標記整理演演算法(Mark-Compact)

如果回收時空間內的物件存活率較高,那麼使用複製演演算法一次只能使用50%的空間(以應對所有物件都存活的情況),因此老年代採用標記整理演演算法。先對需要清理的物件進行標記,然後將存活的物件都向一端移動,直接清理掉端邊界以外的記憶體。這種方式也不會留下記憶體碎片。

標記整理演演算法沒有複製演演算法快。

三. Java垃圾收集器

(瞭解即可,需要時可以網上細查)

新生代收集器:Serial收集器、ParNew收集器(Serial的多執行緒版本)、Parallel Scanvenge收集器(控制吞吐量,提高相應速度)

老年代收集器:Serial Old收集器、Parallel Old收集器、CMS收集器(最短停頓)、G1(新生代、老年代都可回收)

四. 記憶體的分配與回收

新生代:即複製演演算法中提到的Eden區以及2個Survivor區。

老年代:新生代存活足夠長時間後進入老年代。堆上的另一塊區域。

Minor GC:發生在新生代的垃圾收集動作。因為Java物件存活時間一般較短,故Minor GC非常頻繁,一般回收速度也較快。

Full GC:發生在老年代的垃圾收集動作,伴隨著最少一次的Minor GC,且速度較慢(比Minor GC慢10倍以上)

1.空間的分配

1)物件優先在新生代Eden區分配。當Eden區沒有足夠空間時,將發動一次Minor GC.

2)較大物件需要連續的空間,如長字串或陣列,如果放在新生代會提前觸發GC。故大物件直接進入老年代區域,避免頻繁的GC。

3)長期存活的物件進入老年代,每個物件有一個年齡,在物件頭Mark Word中記錄,剛被建立時年齡為0,當它活過一次Minor GC,並且轉移到Survivor中,年齡變為1,此後,在Survivor區中每活過一個Minor GC,年齡就會+1,當年齡達到某個程度(預設為15),就會晉升到老年代。

4)此外,為了適應記憶體的複雜情況,年齡不一定達到規定值才能進入老年代。當Survivor區的相同年齡所有物件大小大於Survivor區大小的一半時,此年齡就會被作為判定標準,大於等於該年齡的都會進入老年代。

2.空間的回收--GC

這裡我用一張圖來徹底解釋清除:

需要解釋的地方有:擔保失敗,這個的作用在圖上已經解釋的很清楚了,可以在JVM引數設定。

另外一個地方就是平均大小來作比較,因為有多少物件晉升到老年代是無法知道的,所以只好取之前每一次晉升到老年代的物件的容量的平均值大小來作為經驗值,來決定是否進行Full GC來讓老年代騰出更多空間。如果仍然失敗,那麼只能進行一次Full GC。在我個人開來,之所以使用擔保,經驗值來儘可能的只進行MinorGC,所有的一切,都是為了儘可能不執行Full GC的情況下將需要申請的記憶體空間搞定