Java中的垃圾回收算法詳解


一、前言

  前段時間大致看了一下《深入理解Java虛擬機》這本書,對相關的基礎知識有了一定的了解,准備寫一寫JVM的系列博客,這是第二篇。這篇博客就來談一談JVM中使用到的垃圾回收算法。


二、正文

 2.1 什么是垃圾回收

  在正式介紹垃圾回收算法前,先來說說什么是垃圾回收。這里所說的垃圾主要指的是已經不會再繼續使用的對象,當然也有可能是其他,比如不再使用的類以及常量,但主要還是指對象,所以以下算法將介紹對象的回收。所以垃圾回收的含義就是:將內存中已經不會被使用的對象(或類和常量)清除,釋放內存空間

  JVM的內存模型分為五個部分,其中堆內存的唯一目的就是存放對象,對象也基本上都是存放在堆內存中。堆中,為了方便進行垃圾回收,一般會將內存分為兩個部分:

  • 新生代:用來存放生命周期短的對象。由於這一塊內存中的對象存活時間較短,所以頻繁發生垃圾回收,而且每次回收一般都能釋放大量空間;
  • 老年代:用來存放生命周期長的對象。新生代中存活了較長時間的對象會被遷移到這里(當然,對象進入老年代不僅僅只有這一個方法),所以這里存放的對象生命周期一般較長,所以這一塊區域發生垃圾回收的頻率較低,釋放的空間也較少;

  下面正式開始討論JVM中的垃圾回收算法。


 2.2 如何識別垃圾

  進行垃圾回收的第一步就是找到垃圾(我們這里主要以對象為例),也就是無法被使用的對象。對象在什么情況下無法被使用?很簡單,沒有引用指向這個對象,我們自然無法使用它,比如看下面這段代碼:

public static void main(String[] args) throws InterruptedException {
    Object a = new Object();
    a = null;
}

  上面的代碼中,我創建了一個對象,並使用變量a指向這個對象,但是在這之后,我又將null賦給了a,這會出現什么情況?不難發現,我們已經無法使用這個對象了,它已經丟失了,因為我們已經無法通過任何變量去調用這個對象,但是它依然在內存中。此時,這個對象占用着內存就是白白浪費資源,我們希望它被清除。所以,我們可以想到,當一個對象沒有引用指向它時,就可以認為他是一個垃圾對象了。

(1)引用計數法

  引用計數法就是通過引用來識別無用對象。我們記錄每一個對象的引用個數,若有新的變量引用一個對象時,這個對象的引用個數加1;若一個引用失效時,引用的個數減1,而引用個數為0的對象,即可作為垃圾被回收。這里要注意,若這些垃圾對象的成員變量引用了其他對象,則當垃圾對象被釋放時,它的這個引用自然就失效了。

  這個算法實現簡單,效率也高,但是,它並沒有被用在主流的Java虛擬機中,因為它有一個很大的缺陷——很難解決循環引用的問題。什么是循環引用,看下面一段代碼:

public class Main {
    
    private Object obj;
    
    public static void main(String[] args) {
        Main m1 = new Main();
        Main m2 = new Main();

        // 循環引用
        m1.obj = m2;
        m2.obj = m1;

        m1 = null;
        m2 = null;
    }
}

  上面這段代碼中,創建了兩個對象m1m2,它們都有一個屬性obj。而m1obj指向了m2,而m2obj指向了m1。多個引用形成一個環,這就是循環引用。這對於使用引用計數算法的垃圾回收器來說有一個問題,即上面的代碼最后,m1m2都置為了空,它們指向的兩個對象已經無法再使用了,但是由於這兩個對象相互引用,導致它們的引用計數並不為0,所以垃圾回收器不會將它們判別為無用對象。正是因為這個問題的存在,Java中的垃圾回收器基本上不使用這個算法。

(2)可達性分析法

  可達性分析法是Java垃圾回收中判別無用對象的主要方法。這個方法的步驟是,從根節點對象出發,使用DFSBFS算法,沿着引用遞歸遍歷,而無法被遍歷到的對象,就是無法再被使用的對象,可以被垃圾回收器回收。所謂的根節點,就是我們能夠直接使用的引用類型變量,如:

  • 方法中的參數或局部變量;
  • 類的靜態成員或非靜態成員;
  • 代碼中的常量;

  這種方法的效率相對於引用計數來說相對復雜,而且效率較低,但是解決了循環引用的問題,是Java垃圾回收中主要使用的方法。


 2.3 如何釋放垃圾

  釋放垃圾指的就是清除無用對象,釋放它們所占的內存空間,方便繼續使用。這里主要介紹三種方法:

  • 標記—清除算法;
  • 復制算法;
  • 標記—整理算法;

  這三種算法根據具體情況的不同,搭配使用,才能發揮最好的效果。下面就來一一介紹。


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

  標記—清除是以上上面三種算法中最基礎的一種,為什么說它是最基礎的,因為它的原理非常簡單。故名思意,這個算法分為兩個步驟:(1)標記;(2)清除。

  • 標記:標記指的就是我們上面所說的可達性分析,采用之前所說的可達性分析算法遍歷對象,所有不可達的對象將被標記為垃圾,等待回收;
  • 清除:這一步很簡單,直接釋放垃圾對象所占內存空間;

  這個算法有兩個的問題:

  1. 效率較低,標記和清除這兩個步驟的效率都比較低,清除的效率低是因為需要掃描整個內存空間,逐個釋放對象所占內存;
  2. 使用這個算法清除垃圾后,將會造成很多內存碎片,所以可能出現剩余內存較多,但是沒有較大的連續空間,導致大對象無法被分配空間,而再次觸發垃圾回收;

  我們通過兩張對比圖來看看這個算法的效果。通過下面這張圖我們可以看到,在垃圾回收后造成了很多的內存碎片。


(2)復制算法(Copying)

  為了解決效率較低以及產生內存碎片的問題,有人提出了一個新的算法——復制算法。這個算法的原理是:將內存分為兩個相等大小的區域,一塊存放對象,一塊保留。當存放對象的那塊區域無法再分配空間時,將所有仍然存活的對象復制到保留的那塊區域中,然后直接釋放當前正在使用區域的全部內存。這樣一來,仍然存活的對象被放進保留區,而垃圾對象也被釋放了。同時,之前被使用的空間被清空后,成了新的保留區,而之前的保留區成了被使用的空間,就這樣不斷循環使用兩個空間。

  我們之前提過,堆內存被分為新生代和老年代。在新生代中,每次垃圾回收都可以釋放大量的對象,只有少部分存活,所以只有少部分對象要被復制到保留區中,這也意味着復制並不會太耗時。除此之外,直接釋放被使用的空間的全部內存,比一段一段釋放的效率也要高很多。同時,對象被復制到另外一個區域時,會被整齊地擺放,所以不會出現內存碎片,所以能夠更簡單地分配空間。所以,復制算法的效率要遠遠高於標記—清除算法。以下是一張復制算法的演示圖:

  但是,這里存在一個問題,復制算法將內存區域划分為相等的兩部分,這也意味着每次都有一半的空間無法被使用,這未免也太浪費了。所以,對於空間的划分,需要做出一些改進。IBM公司的研究表明,98%的對象存活時間都非常的短暫,所以,完全沒有必要保留一半的空間供復制使用。在實際實現中,會將空間划分為三塊區域,一塊較大的Eden空間,以及兩塊較小的Survivor空間。在為新對象分配空間時,首先會將其分配到Eden空間中,若Eden空間無法再分配空間時,將會觸發垃圾回收,此時,會將Eden空間中的存活對象復制到其中一塊Survivor空間中,然后清空Eden空間。當Eden空間再一次因無法分配空間而觸發垃圾回收時,則會將Eden空間中的存活對象,以及上一次被復制進Survivor空間中的存活對象,都復制到另一塊Survivor空間中,然后將Eden和上一塊Survivor清空。也就是說,交替地使用兩塊Survivor空間,來存放垃圾回收中任然存活的對象。而在具體實現中,這三個空間的比例一搬是8:1:1,即是說只有10%的空間無法被使用。

  可以看出,這個算法在大部分對象的生命周期都短時,效率會非常高,但是若大部分對象的生命周期都很長,將不再適用,所以這個算法一般只被用在新生代中。這里我們不得不考慮一個問題,當我們使用了上面說的將內存划分為三塊的這種方式時,可能會出現一個問題:如果在某次垃圾回收過后,仍然有大量的對象存活,此時一個Survivor空間不夠存放這些對象怎么辦?這時候就需要有另一個空間來做擔保了,當這種情況發生時,會將這些對象放入另一個空間中,那個空間就叫做擔保空間。就像我們去銀行貸款,需要有一個擔保人,當貸款人不能償還時,由擔保人代為償還。以上算法是用在新生代中,而所謂的擔保空間,實際上就是老年代。老年代為這個算法提供了擔保,但是在大部分情況下,Survivor都是能夠滿足需求的。


(3)標記—整理(Mark-Compact)

  由於老年代中的對象一般存活時間都比較長,所以並不適合在老年代使用上面的復制算法進行垃圾回收。而有人根據老年代的特點,提出了標記—整理算法,注意看清楚,這里是整理,而不是第一種算法中的清除。這個算法也分為標記和整理兩個步驟,標記這個步驟和第一個算法是一樣的,關鍵是整理步驟。所謂的整理,就是將內存中還存活的對象向一邊移動,直至這些對象相互靠攏,整齊排列,然后直接清除不屬於這一部分的全部內存。標記—整理的好處是解決內存碎片的問題。以下是這個算法的演示圖:


(4)分代收集算法

  分代收集算法並不是什么新思想,而是對上面三種算法的綜合使用。前面也提過,為方便垃圾回收,一般將堆內存分為新生代老年代兩個部分。

  • 對於新生代而言,這一塊區域中的對象存活時間短,每一次垃圾回收都能回收大部分內存,所以適合使用復制算法,同時以老年代作為這個算法的擔保空間;
  • 對於老年代而言,每次垃圾回收只能釋放小部分空間,若使用復制算法,每次將需要做大量復制,而且此時Survivor需要較大的空間,所以不適合使用復制算法,因此在老年代中,一般使用標記—清除或者標記—整理算法;

三、總結

  上面對JVM中的垃圾回收算法做了一個比較詳細的介紹,相信看完這一篇博客會對這部分內容有更深的理解。但是,歸根到底,上面的內容只是理論,接下來我將寫一篇博客,來講講JVM具體如何分配和釋放對象,作為JVM系列博客的第三篇。


四、參考

  • 《深入理解Java虛擬機》


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM