Java虛擬機詳解(三)------垃圾回收

語言: CN / TW / HK

大家好,我是可樂,一個專注原創,樂於分享的程序猿。 本系列教程持續更新,可以微信搜索「 IT可樂 」第一時間閲讀。回覆《電子書》有我為大家特別篩選的海量免費書籍資料

  如果對C++這門語言熟悉的人,再來看Java,就會發現這兩者對垃圾(內存)回收的策略有很大的不同。

  C++:垃圾回收很重要,我們必須要自己來回收!!!

  Java:垃圾回收很重要,我們必須交給系統來幫我們完成!!!

  我想這也能看出這兩門語言設計者的心態吧,總之,Java和C++之間有一堵由內存動態分佈和垃圾回收技術所圍成的高牆,牆外面的人想進去,牆裏面的人想出來。

  本篇博客我們就來詳細介紹Java的垃圾回收策略。

1、為什麼要進行垃圾回收

  我們知道Java是一門面向對象的語言,在一個系統運行中,會伴隨着很多對象的創建,而這些對象一旦創建了就佔據了一定的內存,在上一篇博客Java運行時內存結構中,我們介紹過創建的對象是保存在堆中的,當對象使用完畢之後,不對其進行清理,那麼會一直佔據內存空間,很明顯內存空間是有限的,如果不回收這些無用的對象佔據的內存,那麼新創建的對象申請不了內存空間,系統就會拋出異常而無法運行,所以必須要經常進行內存的回收,也就是垃圾收集。

2、為什麼要了解垃圾回收

  文章開頭,我們就説Java的垃圾回收是系統自動進行的,不需要我們程序員手動處理,那麼我們為什麼還要了解垃圾回收呢,?

  其實這也是一個程序員進階的過程,生產項目在運行過程中,很可能會存在內存溢出、內存泄露等問題,出現了這些問題,我們應該怎麼排查?以及在生產服務器有限的資源上如何更好的分配Java運行時內存區域,提高系統運行效率等,我們必須知其然知其所以然。

  PS:本篇博客只是介紹Java垃圾回收機制,關於排查內存泄漏、溢出,運行時內存區域參數調優等會在後面進行介紹。

3、回收哪部分區域內存

  還是結合上一篇博客Java運行時內存結構,我們介紹了Java運行時的內存結構,其中程序計數器、虛擬機棧、本地方法棧這三個區域是線程私有的,隨線程而生,隨線程而滅,棧中的棧幀隨着方法的進入和退出而有條不紊的執行着入棧和出棧操作,這幾個區域的內存分配和回收都具備確定性,在方法結束或線程結束時,內存也就跟着回收了,所以不需要我們考慮。

  那麼現在就剩下Java堆和方法區了,這兩塊區域在編譯期間我們並不能完全確定創建多少個對象,有些是在運行時期創建的對象,所以Java內存回收機制主要是作用在這兩塊區域。

4、如何判斷對象為垃圾對象

  通過上面介紹了,我們瞭解了為什麼要進行垃圾回收以及回收哪部分的垃圾,那麼接下來我們怎麼去區分哪些對象為垃圾呢?

  換句話來説,我們如何判斷哪些對象還“活着”,哪些對象已經“死了”,那些“死了”的對象佔據的內存就是我們要進行回收的。

①、引用計數算法   這種算法是這樣的:給每一個創建的對象增加一個引用計數器,每當有一個地方引用它時,這個計數器就加1;而當引用失效時,這個計數器就減1。當這個引用計數器值為0時,也就是説這個對象沒有任何地方在使用它了,那麼這就是一個無效的對象,便可以進行垃圾回收了。

  這種算法實現簡單,而且效率也很高。但是Java沒有采用該算法來進行垃圾回收,因為這種算法無法解決對象之間的循環引用問題。

  下面我們就來構造一個循環引用的例子:

  首先,有一個 Person 類,這個類有兩個自引用屬性,分別表示其父親,兒子。

package com.ys.algorithmproject.leetcode.demo.JVM;

/**
 * Create by YSOcean
 */
public class Person {

    private Byte[] _1MB = null;

    public Person() {
        /**
         * 這個成員屬性的作用純粹就是佔據一定內存,以便在日誌中查看是否被回收
         */
        _1MB = new Byte[1*1024*1024];
    }



    private Person father;
    private Person son;

    public Person getFather() {
        return father;
    }

    public void setFather(Person father) {
        this.father = father;
    }

    public Person getSon() {
        return son;
    }

    public void setSon(Person son) {
        this.son = son;
    }
}
複製代碼

  接着,我們通過Person類構造兩個對象,分別是父親,兒子,如下:

public static void main(String[] args) {

    Person father = new Person();
    Person son = new Person();
    father.setSon(son);
    son.setFather(father);

    father = null;
    son = null;

    /**
     * 調用此方法表示希望進行一次垃圾回收。但是它不能保證垃圾回收一定會進行,
     * 而且具體什麼時候進行是取決於具體的虛擬機的,不同的虛擬機有不同的對策。
     */
    System.gc();
}
複製代碼

  首先,從第3-6行代碼,其運行時內存結構圖如下:

  father對象和son對象,其引用計數第一個是棧內存指向,第二個就是其屬性互相引用對方,所有引用計數器都是2。

  接着我們看第8,9行代碼,分別將這兩個對象置為null,也就是去掉了棧內存指向。

  這時候其實這兩個對象只是自己互相引用了,沒有別的地方在引用它們,引用計數器為1,那麼這兩個對象按照引用計數算法實現的虛擬機就不會回收,可想而知,這是我們不能接受的。

  所以Java虛擬機都沒有使用該算法來判斷對象是否存活,我們可以通過增加打印虛擬機參數來驗證。

  我們將上面的man函數,增加如下Java虛擬機參數,用來打印gc信息。

-verbose:gc

  在IDEA編輯器中,添加方式如下:

  運行結果如下:

  我們看到12201K->1088K(125952K)的輸出,表示垃圾收集GC前有12201K,回收後剩下1088K,堆的總量為125952K,回收的內存為12201K-1088K = 11113K。

  換句話説,上面的例子Java虛擬機是有進行垃圾回收的,所以,這也間接佐證了Java虛擬機並不是採用的引用計數法來判斷對象是否是垃圾。

  PS:這些參數信息詳解也會在後面博客進行詳細介紹。

②、根搜索算法   我們這裏直接給出結論:在主流的商用程序中(Java,C#),都是使用根搜索算法(GC Roots Tracing)來判定對象是否存活。

  該算法思路:通過一系列名為“GC Roots” 的對象作為終點,當一個對象到GC Roots 之間無法通過引用到達時,那麼該對象便可以進行回收了。

  上圖Object1,Object2,Object3,Object4到GC Roots是可達的,所以不會被作為垃圾回收。

  上圖Object1,Object2,Object3這三個對象互相引用,但是到 GC Roots不可達,所以都會被垃圾回收掉。

  那麼有哪些對象可以作為 GC Roots 呢?

  在Java語言中,有如下4中對象可以作為 GC Roots:

  PS:紅色的對象是要被當做垃圾回收的!

虛擬機棧(棧幀中的本地變量表)中引用的對象
方法區中的靜態變量屬性引用的對象
方法區中常量引用的對象
本地方法棧中(JNI)(即一般説的Native方法)的引用的對象
複製代碼

5、如何進行垃圾回收

  垃圾回收涉及到大量的程序細節,而且各個平台的虛擬機操作內存的方式也不一樣,但是他們進行垃圾回收的算法是通用的,所以這裏我們也只介紹幾種通用算法。

①、標記-清除算法   算法實現:分為標記-清除兩個階段,首先根據上面的根搜索算法標記出所有需要回收的對象,在標記完成後,然後在統一回收掉所有被標記的對象。

  缺點:

  1、效率低:標記和清除這兩個過程的效率都不高。

  2、容易產生內存碎片:因為內存的申請通常不是連續的,那麼清除一些對象後,那麼就會產生大量不連續的內存碎片,而碎片太多時,當有個大對象需要分配內存時,便會造成沒有足夠的連續內存分配而提前觸發垃圾回收,甚至直接拋出OutOfMemoryExecption。

②、複製算法   為了解決標記-清除算法的兩個缺點,複製算法誕生了。

  算法實現:將可用內存按容量劃分為大小相等的兩塊區域,每次只使用其中一塊,當這一塊的內存用完了,就將還活着的對象複製到另一塊區域上,然後再把已使用過的內存空間一次性清理掉。

  優點:每次都是隻對其中一塊內存進行回收,不用考慮內存碎片的問題,而且分配內存時,只需要移動堆頂指針,按順序進行分配即可,簡單高效。

  缺點:將內存分為兩塊,但是每次只能使用一塊,也就是説,機器的一半內存是閒置的,這資源浪費有點嚴重。並且如果對象存活率較高,每次都需要複製大量的對象,效率也會變得很低。

③、標記-整理算法   上面我們説過複製算法會浪費一半的內存,並且對象存活率較高時,會有過多的複製操作,效率低下。

  如果對象存活率很高,基本上不會進行垃圾回收時,標記-整理算法誕生了。

  算法實現:首先標記出所有存活的對象,然後讓所有存活對象向一端進行移動,最後直接清理到端邊界以外的內存。

  侷限性:只有對象存活率很高的情況下,使用該算法才會效率較高。

④、分代收集算法   當前商業虛擬機都是採用此算法,但是其實這不是什麼新的算法,而是上面幾種算法的合集。

  算法實現:根據對象的存活週期不同將內存分為幾塊,然後不同的區域採用不同的回收算法。

    1、對於存活週期較短,每次都有大批對象死亡,只有少量存活的區域,採用複製算法,因為只需要付出少量存活對象的複製成本即可完成收集;

    2、對於存活週期較長,沒有額外空間進行分配擔保的區域,採用標記-整理算法,或者標記-清除算法。

  比如,對於 HotSpot 虛擬機,它將堆空間分為如下兩塊區域:

  堆有新生代和老年代兩塊區域組成,而新生代區域又分為三個部分,分別是 Eden,From Surivor,To Survivor ,比例是8:1:1。

  新生代採用複製算法,每次使用一塊Eden區和一塊Survivor區,當進行垃圾回收時,將Eden和一塊Survivor區域的所有存活對象複製到另一塊Survivor區域,然後清理到剛存放對象的區域,依次循環。

  老年代採用標記-清除或者標記-整理算法,根據使用的垃圾回收器來進行判斷。

  至於為什麼要這樣,這是由於內存分配的機制導致的,新生代存的基本上都是朝生夕死的對象,而老年代存放的都是存活率很高的對象。關於內存分配下篇博客我們會詳細進行介紹。

6、何時進行垃圾回收

  理清了什麼是垃圾,怎麼回收垃圾,最後一點就是Java虛擬機何時進行垃圾回收呢?

  程序員可以調用 System.gc()方法,手動回收,但是調用此方法表示希望進行一次垃圾回收。但是它不能保證垃圾回收一定會進行,而且具體什麼時候進行是取決於具體的虛擬機的,不同的虛擬機有不同的對策。   其次虛擬機會自行根據當前內存大小,判斷何時進行垃圾回收,比如前面所説的,新生代滿了,新產生的對象無法分配內存時,便會觸發垃圾回收機制。

  這裏需要説明的是宣告一個對象死亡,至少要經歷兩次標記,前面我們説過,如果對象與GC Roots 不可達,那麼此對象會被第一次標記並進行一次篩選,篩選的條件是此對象是否有必要執行 finalize() 方法,當對象沒有覆蓋 finalize()方法,或者該方法已經執行了一次,那麼虛擬機都將視為沒有必要執行finalize()方法。

  如果這個對象有必要執行 finalize() 方法,那麼該對象將會被放置在一個有虛擬機自動建立、低優先級,名為 F-Queue 隊列中,GC會對F-Queue進行第二次標記,如果對象在finalize() 方法中成功拯救了自己(比如重新與GC Roots建立連接),那麼第二次標記時,就會將該對象移除即將回收的集合,否則就會被回收。

本系列教程持續更新,可以微信搜索「 IT可樂 」第一時間閲讀。回覆《電子書》有我為大家特別篩選的書籍資料