JVM與DVM ——(2)GC 回收機(jī)制與分代回收策略

垃圾回收(Garbage Collection,簡(jiǎn)寫為 GC)可能是虛擬機(jī)眾多知識(shí)點(diǎn)中最為大眾所熟知的一個(gè)了,也是Java開(kāi)發(fā)者最關(guān)注的一塊知識(shí)點(diǎn)。不同于C語(yǔ)言,在Java 語(yǔ)言中,我們不需要手動(dòng)釋放對(duì)象的內(nèi)存,JVM 中的垃圾回收器(Garbage Collector)會(huì)為我們自動(dòng)回收。但是這種幸福是有代價(jià)的:一旦這種自動(dòng)化機(jī)制出錯(cuò),我們又不得不去深入理解 GC 回收機(jī)制,甚至需要對(duì)這些“自動(dòng)化”的技術(shù)實(shí)施必要的監(jiān)控和調(diào)節(jié)。

Java 內(nèi)存運(yùn)行時(shí)區(qū)域的各個(gè)部分,其中程序計(jì)數(shù)器、虛擬機(jī)棧、本地方法棧 3 個(gè)區(qū)域隨線程而生,隨線程而滅;棧中的棧幀隨著方法的進(jìn)入和退出而有條不紊地執(zhí)行著出棧和入棧操作,這幾個(gè)區(qū)域內(nèi)不需要過(guò)多考慮回收的問(wèn)題。

而堆和方法區(qū)則不一樣,一個(gè)接口中的多個(gè)實(shí)現(xiàn)類需要的內(nèi)存可能不一樣,一個(gè)方法中的多個(gè)分支需要的內(nèi)存也可能不一樣,我們只有在程序處于運(yùn)行期間時(shí)才能知道會(huì)創(chuàng)建哪些對(duì)象,這部分內(nèi)存的分配和回收都是動(dòng)態(tài)的,垃圾收集器所關(guān)注的就是這部分內(nèi)存。

什么是垃圾

垃圾就是內(nèi)存中已經(jīng)沒(méi)有用的對(duì)象。 既然是”垃圾回收",那就必須知道哪些對(duì)象是垃圾。Java 虛擬機(jī)中使用一種叫作"可達(dá)性分析”的算法來(lái)決定對(duì)象是否可以被回收。

可達(dá)性分析

可達(dá)性分析算法是從離散數(shù)學(xué)中的圖論引入的,JVM 把內(nèi)存中所有的對(duì)象之間的引用關(guān)系看作一張圖,通過(guò)一組名為”GC Root"的對(duì)象作為起始點(diǎn),從這些節(jié)點(diǎn)開(kāi)始向下搜索,搜索所走過(guò)的路徑稱為引用鏈,最后通過(guò)判斷對(duì)象的引用鏈?zhǔn)欠窨蛇_(dá)來(lái)決定對(duì)象是否可以被回收。如下圖所示:

img

比如上圖中,對(duì)象A/B/C/D/E 與 GC Root 之間都存在一條直接或者間接的引用鏈,這也代表它們與 GC Root 之間是可達(dá)的,因此它們是不能被 GC 回收掉的。而對(duì)象M和K雖然被對(duì)象 J 引用到,但是并不存在一條引用鏈連接它們與 GC Root,所以當(dāng) GC 進(jìn)行垃圾回收時(shí),只要遍歷到 J/K/M 這 3 個(gè)對(duì)象,就會(huì)將它們回收。

注意:上圖中圓形圖標(biāo)雖然標(biāo)記的是對(duì)象,但實(shí)際上代表的是此對(duì)象在內(nèi)存中的引用。包括 GC Root 也是一組引用而并非對(duì)象。

GC Root 對(duì)象

在 Java 中,有以下幾種對(duì)象可以作為 GC Root:

  1. Java 虛擬機(jī)棧(局部變量表)中的引用的對(duì)象。
  2. 方法區(qū)中靜態(tài)引用指向的對(duì)象。
  3. 仍處于存活狀態(tài)中的線程對(duì)象。
  4. Native 方法中 JNI 引用的對(duì)象。

什么時(shí)候回收

不同的虛擬機(jī)實(shí)現(xiàn)有著不同的 GC 實(shí)現(xiàn)機(jī)制,但是一般情況下每一種 GC 實(shí)現(xiàn)都會(huì)在以下兩種情況下觸發(fā)垃圾回收。

  1. Allocation Failure:在堆內(nèi)存中分配時(shí),如果因?yàn)榭捎檬S嗫臻g不足導(dǎo)致對(duì)象內(nèi)存分配失敗,這時(shí)系統(tǒng)會(huì)觸發(fā)一次 GC。
  2. System.gc():在應(yīng)用層,Java 開(kāi)發(fā)工程師可以主動(dòng)調(diào)用此 API 來(lái)請(qǐng)求一次 GC。

代碼驗(yàn)證 GC Root 的幾種情況

了解了 Java 中的 GC Root,以及何時(shí)觸發(fā) GC,接下來(lái)就通過(guò)幾個(gè)案例來(lái)驗(yàn)證 GC Root 的情況。在看具體代碼之前,我們先了解一個(gè)執(zhí)行 Java 命令時(shí)的參數(shù)。

-Xms 初始分配 JVM 運(yùn)行時(shí)的內(nèi)存大小,如果不指定默認(rèn)為物理內(nèi)存的 1/64。

比如我們運(yùn)行如下命令執(zhí)行 HelloWorld 程序,從物理內(nèi)存中分配出 200M 空間分配給 JVM 內(nèi)存。

java -Xms200m HelloWorld

驗(yàn)證虛擬機(jī)棧(棧幀中的局部變量)中引用的對(duì)象作為 GC Root

運(yùn)行如下代碼:

public class GCRootLocalVariable {
    private int _10MB = 10 * 1024 * 1024;
    private byte[] memory = new byte[8 * _10MB];
    
    public static void main(String[] args){
        System.out.println("開(kāi)始時(shí):");
        printMemory();
        method();
        System.gc();
        System.out.println("第二次GC完成");
        printMemory();
    }
    
    public static void method() {
        GCRootLocalVariable g = new GCRootLocalVariable();
        System.gc();
        System.out.println("第一次GC完成");
        printMemory();
    }

    /**
     * 打印出當(dāng)前JVM剩余空間和總的空間大小
     */
    public static void printMemory() {
        System.out.print("free is " + Runtime.getRuntime().freeMemory()/1024/1024 + " M, ");
        System.out.println("total is " + Runtime.getRuntime().totalMemory()/1024/1024 + " M, ");
    }
}

打印日志:

開(kāi)始時(shí):
free is 242 M, total is 245 M,
第一次GC完成
free is 163 M, total is 245 M,
第二次GC完成
free is 243 M, total is 245 M,

可以看出:

  • 當(dāng)?shù)谝淮?GC 時(shí),g 作為局部變量,引用了 new 出的對(duì)象(80M),并且它作為 GC Roots,在 GC 后并不會(huì)被 GC 回收。
  • 當(dāng)?shù)诙?GC:method() 方法執(zhí)行完后,局部變量 g 跟隨方法消失,不再有引用類型指向該 80M 對(duì)象,所以第二次 GC 后此 80M 也會(huì)被回收。

注意:上面日志包括后面的實(shí)例中,因?yàn)橛兄虚g變量,所以會(huì)有 1M 左右的誤差,但不影響我們分析 GC 過(guò)程。

驗(yàn)證方法區(qū)中的靜態(tài)變量引用的對(duì)象作為 GC Root

運(yùn)行如下代碼:

public class GCRootStaticVariable{
    private static int _10MB = 10 * 1024 * 1024;
    private byte[] memory;
    private static GCRootStaticVariable staticVariable;

    public GCRootStaticVariable(int size) {
        memory = new byte[size];
    }

    public static void main(String[] args){
        System.out.println("程序開(kāi)始:");
        printMemory();
        GCRootStaticVariable g = new GCRootStaticVariable(4 * _10MB);
        g.staticVariable = new GCRootStaticVariable(8 * _10MB);
        // 將g置為null, 調(diào)用GC時(shí)可以回收此對(duì)象內(nèi)存
        g = null;
        System.gc();
        System.out.println("GC完成");
        printMemory();
    }

    /**
     * 打印出當(dāng)前JVM剩余空間和總的空間大小
     */
    public static void printMemory() {
        System.out.print("free is " + Runtime.getRuntime().freeMemory()/1024/1024 + " M, ");
        System.out.println("total is " + Runtime.getRuntime().totalMemory()/1024/1024 + " M, ");
    }
}

打印日志:

程序開(kāi)始:
free is 242 M, total is 245 M,
GC完成
free is 163 M, total is 245 M,

可以看出:

程序剛開(kāi)始運(yùn)行時(shí)內(nèi)存為 242M,并分別創(chuàng)建了 g 對(duì)象(40M),同時(shí)也初始化 g 對(duì)象內(nèi)部的靜態(tài)變量 staticVariable 對(duì)象(80M)。當(dāng)調(diào)用 GC 時(shí),只有 g 對(duì)象的 40M 被 GC 回收掉,而靜態(tài)變量 staticVariable 作為 GC Root,它引用的 80M 并不會(huì)被回收。

驗(yàn)證活躍線程作為 GC Root

運(yùn)行如下代碼:

public class GCRootThread{
    private int _10MB = 10 * 1024 * 1024;
    private byte[] memory = new byte[8 * _10MB];

    public static void main(String[] args) throws Exception {
        System.out.println("開(kāi)始前內(nèi)存情況:");
        printMemory();
        AsyncTask at = new AsyncTask(new GCRootThread());
        Thread thread = new Thread(at);
        thread.start();
        System.gc();
        System.out.println("main方法執(zhí)行完畢,完成GC");
        printMemory();
        thread.join();
        at = null;
        System.gc();
        System.out.println("線程代碼執(zhí)行完畢,完成GC");
        printMemory();
    }

    /**
     * 打印出當(dāng)前JVM剩余空間和總的空間大小
     */
    public static void printMemory() {
        System.out.print("free is " + Runtime.getRuntime().freeMemory()/1024/1024 + " M, ");
        System.out.println("total is " + Runtime.getRuntime().totalMemory()/1024/1024 + " M, ");
    }

    private static class AsyncTask implements Runnable {
        private GCRootThread gcRootThread;

        public AsyncTask(GCRootThread gcRootThread){
            this.gcRootThread = gcRootThread;
        }
        
        @Override
        public void run() {
            try{
                Thread.sleep(500);
            } catch(Exception e){}
        }
    }
}
開(kāi)始前內(nèi)存情況:
free is 242 M, total is 245 M,
main方法執(zhí)行完畢,完成GC
free is 163 M, total is 245 M,
線程代碼執(zhí)行完畢,完成GC
free is 243 M, total is 245 M,

程序剛開(kāi)始時(shí)是 242M 內(nèi)存,當(dāng)調(diào)用第一次 GC 時(shí)線程并沒(méi)有執(zhí)行結(jié)束,并且它作為 GC Root,所以它所引用的 80M 內(nèi)存并不會(huì)被 GC 回收掉。 thread.join() 保證線程結(jié)束再調(diào)用后續(xù)代碼,所以當(dāng)調(diào)用第二次 GC 時(shí),線程已經(jīng)執(zhí)行完畢并被置為 null,這時(shí)線程已經(jīng)被銷毀,所以之前它所引用的 80M 此時(shí)會(huì)被 GC 回收掉。

測(cè)試成員變量是否可作為 GC Root

運(yùn)行如下代碼:

public class GCRootClassVariable{
    private static int _10MB = 10 * 1024 * 1024;
    private byte[] memory;
    private GCRootClassVariable classVariable;
    public GCRootClassVariable(int size){
        memory = new byte[size];
    }
    public static void main(String[] args){
        System.out.println("程序開(kāi)始:");
        printMemory();
        GCRootClassVariable g = new GCRootClassVariable(4 * _10MB);
        g.classVariable = new GCRootClassVariable(8 * _10MB);
        g = null;
        System.gc();
        System.out.println("GC完成");
        printMemory();
    }
    /**
     * 打印出當(dāng)前JVM剩余空間和總的空間大小
     */
    public static void printMemory() {
        System.out.print("free is " + Runtime.getRuntime().freeMemory()/1024/1024 + " M, ");
        System.out.println("total is " + Runtime.getRuntime().totalMemory()/1024/1024 + " M, ");
    }
}

打印日志:

程序開(kāi)始:
free is 242 M, total is 245 M,
GC完成
free is 243 M, total is 245 M,

從上面日志中可以看出當(dāng)調(diào)用 GC 時(shí),因?yàn)?g 已經(jīng)置為 null,因此 g 中的全局變量 classVariable 此時(shí)也不再被 GC Root 所引用。所以最后 g(40M) 和 classVariable(80M) 都會(huì)被回收掉。這也表明全局變量同靜態(tài)變量不同,它不會(huì)被當(dāng)作 GC Root。

上面演示的這幾種情況往往也是內(nèi)存泄漏發(fā)生的場(chǎng)景,設(shè)想一下我們將各個(gè) Test 類換成 Android 中的 Activity 的話將導(dǎo)致 Activity 無(wú)法被系統(tǒng)回收,而一個(gè) Activity 中的數(shù)據(jù)往往是較大的,因此內(nèi)存泄漏導(dǎo)致 Activity 無(wú)法回收還是比較致命的。

如何回收垃圾

垃圾收集算法的實(shí)現(xiàn)涉及大量的程序細(xì)節(jié),各家虛擬機(jī)廠商對(duì)其實(shí)現(xiàn)細(xì)節(jié)各不相同,因此本文并不過(guò)多的討論算法的實(shí)現(xiàn),只是介紹幾種算法的思想以及優(yōu)缺點(diǎn)。

標(biāo)記清除算法(Mark and Sweep GC)

從”GC Roots”集合開(kāi)始,將內(nèi)存整個(gè)遍歷一次,保留所有可以被 GC Roots 直接或間接引用到的對(duì)象,而剩下的對(duì)象都當(dāng)作垃圾對(duì)待并回收,過(guò)程分兩步。

  1. Mark 標(biāo)記階段:找到內(nèi)存中的所有 GC Root 對(duì)象,只要是和 GC Root 對(duì)象直接或者間接相連則標(biāo)記為灰色(也就是存活對(duì)象),否則標(biāo)記為黑色(也就是垃圾對(duì)象)。
  2. Sweep 清除階段:當(dāng)遍歷完所有的 GC Root 之后,則將標(biāo)記為垃圾的對(duì)象直接清除。

如下圖所示:

img
  • 優(yōu)點(diǎn):實(shí)現(xiàn)簡(jiǎn)單,不需要將對(duì)象進(jìn)行移動(dòng)。
  • 缺點(diǎn):這個(gè)算法需要中斷進(jìn)程內(nèi)其他組件的執(zhí)行(stop the world),并且可能產(chǎn)生內(nèi)存碎片,提高了垃圾回收的頻率。

復(fù)制算法(Copying)

將現(xiàn)有的內(nèi)存空間分為兩快,每次只使用其中一塊,在垃圾回收時(shí)將正在使用的內(nèi)存中的存活對(duì)象復(fù)制到未被使用的內(nèi)存塊中。之后,清除正在使用的內(nèi)存塊中的所有對(duì)象,交換兩個(gè)內(nèi)存的角色,完成垃圾回收。

  1. 復(fù)制算法之前,內(nèi)存分為 A/B 兩塊,并且當(dāng)前只使用內(nèi)存 A,內(nèi)存的狀況如下圖所示:
img
  1. 標(biāo)記完之后,所有可達(dá)對(duì)象都被按次序復(fù)制到內(nèi)存 B 中,并設(shè)置 B 為當(dāng)前使用中的內(nèi)存。內(nèi)存狀況如下圖所示:
img
  • 優(yōu)點(diǎn):按順序分配內(nèi)存即可,實(shí)現(xiàn)簡(jiǎn)單、運(yùn)行高效,不用考慮內(nèi)存碎片。
  • 缺點(diǎn):可用的內(nèi)存大小縮小為原來(lái)的一半,對(duì)象存活率高時(shí)會(huì)頻繁進(jìn)行復(fù)制。

標(biāo)記-壓縮算法 (Mark-Compact)

需要先從根節(jié)點(diǎn)開(kāi)始對(duì)所有可達(dá)對(duì)象做一次標(biāo)記,之后,它并不簡(jiǎn)單地清理未標(biāo)記的對(duì)象,而是將所有的存活對(duì)象壓縮到內(nèi)存的一端。最后,清理邊界外所有的空間。因此標(biāo)記壓縮也分兩步完成:

  1. Mark 標(biāo)記階段:找到內(nèi)存中的所有 GC Root 對(duì)象,只要是和 GC Root 對(duì)象直接或者間接相連則標(biāo)記為灰色(也就是存活對(duì)象),否則標(biāo)記為黑色(也就是垃圾對(duì)象)。
  2. Compact 壓縮階段:將剩余存活對(duì)象按順序壓縮到內(nèi)存的某一端。
img
  • 優(yōu)點(diǎn):這種方法既避免了碎片的產(chǎn)生,又不需要兩塊相同的內(nèi)存空間,因此,其性價(jià)比比較高。
  • 缺點(diǎn):所謂壓縮操作,仍需要進(jìn)行局部對(duì)象移動(dòng),所以一定程度上還是降低了效率。

JVM分代回收策略

Java 虛擬機(jī)根據(jù)對(duì)象存活的周期不同,把堆內(nèi)存劃分為幾塊,一般分為新生代老年代,這就是 JVM 的內(nèi)存分代策略。注意: 在 HotSpot 中除了新生代和老年代,還有永久代

分代回收的中心思想就是:對(duì)于新創(chuàng)建的對(duì)象會(huì)在新生代中分配內(nèi)存,此區(qū)域的對(duì)象生命周期一般較短。如果經(jīng)過(guò)多次回收仍然存活下來(lái),則將它們轉(zhuǎn)移到老年代中。

新生代(Young Generation)

新生成的對(duì)象優(yōu)先存放在新生代中,新生代對(duì)象朝生夕死,存活率很低,在新生代中,常規(guī)應(yīng)用進(jìn)行一次垃圾收集一般可以回收 70%~95% 的空間,回收效率很高。新生代中因?yàn)橐M(jìn)行一些復(fù)制操作,所以一般采用的 GC 回收算法是復(fù)制算法。

新生代又可以繼續(xù)細(xì)分為 3 部分:Eden、Survivor0(簡(jiǎn)稱 S0)、Survivor1(簡(jiǎn)稱S1)。這 3 部分按照 8:1:1 的比例來(lái)劃分新生代。這 3 塊區(qū)域的內(nèi)存分配過(guò)程如下:

絕大多數(shù)剛剛被創(chuàng)建的對(duì)象會(huì)存放在 Eden 區(qū)。如圖所示:

img

當(dāng) Eden 區(qū)第一次滿的時(shí)候,會(huì)進(jìn)行垃圾回收。首先將 Eden區(qū)的垃圾對(duì)象回收清除,并將存活的對(duì)象復(fù)制到 S0,此時(shí) S1是空的。如圖所示:

img

下一次 Eden 區(qū)滿時(shí),再執(zhí)行一次垃圾回收。此次會(huì)將 EdenS0區(qū)中所有垃圾對(duì)象清除,并將存活對(duì)象復(fù)制到 S1,此時(shí) S0變?yōu)榭铡H鐖D所示:

img

如此反復(fù)在 S0S1之間切換幾次(默認(rèn) 15 次)之后,如果還有存活對(duì)象。說(shuō)明這些對(duì)象的生命周期較長(zhǎng),則將它們轉(zhuǎn)移到老年代中。如圖所示:

img

老年代(Old Generation)

一個(gè)對(duì)象如果在新生代存活了足夠長(zhǎng)的時(shí)間而沒(méi)有被清理掉,則會(huì)被復(fù)制到老年代。老年代的內(nèi)存大小一般比新生代大,能存放更多的對(duì)象。如果對(duì)象比較大(比如長(zhǎng)字符串或者大數(shù)組),并且新生代的剩余空間不足,則這個(gè)大對(duì)象會(huì)直接被分配到老年代上。

我們可以使用 -XX:PretenureSizeThreshold 來(lái)控制直接升入老年代的對(duì)象大小,大于這個(gè)值的對(duì)象會(huì)直接分配在老年代上。老年代因?yàn)閷?duì)象的生命周期較長(zhǎng),不需要過(guò)多的復(fù)制操作,所以一般采用標(biāo)記壓縮的回收算法。

注意:對(duì)于老年代可能存在這么一種情況,老年代中的對(duì)象有時(shí)候會(huì)引用到新生代對(duì)象。這時(shí)如果要執(zhí)行新生代 GC,則可能需要查詢整個(gè)老年代上可能存在引用新生代的情況,這顯然是低效的。所以,老年代中維護(hù)了一個(gè) 512 byte 的 card table,所有老年代對(duì)象引用新生代對(duì)象的信息都記錄在這里。每當(dāng)新生代發(fā)生 GC 時(shí),只需要檢查這個(gè) card table 即可,大大提高了性能。

GC Log 分析

為了讓上層應(yīng)用開(kāi)發(fā)人員更加方便的調(diào)試 Java 程序,JVM 提供了相應(yīng)的 GC 日志。在 GC 執(zhí)行垃圾回收事件的過(guò)程中,會(huì)有各種相應(yīng)的 log 被打印出來(lái)。其中新生代和老年代所打印的日志是有區(qū)別的。

  • 新生代 GC:這一區(qū)域的 GC 叫作 Minor GC。因?yàn)?Java 對(duì)象大多都具備朝生夕滅的特性,所以 Minor GC 非常頻繁,一般回收速度也比較快。
  • 老年代 GC:發(fā)生在這一區(qū)域的 GC 也叫作 Major GC 或者 Full GC。當(dāng)出現(xiàn)了 Major GC,經(jīng)常會(huì)伴隨至少一次的 Minor GC。

注意:在有些虛擬機(jī)實(shí)現(xiàn)中,Major GC 和 Full GC 還是有一些區(qū)別的。Major GC 只是代表回收老年代的內(nèi)存,而 Full GC 則代表回收整個(gè)堆中的內(nèi)存,也就是新生代 + 老年代。

接下來(lái)就通過(guò)幾個(gè)案例來(lái)分析如何查看 GC Log,分析這些 GC Log 的過(guò)程中也能再加深對(duì) JVM 分代策略的理解。

首先我們需要理解幾個(gè) Java 命令的參數(shù):

img

使用如下代碼,在內(nèi)存中創(chuàng)建 4 個(gè) byte 類型數(shù)組來(lái)演示內(nèi)存分配與 GC 的詳細(xì)過(guò)程。代碼如下:

/**
* VM agrs: -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails
* -XX:SurvivorRatio=8
*/
public class MinorGCTest {
    private static final int _1MB = 1024 * 1024;
    public static void testAllocation() {
        byte[] a1, a2, a3, z4;
        a1 = new byte[2 * _1MB];
        a2 = new byte[2 * _1MB];
        a3 = new byte[2 * _1MB];
        a4 = new byte[1 * _1MB];
    }
    public static void main(String[] agrs) {
        testAllocation();
    }
}

通過(guò)上面的參數(shù),可以看出堆內(nèi)存總大小為 20M,其中新生代占 10M,剩下的 10M 會(huì)自動(dòng)分配給老年代。執(zhí)行上述代碼打印日志如下:

Heap
PSYoungGen      total 9216K, used 8003K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
  eden space 8192K, 97% used [0x00000007bf600000,0x00000007bfdd0ed8,0x00000007bfe00000)
  from space 1024K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007c0000000)
  to   space 1024K, 0% used [0x00000007bfe00000,0x00000007bfe00000,0x00000007bff00000)
ParOldGen       total 10240K, used 0K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
  object space 10240K, 0% used [0x00000007bec00000,0x00000007bec00000,0x00000007bf600000)
Metaspace       used 2631K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 286K, capacity 386K, committed 512K, reserved 1048576K

日志中的各字段代表意義如下:

img

從日志中可以看出:程序執(zhí)行完之后,a1、a2、a3、a4 四個(gè)對(duì)象都被分配在了新生代的 Eden 區(qū)。

如果我們將測(cè)試代碼中的 a4 初始化改為 a4 = new byte[2 * _1MB] 則打印日志如下:

[GC (Allocation Failure) [PSYoungGen: 6815K->480K(9216K)] 6815K->6632K(19456K), 0.0067344 secs] [Times: user=0.04 sys=0.00, real=0.01 secs]
Heap
PSYoungGen      total 9216K, used 2130K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
  eden space 8192K, 26% used [0x00000007bf600000,0x00000007bf814930,0x00000007bfe00000)
  from space 1024K, 0% used [0x00000007bfe00000,0x00000007bfe00000,0x00000007bff00000)
  to   space 1024K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007c0000000)
ParOldGen       total 10240K, used 6420K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
  object space 10240K, 62% used [0x00000007bec00000,0x00000007bf2450d0,0x00000007bf600000)
Metaspace       used 2632K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 286K, capacity 386K, committed 512K, reserved 1048576K

這是因?yàn)樵诮o a4 分配內(nèi)存之前,Eden 區(qū)已經(jīng)被占用 6M。已經(jīng)無(wú)法再分配出 2M 來(lái)存儲(chǔ) a4 對(duì)象。因此會(huì)執(zhí)行一次 Minor GC。并嘗試將存活的 a1、a2、a3 復(fù)制到 S1 區(qū)。但是 S1 區(qū)只有 1M 空間,所以沒(méi)有辦法存儲(chǔ) a1、a2、a3 任意一個(gè)對(duì)象。在這種情況下 a1、a2、a3 將被轉(zhuǎn)移到老年代,最后將 a4 保存在 Eden 區(qū)。所以最終結(jié)果就是:Eden 區(qū)占用 2M(a4),老年代占用 6M(a1、a2、a3)。

通過(guò)這個(gè)測(cè)試案例,我們也間接驗(yàn)證了 JVM 的內(nèi)存分配和分代回收策略。建議多嘗試使用各種命令參數(shù),給堆的新生代和老年代設(shè)置不同的大小來(lái)驗(yàn)證不同的結(jié)果。

再談引用

上文中已經(jīng)介紹過(guò),判斷對(duì)象是否存活我們是通過(guò)GC Roots的引用可達(dá)性來(lái)判斷的。但是JVM中的引用關(guān)系并不止一種,而是有四種,根據(jù)引用強(qiáng)度的由強(qiáng)到弱,他們分別是:強(qiáng)引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)、虛引用(Phantom Reference)。

四種引用做簡(jiǎn)單對(duì)比如下:

img

平時(shí)項(xiàng)目中,尤其是Android項(xiàng)目,因?yàn)橛写罅康膱D像(Bitmap)對(duì)象,使用軟引用的場(chǎng)景較多。所以重點(diǎn)看下軟引用SoftReference的使用,不當(dāng)?shù)氖褂密浺糜袝r(shí)也會(huì)導(dǎo)致系統(tǒng)異常。

軟引用常規(guī)使用

常規(guī)使用代碼如下:

img

執(zhí)行上述代碼,打印日志如下:

img

首先通過(guò)-Xmx將堆最大內(nèi)存設(shè)置為200M。從日志中可以看出,當(dāng)?shù)谝淮蜧C時(shí),內(nèi)存中還有剩余可用內(nèi)存,所以軟引用并不會(huì)被GC回收。但是當(dāng)我們?cè)俅蝿?chuàng)建一個(gè)120M的強(qiáng)引用時(shí),JVM可用內(nèi)存已經(jīng)不夠,所以會(huì)嘗試將軟引用給回收掉。

軟引用隱藏問(wèn)題

需要注意的是,被軟引用對(duì)象關(guān)聯(lián)的對(duì)象會(huì)自動(dòng)被垃圾回收器回收,但是軟引用對(duì)象本身也是一個(gè)對(duì)象,這些創(chuàng)建的軟引用并不會(huì)自動(dòng)被垃圾回收器回收掉。比如如下代碼:

img

上述代碼,雖然每一個(gè)SoftObject都被一個(gè)軟引用所引用,在內(nèi)存緊張時(shí),GC會(huì)將SoftObject所占用的1KB回收。但是每一個(gè)SoftReference又都被Set所引用(強(qiáng)引用)。執(zhí)行上述代碼結(jié)果如下:

img

限制堆內(nèi)存大小為4M,最終程序崩潰,但是異常的原因并不是普通的堆內(nèi)存溢出,而是"GC overhead"。之所以會(huì)拋出這個(gè)錯(cuò)誤,是由于虛擬機(jī)一直在不斷回收軟引用,回收進(jìn)行的速度過(guò)快,占用的cpu過(guò)大(超過(guò)98%),并且每次回收掉的內(nèi)存過(guò)小(小于2%),導(dǎo)致最終拋出了這個(gè)錯(cuò)誤。

這里需要做優(yōu)化,合適的處理方式是注冊(cè)一個(gè)引用隊(duì)列,每次循環(huán)之后將引用隊(duì)列中出現(xiàn)的軟引用對(duì)象從cache中移除。如下所示:

img

再次運(yùn)行修改后的代碼,結(jié)果如下:

img

可以看出優(yōu)化后,程序可以正常執(zhí)行完。并且在執(zhí)行過(guò)程中會(huì)動(dòng)態(tài)的將集合中的軟引用刪除。

更多詳細(xì) SoftReference 的介紹,可以參考 :

Java虛擬機(jī)究竟是如何處理SoftReference的 。

總結(jié):

本文學(xué)習(xí)總結(jié)了 JVM 中有關(guān)垃圾回收的相關(guān)知識(shí)點(diǎn),其中有使用可達(dá)性分析來(lái)判斷對(duì)象是否可以被回收,以及 3 種垃圾回收算法。最后通過(guò)分析 GC Log 驗(yàn)證了 Java 虛擬機(jī)中內(nèi)存分配及分代策略的一些細(xì)節(jié)。

虛擬機(jī)垃圾回收機(jī)制很多時(shí)候都是影響系統(tǒng)性能、并發(fā)能力的主要因素之一。尤其是對(duì)于從事 Android 開(kāi)發(fā)的工程師來(lái)說(shuō),有時(shí)候垃圾回收會(huì)很大程度上影響 UI 線程,并造成界面卡頓現(xiàn)象。因此理解垃圾回收機(jī)制并學(xué)會(huì)分析 GC Log 也是一項(xiàng)必不可少的技能。在后面的 DVM 文章中,將總結(jié) Android 虛擬機(jī)中對(duì)垃圾回收所做的優(yōu)化。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容