我對(duì)JVM中垃圾回收機(jī)制的理解

前言

作為一名Java編程者,想要往高級(jí)進(jìn)階,內(nèi)存管理往往是避不開(kāi)的環(huán)節(jié),而垃圾回收 以下簡(jiǎn)稱GC(Garbage Collection)機(jī)制作為內(nèi)存管理最重要的一個(gè)部分,是我們必須要掌握的。今天就分享下我對(duì) 垃圾回收機(jī)制分代回收策略 的理解.

目錄


  • 1. 背景

  • 2. 兩種回收機(jī)制

    • 2.1. 引用計(jì)數(shù)
    • 2.2. 可達(dá)性分析
  • 3. 回收算法

    • 3.1. 標(biāo)記清除算法
    • 3.2. 復(fù)制算法
    • 3.3. 標(biāo)記壓縮算法
  • 4. 分代回收策略

    • 4.1. 新生代
    • 4.2. 老生代
  • 5. 四大引用


1. 背景

一般來(lái)講,在我們編程的過(guò)程中是會(huì)不斷的往內(nèi)存中寫(xiě)入數(shù)據(jù)的,而這些數(shù)據(jù)用完了要及時(shí)從內(nèi)存中清理,否則會(huì)引發(fā)OutOfMemory(內(nèi)存溢出) ,所以每個(gè)編程者都必須遵從這一原則。聽(tīng)說(shuō)(我也不懂C語(yǔ)言~)在C語(yǔ)言階段,垃圾是需要編程者自己手動(dòng)回收的,而我們Javaer相對(duì)來(lái)說(shuō)就要幸福多了,因?yàn)镴VM存在GC機(jī)制,也就是說(shuō)JVM會(huì)幫我們自動(dòng)清理垃圾,但幸福也是有代價(jià)的,因?yàn)榭偸菚?huì)有些垃圾對(duì)象陰差陽(yáng)錯(cuò)的避開(kāi)GC算法,這一現(xiàn)象也稱之為內(nèi)存泄漏,所以只有掌握了GC機(jī)制才能避免寫(xiě)出內(nèi)存泄漏的程序。

2. 兩種回收機(jī)制

2.1 引用計(jì)數(shù)

什么是引用計(jì)數(shù)呢?打個(gè)比方A a = new A(),代碼中 A 對(duì)象被引用 a 所持有,此時(shí)引用計(jì)數(shù)就會(huì) +1 ,如果 a 將引用置為 nulla = null此時(shí)對(duì)象 A 的引用計(jì)數(shù)就會(huì)變?yōu)?0 ,GC算法檢測(cè)到 A 對(duì)象引用計(jì)數(shù)為 0 就會(huì)將其回收。很簡(jiǎn)單,但引用計(jì)數(shù)存在一定弊端

場(chǎng)景如下:

A a = new A();
B b = new B();
a.next = b;
b.next = a;
a = null;
b = null;

執(zhí)行完上述代碼后 AB 對(duì)象會(huì)被回收嗎?看似引用都已經(jīng)置為 null ,但實(shí)際上 abnext 分別持有對(duì)方引用,形成了一種相互持有引用的局面,導(dǎo)致AB 即使成了垃圾對(duì)象且不能被回收。有些同學(xué)可能會(huì)說(shuō),內(nèi)存泄漏太容易看出來(lái)了, ab 置空前將各自的 next 置為空不就完了。嗯,這樣說(shuō)沒(méi)錯(cuò),但是在實(shí)際業(yè)務(wù)中面對(duì)龐大的業(yè)務(wù)邏輯內(nèi)存泄漏是很難一眼看出的。所以JVM在后來(lái)摒棄了引用計(jì)數(shù),采用了可達(dá)性分析。

2.2 可達(dá)性分析

可達(dá)性分析其實(shí)是數(shù)學(xué)中的一個(gè)概念,在JVM中,會(huì)將一些特殊的引用作為 GcRoot ,如果通過(guò) GcRoot 可以訪達(dá)的對(duì)象不會(huì)被當(dāng)作垃圾對(duì)象。換種方式說(shuō)就是,一個(gè)對(duì)象被 GcRoot 直接 或 間接持有,那么該對(duì)象就不會(huì)被當(dāng)作垃圾對(duì)象。用一張圖表示大概就是這個(gè)樣子:

image

圖中A、B、C、D可以被 GcRoot 訪達(dá),所以不會(huì)被回收。E、F不能被 GcRoot 訪達(dá),所以會(huì)被標(biāo)記為垃圾對(duì)象。最典型的是G、H,雖說(shuō)相互引用,但不能被 GcRoot 訪達(dá),所以也會(huì)被標(biāo)記為垃圾對(duì)象。綜上所述: 可達(dá)性分析 可以解決 引用計(jì)數(shù) 中 對(duì)象相互引用 不能被回收的問(wèn)題。

什么類(lèi)型的引用可作為 GcRoot 呢。 大概有如下四種:

  • 棧中局部變量
  • 方法區(qū)中靜態(tài)變量
  • 方法區(qū)中常量
  • 本地方法棧JNI的引用對(duì)象

注意點(diǎn)

千萬(wàn)不要把引用和對(duì)象兩個(gè)概念混淆,對(duì)象是實(shí)實(shí)在在存在于內(nèi)存中的,而引用只是一個(gè)變量/常量并持有對(duì)象在內(nèi)存中的地址指。

下面我來(lái)通過(guò)一些代碼來(lái)驗(yàn)證幾種 GcRoot

局部變量

筆者是用Android代碼進(jìn)行調(diào)試,不懂Android的同學(xué)把onCreate視為main方法即可。

public class MyApp extends Application {

    @Override
    public void onCreate() {
        super.onCreate();
        method();
    }

    private void method(){
        Log.i("test","method start");
        A a = new A();
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        Log.i("test","method end");
    }

    class A{
        @Override
        protected void finalize() throws Throwable {
            Log.i("test","finalize A");
        }
    }
}

提示

  • 在java中一個(gè)對(duì)象被回收會(huì)調(diào)用其finalize方法
  • JVM中垃圾回收是在一個(gè)單獨(dú)線程進(jìn)行。為了更好的驗(yàn)證效果,在此加2000毫秒延時(shí)

打印結(jié)果如下:

17:58:57.526   method start
17:58:59.526   method end
17:58:59.591   finalize A

method 方法執(zhí)行時(shí)間是2000毫秒,對(duì)象Amethod 方法結(jié)束立即被回收。所以可以認(rèn)定棧中局部變量可作為 GcRoot

本地方法區(qū)靜態(tài)變量

public class MyApp extends Application {

    private static A a;
    @Override
    public void onCreate() {
        super.onCreate();
        Log.i("test","onCreate");
        a = new A();
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        a = null;
        Log.i("test","a = null");
    }
}

打印結(jié)果如下:

18:12:35.988   a = new A()
18:12:38.028   a = null
18:12:38.096   finalize A

創(chuàng)建一個(gè) A 對(duì)象賦值給靜態(tài)變量 a , 2000毫秒后將靜態(tài)變量 a 置為空。通過(guò)日志可以看出對(duì)象 A 在靜態(tài)變量 a 置空后被立即回收。所以可以認(rèn)定靜態(tài)變量可作為 GcRoot

方法區(qū)常量與靜態(tài)變量驗(yàn)證過(guò)程完全一致,關(guān)于native 驗(yàn)證過(guò)程比較復(fù)雜,感興趣的同學(xué)可自行驗(yàn)證。

驗(yàn)證成員變量是否可作為 GcRoot

public class MyApp extends Application {

    @Override
    public void onCreate() {
        super.onCreate();
        A a = new A();
        B b = new B();
        a.b = b;
        a = null;
    }

    class A{
        B b;
        @Override
        protected void finalize() throws Throwable {
            Log.i("test","finalize A");
        }
    }

    class B{
        @Override
        protected void finalize() throws Throwable {
            Log.i("test","finalize B");
        }
    }
}

打印結(jié)果如下:

13:14:58.999   finalize A
13:14:58.999   finalize B

通過(guò)日志可以看出,A、B 兩個(gè)對(duì)象都被回收。雖然 B 對(duì)象被 A 對(duì)象中的 b 引用所持有,但成員變量不能被作為 GcRoot, 所以B 對(duì)象不可達(dá),進(jìn)而會(huì)被當(dāng)作垃圾。

3. 回收算法

上一小結(jié)描述了 GC 機(jī)制,但具體實(shí)現(xiàn)還是要靠算法,下面我簡(jiǎn)單描述一下幾種常見(jiàn)的 GC算法。

3.1. 標(biāo)記清除算法

獲取所有的 GcRoot 遍歷內(nèi)存中所有的對(duì)象,如果可以被 GcRoot 就加個(gè)標(biāo)記,剩下所有的對(duì)象都將視為垃圾被清除。

  • 優(yōu)點(diǎn):實(shí)現(xiàn)簡(jiǎn)單,執(zhí)行效率高
  • 缺點(diǎn):容易產(chǎn)生 內(nèi)存碎片(可用內(nèi)存分布比較分散),如果需要申請(qǐng)大塊連續(xù)內(nèi)存可能會(huì)頻繁觸發(fā) GC

3.2. 復(fù)制算法

將內(nèi)存分為兩塊,每次只是用其中一塊。首先遍歷所有對(duì)象,將可用對(duì)象復(fù)制到另一塊內(nèi)存中,此時(shí)上一塊內(nèi)存可視為全是垃圾,清理后將新內(nèi)存塊置為當(dāng)前可用。如此反復(fù)進(jìn)行

  • 優(yōu)點(diǎn):解決了內(nèi)存碎片的問(wèn)題
  • 缺點(diǎn):需要按順序分配內(nèi)存,可用內(nèi)存變?yōu)樵瓉?lái)的一半。

3.3. 標(biāo)記壓縮算法

獲取所有的 GcRoot , GcRoot 開(kāi)始從遍歷內(nèi)存中所有的對(duì)象,將可用對(duì)象壓縮到另一端,再將垃圾對(duì)象清除。實(shí)則是犧牲時(shí)間復(fù)雜度來(lái)降低空間復(fù)雜度

  • 優(yōu)點(diǎn):解決了標(biāo)記清除的 內(nèi)存碎片 ,也不需要復(fù)制算法中的 內(nèi)存分塊
  • 缺點(diǎn):仍需要將對(duì)象進(jìn)行移動(dòng),執(zhí)行效率略低。

4. 分代回收策略

在JVM中 垃圾回收器 是很繁忙的,如果一個(gè)對(duì)象存活時(shí)間較長(zhǎng),避免重復(fù) 創(chuàng)建/回收垃圾回收器 進(jìn)一步造成負(fù)擔(dān),能不能犧牲點(diǎn)內(nèi)存把它緩存起來(lái)? 答案是肯定的。JVM制定了 分代回收策略 為每個(gè)對(duì)象設(shè)置生命周期
,堆內(nèi)存會(huì)劃分不同的區(qū)域,來(lái)存儲(chǔ)各生命周期的對(duì)象。一般情況下對(duì)象的生命周期有 新生代、老年代、永久代(java 8已廢棄)。

4.1. 新生代

首先來(lái)看新生代內(nèi)存結(jié)構(gòu)示意圖:

image

按照8:1:1將新生代內(nèi)存分為 Eden、SurvivorA、SurvivorB

新生代內(nèi)存工作流程:

  • 當(dāng)一個(gè)對(duì)象剛被創(chuàng)建時(shí)會(huì)放到 Eden 區(qū)域,當(dāng) Eden 區(qū)域即將存滿時(shí)做一次垃圾回收,將當(dāng)前存活的對(duì)象復(fù)制到 SurvivorA ,隨后將 Eden 清空
  • 當(dāng)Eden 下一次存滿時(shí),再做一次垃圾回收,先將存活對(duì)象復(fù)制到 SurvivorB ,再把 EdenSurvivorA 所有對(duì)象進(jìn)行回收,
  • 當(dāng)Eden 再一次存滿時(shí),再做一次垃圾回收,將存活對(duì)象復(fù)制到 SurvivorA,再把 EdenSurvivorB對(duì)象進(jìn)行回收。如此反復(fù)進(jìn)行大概 15 次,將最終依舊存活的對(duì)象放入到老年代區(qū)域。

新生代工作流程與 復(fù)制算法 應(yīng)用場(chǎng)景較為吻合,都是以復(fù)制為核心,所以會(huì)采用復(fù)制算法。

4.2. 老年代

根據(jù)對(duì) 上一小節(jié) 我們可以得知 當(dāng)一個(gè)對(duì)象存活時(shí)間較久會(huì)被存入到 老年代 區(qū)域。 老年代 區(qū)即將被存滿時(shí)會(huì)做一次垃圾回收,



所以 老年代 區(qū)域特點(diǎn)是存活對(duì)象多、垃圾對(duì)象少,采用標(biāo)記壓縮 算法時(shí)移動(dòng)少、也不會(huì)產(chǎn)生內(nèi)存碎片。所以老年代 區(qū)域可以選用 標(biāo)記壓縮 算法進(jìn)一步提升效率。

5. 四大引用

在我們開(kāi)發(fā)程序的過(guò)程中,避免不了會(huì)創(chuàng)建一些比較大的對(duì)象,比如Android中用于承載像素信息的Bitmap,使用稍有不當(dāng)就會(huì)造成內(nèi)存泄漏,如果存在大量類(lèi)似對(duì)象對(duì)內(nèi)存影響還是蠻大的。



為了盡可能避免上述情況的出現(xiàn),JVM為我們提供了四種對(duì)象引用方式:強(qiáng)引用、軟引用、弱引用、虛引用 供我們選擇,下面我用一張表格來(lái)做一下類(lèi)比

  • 假設(shè)以下所述對(duì)象可被 GcRoot 訪達(dá)
引用類(lèi)型 回收時(shí)機(jī)
強(qiáng)引用 絕不會(huì)被回收(默認(rèn))
軟引用 內(nèi)存不足時(shí)回收
弱引用 第一次觸發(fā)GC時(shí)就會(huì)被回收
虛引用 隨時(shí)都會(huì)被回收,不存在實(shí)際意義






參考文獻(xiàn):《Android 工程師進(jìn)階 34 講》 第二講

結(jié)語(yǔ)

文章從五個(gè)方面描述了 GC 機(jī)制。

  • GC 機(jī)制的誕生是為了提升開(kāi)發(fā)者的效率
  • 可達(dá)性分析 解決的 引用計(jì)數(shù) 相互引用的問(wèn)題
  • 不同場(chǎng)景 運(yùn)用不同 GC 算法可以提升效率
  • 分代回收策略 進(jìn)一步提升 GC 效率
  • 巧妙運(yùn)用 四大引用 可以一定程度解決 內(nèi)存泄漏
?著作權(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)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

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