這麼多人問的JVM的垃圾回收到底是個啥?

2020-08-14 11:06:37

概述

這次我們主要關注的是黃色部分,記憶體的分配與回收

在这里插入图片描述

  • 衆所周知,Java 和 C++語言的區別,就在於垃圾收集技術和記憶體動態分配上,C語言沒有垃圾收集技術,需要我們手動的收集。

  • 垃圾收集,不是Java語言的伴生產物。早在1960年,第一門開始使用記憶體動態分配和垃圾收集技術的Lisp語言誕生。 關於垃圾收集有三個經典問題:

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

垃圾收集機制 機製是 Java 的招牌能力,極大地提高了開發效率。如今,垃圾收集幾乎成爲現代語言的標配,即使經過如此長時間的發展,Java的垃圾收集機制 機製仍然在不斷的演進中,不同大小的裝置、不同特徵的應用場景,對垃圾收集提出了新的挑戰,這當然也是面試的熱點。

一些大廠面試題:

  • 什麼情況下觸發垃圾回收?
  • 常見的垃圾回收器演算法有哪些?各有什麼優劣?如何選擇合適的垃圾收集演算法?
  • JVM有哪三種垃圾回收器?
  • system.gc()runtime.gc()會做什麼事情
  • GC Roots有哪些?
  • Java物件的回收方式,回收演算法。
  • CMS和G1瞭解麼,CMS解決什麼問題,說一下回收的過程。
  • CMS回收停頓了幾次,爲什麼要停頓兩次?

什麼是垃圾

  • 垃圾是指在執行程式中沒有任何指針指向的物件,這個物件就是需要被回收的垃圾。

  • 如果不及時對記憶體中的垃圾進行清理,那麼,這些垃圾物件所佔的記憶體空間會一直保留到應用程式的結束,被保留的空間無法被其它物件使用,甚至可能導致記憶體溢位。

爲什麼需要GC

  • 對於高階語言來說,一個基本認知是如果不進行垃圾回收,記憶體遲早都會被消耗完,因爲不斷地分配記憶體空間而不進行回收,就好像不停地生產生活垃圾而從來不打掃一樣。

  • 除了釋放沒用的物件,垃圾回收也可以清除記憶體裡的記錄碎片。碎片整理將所佔用的堆記憶體移到堆的一端,以便JVM將整理出的記憶體分配給新的物件。

  • 隨着應用程式所應付的業務越來越龐大、複雜,使用者越來越多,沒有GC就不能保證應用程式的正常進行。而經常造成STW的GC又跟不上實際的需求,所以纔會不斷地嘗試對GC進行優化。

早期垃圾回收

在早期的C/C++時代,垃圾回收基本上是手工進行的。

開發人員可以使用new關鍵字進行記憶體申請,並使用delete關鍵字進行記憶體釋放。比如以下程式碼:

MibBridge *pBridge= new cmBaseGroupBridge();
// 如果註冊失敗,使用 Delete 釋放該物件所佔記憶體區域
if(pBridge->Register(kDestroy)!=NO_ERROR)
	delete pBridge;

這種方式可以靈活控制記憶體釋放的時間,但是會給開發人員帶來頻繁申請和釋放記憶體的管理負擔。

倘若有一處記憶體區間由於程式設計師編碼的問題忘記被回收,那麼就會產生記憶體漏失(物件不用了,但是也沒辦法回收),垃圾物件永遠無法被清除;

隨着系統執行時間的不斷增長,垃圾物件所耗記憶體可能持續上升,直到出現記憶體溢位並造成應用程式崩潰。

有了垃圾回收機制 機製後,上述程式碼極有可能變成這樣

MibBridge *pBridge=new cmBaseGroupBridge(); 
pBridge->Register(kDestroy);

現在,除了Java以外,C#、Python、Ruby等語言都使用了自動垃圾回收的思想,也是未來發展趨勢,可以說這種自動化的記憶體分配和來及回收方式已經成爲了線代開發語言必備的標準

Java垃圾回收機制 機製

優點

  • 自動記憶體管理,無需開發人員手動參與記憶體的分配與回收,這樣降低記憶體漏失和記憶體溢位的風險

  • 沒有垃圾回收器,java也會和cpp一樣,各種懸垂指針,野指針,泄露問題讓你頭疼不已。

  • 自動記憶體管理機制 機製,將程式設計師從繁重的記憶體管理中釋放出來,可以更專心地專注於業務開發

  • Oracle官網關於垃圾回收的介紹 https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/toc.html

擔憂

  • 對於Java開發人員而言,自動記憶體管理就像是一個黑匣子,如果過度依賴於「自動」,那麼這將會是一場災難,最嚴重的就會弱化Java開發人員在程式出現記憶體溢位時定位問題和解決問題的能力

  • 此時,瞭解JVM的自動記憶體分配和記憶體回收原理就顯得非常重要,只有在真正瞭解JVM是如何管理記憶體後,我們才能 纔能夠在遇見OutOfMemoryError時,快速地根據錯誤異常日誌定位問題和解決問題

  • 當需要排查各種記憶體溢位、記憶體漏失問題時,當垃圾收整合爲系統達到更高併發量的瓶頸時,我們就必須對這些「自動化」的技術實施必要的監控和調節

GC主要關注的區域

  • GC主要關注於 方法區 和 堆中的垃圾收集

在这里插入图片描述

垃圾收集器可以對年輕代回收,也可以對老年代回收,甚至是全棧和方法區的回收

  • 其中,Java堆是垃圾收集器的工作重點

從次數上講:

  • 頻繁收集Young
  • 較少收集Old
  • 基本不收集Perm永久區(元空間)

垃圾回收演算法

標記階段:參照計數演算法

在堆裡存放着幾乎所有的Java物件範例,在GC執行垃圾回收之前,首先需要區分出記憶體中哪些是存活物件,哪些是已經死亡的物件

只有被標記爲己經死亡的物件,GC纔會在執行垃圾回收時,釋放掉其所佔用的記憶體空間,因此這個過程我們可以稱爲垃圾標記階段

那麼在JVM中究竟是如何標記一個死亡物件呢?

  • 簡單來說,當一個物件已經不再被任何的存活物件繼續參照時,就可以宣判爲已經死亡。

判斷物件存活一般有兩種方式參照計數演算法可達性分析演算法

參照計數演算法(Reference Counting)比較簡單,對每個物件儲存一個整型的參照計數器屬性,用於記錄物件被參照的情況。

  • 對於一個物件A,只要有任何一個物件參照了A,則A的參照計數器就加1;當參照失效時,參照計數器就減1。
  • 只要物件A的參照計數器的值爲0,即表示物件A不可能再被使用,可進行回收。

優點

  • 實現簡單,垃圾物件便於辨識;
  • 判定效率高,回收沒有延遲性。

缺點

  • 它需要單獨的欄位儲存計數器,這樣的做法增加了儲存空間的開銷
  • 每次賦值都需要更新計數器,伴隨着加法和減法操作,這增加了時間開銷
  • 參照計數器有一個嚴重的問題,即無法處理回圈參照的情況。這是一條致命缺陷,導致在Java的垃圾回收器中沒有使用這類演算法。

回圈參照

  • 例如下圖中,當p指針斷開的時候,內部的參照形成一個回圈(稱爲回圈參照),從而造成記憶體漏失
  • 但是Java的垃圾回收器中沒有採用這種演算法,所以不會出現這種記憶體泄露,如果舉例子要說明是參照計數演算法中的記憶體泄露

在这里插入图片描述

舉個例子:我們使用一個案例來測試Java中是否採用的是參照計數演算法

/**
 * 參照計數演算法測試
 * -XX:+PrintGCDetails
 */
public class RefCountGC {
    // 這個成員屬性的唯一作用就是佔用一點記憶體
    // 如果沒有被回收,堆記憶體的佔用空間肯定大於這個
    private byte[] bigSize = new byte[5*1024*1024]; // 5MB
    // 參照
    Object reference = null;

    public static void main(String[] args) {
        RefCountGC obj1 = new RefCountGC();
        RefCountGC obj2 = new RefCountGC();
        // 回圈參照
        obj1.reference = obj2;
        obj2.reference = obj1;
        // 指針斷開
        obj1 = null;
        obj2 = null;
        // 顯示執行垃圾收集行爲
        // 這裏發生 GC,obj1 和 obj2是否被回收?
        System.gc();
    }
}

問題
在这里插入图片描述

檢視列印的 GC 資訊

[GC (System.gc()) [PSYoungGen: 15490K->808K(76288K)] 15490K->816K(251392K), 0.0061980 secs] [Times: user=0.00 sys=0.00, real=0.36 secs] 
[Full GC (System.gc()) [PSYoungGen: 808K->0K(76288K)] [ParOldGen: 8K->672K(175104K)] 816K->672K(251392K), [Metaspace: 3479K->3479K(1056768K)], 0.0045983 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 PSYoungGen      total 76288K, used 655K [0x000000076b500000, 0x0000000770a00000, 0x00000007c0000000)
  eden space 65536K, 1% used [0x000000076b500000,0x000000076b5a3ee8,0x000000076f500000)
  from space 10752K, 0% used [0x000000076f500000,0x000000076f500000,0x000000076ff80000)
  to   space 10752K, 0% used [0x000000076ff80000,0x000000076ff80000,0x0000000770a00000)
 ParOldGen       total 175104K, used 672K [0x00000006c1e00000, 0x00000006cc900000, 0x000000076b500000)
  object space 175104K, 0% used [0x00000006c1e00000,0x00000006c1ea8070,0x00000006cc900000)
 Metaspace       used 3486K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 385K, capacity 388K, committed 512K, reserved 1048576K
  • 很明顯這裏發生了一次GC,將上述的新生代中的兩個物件都進行回收了(因年輕代中使用的空間僅655K

小結

參照計數演算法,是很多語言的資源回收選擇,例如因人工智慧而更加火熱的Python,它更是同時支援參照計數和垃圾收集機制 機製。

具體哪種最優是要看場景的,業界有大規模實踐中僅保留參照計數機制 機製,以提高吞吐量的嘗試。

Java並沒有選擇參照計數,是因爲其存在一個基本的難題,也就是很難處理回圈參照關係。Python如何解決回圈參照?

  • 手動解除:很好理解,就是在合適的時機,解除參照關係。
  • 使用弱參照weakrefweakref是Python提供的標準庫,旨在解決回圈參照。

標記階段:可達性分析演算法

概念

可達性分析演算法:也可以稱爲 根搜尋演算法追蹤性垃圾收集

相對於參照計數演算法而言,可達性分析演算法不僅同樣具備實現簡單和執行高效等特點,更重要的是該演算法可以有效地解決在參照計數演算法中回圈參照的問題,防止記憶體漏失的發生。

相較於參照計數演算法,這裏的可達性分析就是Java、C#選擇的。這種型別的垃圾收集通常也叫作追蹤性垃圾收集TGC 、 Tracing Garbage Collection

思路

所謂"GCRoots」根集合就是一組必須活躍的參照

基本思路:

  • 可達性分析演算法是以根物件集合(GCRoots)爲起始點,按照從上至下的方式搜尋被根物件集合所連線的目標物件是否可達
  • 使用可達性分析演算法後,記憶體中的存活物件都會被根物件集合直接或間接連線着,搜尋所走過的路徑稱爲參照鏈(Reference Chain)
  • 如果目標物件沒有任何參照鏈相連,則是不可達的,就意味着該物件己經死亡,可以標記爲垃圾物件
  • 在可達性分析演算法中,只有能夠被根物件集合直接或者間接連線的物件纔是存活物件

在这里插入图片描述

例如之前很火的電視劇《人民的名義》中的人物關係

在这里插入图片描述

GC Roots 可以是哪些?

  • 虛擬機器棧中參照的物件
    • 比如:各個執行緒被呼叫的方法中使用到的參數、區域性變數等。
  • 本地方法棧內JNI(通常說的本地方法)參照的物件
  • 方法區中類靜態屬性參照的物件
    • 比如:Java類的參照型別靜態變數
  • 方法區中常數參照的物件
    • 比如:字串常數池(string Table)裡的參照
  • 所有被同步鎖synchronized持有的物件
  • Java虛擬機器內部的參照。
    • 基本數據型別對應的Class物件,一些常駐的異常物件(如:NullPointerExceptionOutOfMemoryError),系統類載入器。
  • 反映Java虛擬機器內部情況的JMXBeanJVMTI中註冊的回撥、原生代碼快取等。

在这里插入图片描述

總結

總結一句話就是,除了堆空間外的一些結構,比如 虛擬機器棧、本地方法棧、方法區、字串常數池 等地方對堆空間進行參照的,都可以作爲GC Roots進行可達性分析

除了這些固定的GC Roots集合以外,根據使用者所選用的垃圾收集器以及當前回收的記憶體區域不同,還可以有其他物件「臨時性」地加入,共同構成完整GC Roots集合。比如:分代收集區域性回收(PartialGC)。

  • 如果只針對Java堆中的某一塊區域進行垃圾回收(比如:典型的只針對新生代),必須考慮到記憶體區域是虛擬機器自己的實現細節,更不是孤立封閉的,這個區域的物件完全有可能被其他區域的物件所參照,這時候就需要一併將關聯的區域物件也加入GCRoots集閤中去考慮,才能 纔能保證可達性分析的準確性。

小技巧

由於Root採用棧方式存放變數和指針,所以如果一個指針,它儲存了堆記憶體裏面的物件,但是自己又不存放在堆記憶體裏面,那它就是一個Root。

注意

如果要使用可達性分析演算法來判斷記憶體是否可回收,那麼分析工作必須在一個能保障一致性的快照中進行

  • 這點不滿足的話分析結果的準確性就無法保證。
  • 這點也是導致GC進行時必須「Stop The World」的一個重要原因
  • 即使是號稱(幾乎)不會發生停頓的CMS收集器中,列舉根節點時也是必須要停頓的。

物件的 finalization 機制 機製

Java語言提供了物件終止finalization)機制 機製來允許開發人員提供物件被銷燬之前的自定義處理邏輯

當垃圾回收器發現沒有參照指向一個物件,即:垃圾回收此物件之前,總會先呼叫這個物件的finalize()方法。

在这里插入图片描述

finalize()方法允許在子類中被重寫,用於在物件被回收時進行資源釋放

  • 通常在這個方法中進行一些資源釋放和清理的工作,比如關閉檔案、通訊端和數據庫連線等。

注意

永遠不要主動呼叫某個物件的finalize()方法應該交給垃圾回收機制 機製呼叫。理由包括下面 下麪三點:

  • finalize()時可能會導致物件復活
  • finalize()方法的執行時間是沒有保障的,它完全由GC執行緒決定,極端情況下,若不發生GC,則finalize()方法將沒有執行機會。
    • 因爲優先順序比較低,即使主動呼叫該方法,也不會因此就直接進行回收
  • 一個糟糕的finalize()嚴重影響GC的效能。

從功能上來說,finalize()方法與 c++ 中的解構函式比較相似,但是Java採用的是基於垃圾回收器的自動記憶體管理機制 機製,所以finalize()方法在本質上不同於C++中的解構函式

由於finalize()方法的存在,虛擬機器中的物件一般處於三種可能的狀態

生存還是死亡?

如果從所有的根節點都無法存取到某個物件,說明物件己經不再使用了。一般來說,此物件需要被回收。

但事實上,也並非是「非死不可」的,這時候它們暫時處於「緩刑」階段。

一個無法觸及的物件有可能在某一個條件下「復活」自己,如果這樣,那麼對它的回收就是不合理的,爲此,定義虛擬機器中的物件可能的三種狀態。

  • 可觸及的:從根節點開始,可以到達這個物件。
  • 可復活的:物件的所有參照都被釋放,但是物件有可能finalize()中復活。
  • 不可觸及的:物件的finalize()被呼叫,並且沒有復活,那麼就會進入不可觸及狀態。
    • 不可觸及的物件不可能被複活,因爲finalize()只會被呼叫一次。

以上3種狀態中,是由於finalize()方法的存在,進行的區分。只有在物件不可觸及時纔可以被回收

具體過程

判定一個物件 objA 是否可回收,至少要經歷兩次標記過程

  • 如果物件 objA 到 GC Roots 沒有參照鏈,則進行第一次標記。

  • 進行篩選,判斷此物件是否有必要執行 finalize()方法

    • 如果物件 objA 沒有重寫 finalize()方法,或者 finalize()方法已經被虛擬機器呼叫過,則虛擬機器視爲「沒有必要執行」,objA被判定爲不可觸及的。
    • 如果物件 objA 重寫了 finalize()方法,且還未執行過,那麼 objA 會被插入到F-Queue佇列中,由一個虛擬機器自動建立的、低優先順序Finalizer執行緒觸發其 finalize()方法執行。
    • finalize()方法是物件逃脫死亡的最後機會,稍後 GC 會對F-Queue佇列中的物件進行第二次標記。如果 objA 在 finalize() 方法中與參照鏈上的任何一個物件建立了聯繫,那麼在第二次標記時,objA 會被移出「即將回收」集合。之後,物件會再次出現沒有參照存在的情況。在這個情況下,finalize方法 不會被再次呼叫,物件會直接變成不可觸及的狀態,也就是說,一個物件的finalize方法只會被呼叫一次

在这里插入图片描述

程式碼演示

我們使用重寫 finalize()方法,然後在方法的內部,重寫將其存放到 GC Roots 中

/**
 * 測試Object類中finalize()方法
 * 物件復活場景
 */
public class CanReliveObj {
    // 類變數,屬於GC Roots的一部分
    public static CanReliveObj canReliveObj;
	
	// 所有類的父類別都是 Object,所以可以直接重寫
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("呼叫當前類重寫的finalize()方法");
        canReliveObj = this; // 當前待回收的物件在 finalize() 方法中與參照鏈上的任何一個物件建立了聯繫
    }

    public static void main(String[] args) throws InterruptedException {
        canReliveObj = new CanReliveObj();
        canReliveObj = null;
        System.gc();
        System.out.println("-----------------第一次gc操作------------");
        // 因爲 Finalizer 執行緒的優先順序比較低,暫停 2秒,以等待它
        Thread.sleep(2000);
        if (canReliveObj == null) {
            System.out.println("obj is dead");
        } else {
            System.out.println("obj is still alive");
        }

        System.out.println("-----------------第二次gc操作------------");
        canReliveObj = null;
        System.gc();
        // 下面 下麪程式碼和上面程式碼是一樣的,但是 canReliveObj卻自救失敗了
        Thread.sleep(2000);
        if (canReliveObj == null) {
            System.out.println("obj is dead");
        } else````{
            System.out.println("obj is still alive");
        }

    }
}

// 執行結果
-----------------第一次gc操作------------
呼叫當前類重寫的finalize()方法
obj is still alive
-----------------第二次gc操作------------
obj is dead

在進行第一次清除的時候,我們會執行finalize方法,然後 物件 進行了一次自救操作,但是因爲finalize()方法只會被呼叫一次,因此第二次該物件將會被垃圾清除

MAT 與 JProfiler 的 GC Roots 溯源

MAT是什麼?

MAT是Memory Analyzer的簡稱,它是一款功能強大的 Java堆記憶體分析器。用於查詢記憶體漏失以及檢視記憶體消耗情況

命令列使用jmap獲得 Dump 檔案

在这里插入图片描述

使用 JVIsualVM 獲取 Dump 檔案

捕獲的heap dump檔案是一個臨時檔案(快照),關閉JVisualVM後自動刪除,若要保留,需要將其另存爲檔案。可通過以下方法捕獲heap dump:

  • 在左側「Application"(應用程式)子視窗中右擊相應的應用程式,選擇Heap Dump(堆Dump)。

  • Monitor(監視)子標籤頁中點選Heap Dump(堆Dump)按鈕。本地應用程式的Heap dumps作爲應用程式標籤頁的一個子標籤頁開啓。同時,heap dump在左側的Application(應用程式)欄中對應一個含有時間戳的節點。

  • 右擊這個節點選擇save as(另存爲)即可將heap dump儲存到本地。

使用 MAT 開啓Dump檔案,檢視堆資訊

匯入剛纔儲存的 Dump檔案
在这里插入图片描述

開啓後,我們就可以看到有哪些可以作爲GC Roots的物件

在这里插入图片描述

  • 裏面我們能夠看到有一些常用的Java類,還有 Thread執行緒。

JProfiler 的 GC Roots溯源

我們在實際的開發中,一般不會查詢全部的GC Roots,可能只是查詢某個物件的整個鏈路,稱爲GC Roots 溯源,這個時候,我們就可以使用JProfiler

點選Live memory -> All Objects,右側視窗中選中要溯源的物件,單擊右鍵,Show Selection In Heap Walker,右側視窗上測點選References即可看到。

在这里插入图片描述

如何判斷什麼原因造成OOM ?(通過 JProfiler)

當我們程式出現OOM的時候,我們就需要進行排查,需要看某一個物件的參照鏈,從源頭切斷參照,避免記憶體漏失。

我們首先使用下面 下麪的例子進行說明

/**
 * 記憶體溢位排查
 * -Xms8m -Xmx8m -XX:HeapDumpOnOutOfMemoryError
 */
public class HeapOOM {
    // 建立1M的檔案
    byte [] buffer = new byte[1 * 1024 * 1024];

    public static void main(String[] args) {
        ArrayList<HeapOOM> list = new ArrayList<>();
        int count = 0;
        try {
        	// 不斷加入列表中
            while (true) {
                list.add(new HeapOOM());
                count++;
            }
        } catch (Exception e) {
            e.getStackTrace();
            System.out.println("count:" + count);
        }
    }
}

上述程式碼就是不斷的建立一個1M小位元組陣列,然後讓記憶體溢位,我們需要限制一下記憶體大小,同時使用HeapDumpOnOutOfMemoryError將出錯時候的 Dump檔案 輸出:-Xms8m -Xmx8m -XX:HeapDumpOnOutOfMemoryError

我們將生成的 dump檔案 開啓,然後點選Biggest Objects就能夠看到超大物件

在这里插入图片描述

然後我們通過執行緒,還能夠定位到哪裏出現OOM

在这里插入图片描述

清除階段:標記 - 清除演算法

成功區分出記憶體中存活物件和死亡物件後,GC接下來的任務就是執行垃圾回收,釋放掉無用物件所佔用的記憶體空間,以便有足夠的可用記憶體空間爲新物件分配記憶體。

目前在JVM中比較常見的三種垃圾收集演算法是

  • 標記一清除演算法(Mark-Sweep)
  • 複製演算法(copying)
  • 標記-壓縮演算法(Mark-Compact)

標記-清除演算法Mark-Sweep)是一種非常基礎和常見的垃圾收集演算法,該演算法被J.McCarthy等人在1960年提出並並應用於Lisp語言

執行過程

當堆中的有效記憶體空間(available memory)被耗盡的時候,就會停止整個程式(也被稱爲stop the world),然後進行兩項工作,第一項則是標記,第二項則是清除

  • 標記:Collector 從參照根節點開始遍歷,標記所有被參照的物件。一般是在物件的Header中記錄爲可達物件
    • 標記的是參照的物件,不是垃圾!!
  • 清除:Collector對堆記憶體從頭到尾進行線性的遍歷,如果發現某個物件在其Header沒有標記爲可達物件,則將其回收

在这里插入图片描述

什麼是清除?

這裏所謂的清除並不是真的置空,而是把需要清除的物件地址儲存在空閒的地址列表裡。下次有新物件需要載入時,判斷垃圾的位置空間是否夠,如果夠,就存放覆蓋原有的地址

關於空閒列表是在爲物件分配記憶體的時候 提過

  • 如果記憶體規整
    • 採用指針碰撞的方式進行記憶體分配
  • 如果記憶體不規整
    • 虛擬機器需要維護一個列表
    • 空閒列表分配

缺點

  • 標記清除演算法的效率不算高
  • 在進行GC的時候,需要停止整個應用程式(STW),使用者體驗較差
  • 這種方式清理出來的空閒記憶體是不連續的,產生內碎片,需要維護一個空閒列表

清除階段:複製演算法

背景

爲了解決標記-清除演算法在垃圾收集效率方面的缺陷,M.L.Minsky1963年發表了著名的論文,「使用雙儲存區的 Lisp 語言垃圾收集器A LISP Garbage Collector Algorithm Using Serial Secondary Storage)」。

M.L.Minsky在該論文中描述的演算法被人們稱爲複製(Copying)演算法,它也被M.L.Minsky本人成功地引入到了 Lisp 語言的一個實現版本中。

核心思想

將活着的記憶體空間分爲兩塊,每次只使用其中一塊,在垃圾回收時將正在使用的記憶體中的存活物件複製到未被使用的記憶體塊中,之後清除正在使用的記憶體塊中的所有物件,交換兩個記憶體的角色,最後完成垃圾回收。

在这里插入图片描述

把可達的物件,直接複製到另外一個區域中複製完成後,A區就沒有用了,裏面的物件可以直接清除掉,其實新生代(倖存者0區和1區)裏面就用到了複製演算法

在这里插入图片描述

優點

  • 沒有標記和清除過程,實現簡單,執行高效
  • 複製過去以後保證空間的連續性,不會出現「碎片」問題。

缺點

  • 此演算法的缺點也是很明顯的,就是需要兩倍的記憶體空間
  • 對於G1這種分拆成爲大量region(分割區)的GC,複製而不是移動,意味着GC需要維護region之間物件參照關係,不管是記憶體佔用或者時間開銷也不小,即當複製移動的時候,需要維護參照物件的地址,如果使用控制代碼存取就能提高一定的效率,關於控制代碼存取詳情可看物件的存取定位

需要注意的問題!!

如果系統中的垃圾物件很多,複製演算法就不會很理想,因爲複製演算法需要複製的存活物件數量並不會太大,或者說非常低才行

  • 例如老年代大量的物件存活,那麼複製的物件將會有很多,效率會很低(極端情況就是什麼垃圾都沒回收,還全部都複製了一遍)

  • 新生代,對常規應用的垃圾回收,一次通常可以回收70% - 99% 的記憶體空間。回收性價比很高。所以現在的商業虛擬機器都是用這種收集演算法回收新生代。

在这里插入图片描述

清除階段:標記 - 壓縮演算法

標記 - 壓縮演算法(也稱標記 - 整理演算法)Mark - Compact

背景

複製演算法的高效性是建立在存活物件少、垃圾物件多的前提下的。

  • 這種情況在新生代經常發生,但是在老年代,更常見的情況是大部分物件都是存活物件
  • 如果依然使用複製演算法,由於存活物件較多,複製的成本也將很高。
  • 因此,基於老年代垃圾回收的特性,需要使用其他的演算法

標記一清除演算法的確可以應用在老年代中,但是該演算法不僅執行效率低下,而且在執行完記憶體回收後還會產生記憶體碎片,所以 JVM 的設計者需要在此基礎之上進行改進標記-壓縮Mark-Compact)演算法由此誕生。

1970年前後,G.L.Steele、C.J.Chene和D.s.Wise等研究者發佈標記-壓縮演算法。在許多現代的垃圾收集器中,人們都使用了標記-壓縮演算法或其改進版本。

執行過程

  • 第一階段和標記清除演算法一樣,從根節點開始標記所有被參照物件
  • 第二階段將所有的存活物件壓縮到記憶體的一端,按順序排放
  • 之後,清理邊界外所有的空間。

在这里插入图片描述

雖說是標記清理演算法的改進版,那兩者之間有什麼區別呢?

  • 標記-壓縮演算法的最終效果等同於標記-清除演算法執行完成後,再進行一次記憶體碎片整理,因此,也可以把它稱爲標記-清除-壓縮Mark-Sweep-Compact)演算法。

  • 二者的本質差異在於標記-清除演算法是一種非移動式的回收演算法標記-壓縮是移動式的。

    • 是否移動回收後的存活物件是一項優缺點並存的風險決策。
    • 可以看到,標記的存活物件將會被整理,按照記憶體地址依次排列,而未被標記的記憶體會被清理掉。
    • 如此一來,當我們需要給新物件分配記憶體時,JVM只需要持有一個記憶體的起始地址即可,這比維護一個空閒列表顯然少了許多開銷。

優點

  • 消除了標記-清除演算法當中記憶體區域分散的缺點,我們需要給新物件分配記憶體時,JVM只需要持有一個記憶體的起始地址即可。
  • 消除了複製演算法當中記憶體減半的高額代價

缺點

  • 從效率上來說,標記-整理演算法要低於複製演算法。
  • 移動物件的同時,如果物件被其他物件參照,則還需要調整參照的地址
  • 移動過程中,需要全程暫停使用者應用程式。即:STW

標記壓縮演算法相當於是一個折中的演算法

演算法小結

  • 效率上來說,複製演算法是當之無愧的老大,但是卻浪費了太多記憶體。

  • 而爲了儘量兼顧上面提到的三個指標,標記-整理演算法相對來說更平滑一些

  • 但是效率上不盡如人意,它比複製演算法多了一個標記的階段,比標記-清除多了一個整理記憶體的階段。

  • 也比複製演算法節省了空間,比標記-清除演算法少維護了一個空閒列表

  • 綜合來說,沒有最好的演算法,只有最合適的演算法

標記 - 清除 標記 - 整理 複製演算法
速度(三者相對) 中等 最慢 最快
空間開銷 少(但是會堆積碎片) 少(不堆積碎片) 桶長需要活物件的2倍空間(不堆積碎片)
移動物件

分代收集演算法

  • 前面所有這些演算法中,並沒有一種演算法可以完全替代其他演算法,它們都具有自己獨特的優勢和特點。
  • 因此分代收集演算法應運而生,具體問題具體分析。

分代收集演算法,是基於這樣一個事實:不同的物件的生命週期是不一樣的。

因此,不同生命週期的物件可以採取不同的收集方式,以便提高回收效率。

  • 一般是把Java堆分爲新生代和老年代,這樣就可以根據各個年代的特點使用不同的回收演算法,以提高垃圾回收的效率。

在Java程式執行的過程中,會產生大量的物件,其中有些物件是與業務資訊相關,比如 HTTP 請求中的Session物件、執行緒、Socket連線,這類物件跟業務直接掛鉤,因此生命週期比較長

但是還有一些物件,主要是程式執行過程中生成的臨時變數,這些物件生命週期會比較短,比如:String物件,由於其不變類的特性,系統會產生大量的這些物件,有些物件甚至只用一次即可回收。

目前幾乎所有的GC都採用分代手機演算法執行垃圾回收的

在 HotSpot 中,基於分代的概念,GC所使用的記憶體回收演算法必須結合年輕代和老年代各自的特點。

年輕代(Young Gen)

  • 年輕代特點:區域相對老年代較小,物件生命週期短、存活率低,回收頻繁

  • 這種情況複製演算法的回收整理,速度是最快的複製演算法的效率只和當前存活物件大小有關,因此很適用於年輕代的回收。

  • 而複製演算法記憶體利用率不高的問題,通過 HotSpot 中的兩個 Survivor 的設計得到緩解。

老年代(Tenured Gen)

  • 老年代特點:區域較大,物件生命週期長、存活率高,回收不及年輕代頻繁

  • 這種情況存在大量存活率高的物件,複製演算法明顯變得不合適。一般是由標記-清除或者是標記-清除與標記-整理的混合實現

    • Mark 階段的開銷與存活物件的數量成正比(遞回遍歷)。
    • Sweep 階段的開銷與所管理區域的大小成正相關(線性遍歷)。
    • Compact 階段的開銷與存活物件的數量成正比(複製移動)。

以 HotSpot 中的 CMS回收器 爲例,CMS 是基於 Mark-Sweep 實現的,對於物件的回收效率很高。而對於碎片問題, CMS 採用基於 Mark-Compact 演算法的 Serial old 回收器 作爲補償措施:

  • 當記憶體回收不佳(碎片導致的Concurrent Mode Failure時),將採用 Serial old 執行 FullGC 以達到對老年代記憶體的整理。

分代的思想被現有的虛擬機器廣泛使用。幾乎所有的垃圾回收器都區分新生代和老年代

增量收集演算法

減少垃圾回收過程中因 STW 而產生的影響

概述

上述現有的演算法,在垃圾回收過程中,應用軟體將處於一種 Stop the World 的狀態。在 Stop the World 狀態下,應用程式所有的執行緒都會掛起,暫停一切正常的工作,等待垃圾回收的完成。

如果垃圾回收時間過長,應用程式會被掛起很久,將嚴重影響使用者體驗或者系統的穩定性。爲了解決這個問題,即對實時垃圾收集演算法的研究直接導致了增量收集(Incremental Collecting)演算法的誕生。

如果一次性將所有的垃圾進行處理,需要造成系統長時間的停頓,那麼就可以讓垃圾收集執行緒和應用程式執行緒交替執行每次,垃圾收集執行緒只收集一小片區域的記憶體空間,接着切換到應用程式執行緒。依次反覆 反復,直到垃圾收集完成

總的來說,增量收集演算法的基礎仍是傳統的標記-清除和複製演算法。增量收集演算法通過對執行緒間衝突的妥善處理,允許垃圾收集執行緒以分階段的方式完成標記、清理或複製工作

優缺點

使用這種方式,由於在垃圾回收過程中,間斷性地還執行了應用程式程式碼,所以能減少系統的停頓時間

但是,因爲執行緒切換和上下文轉換的消耗,會使得垃圾回收的總體成本上升,造成系統吞吐量的下降

分割區演算法

一般來說,在相同條件下,堆空間越大,一次 GC 時所需要的時間就越長,有關GC產生的停頓也越長。

爲了更好地控制 GC 產生的停頓時間,將一塊大的記憶體區域分割成多個小塊,根據目標的停頓時間,每次合理地回收若幹個小區間,而不是整個堆空間,從而減少一次GC所產生的停頓。

分代演算法將按照物件的生命週期長短劃分成兩個部分
分割區演算法將整個堆空間劃分成連續的不同小區間

  • 每一個小區間都獨立使用,獨立回收。
  • 這種演算法的好處是可以控制一次回收多少個小區間。

在这里插入图片描述

注意,這些只是基本的演算法思路,實際GC實現過程要複雜的多,目前還在發展中的前沿GC都是複合演算法,並且並行和併發兼備

垃圾回收相關概念

System.gc() 的理解

在預設情況下,通過system.gc()或者Runtime.getRuntime().gc() 的呼叫,會顯式觸發FullGC,同時對老年代和新生代進行回收,嘗試釋放被丟棄物件佔用的記憶體。

然而system.gc() 呼叫附帶一個免責宣告,無法保證對垃圾收集器的呼叫。(不能確保立即生效)

JVM實現者可以通過system.gc()呼叫來決定JVM的GC行爲。而一般情況下,垃圾回收應該是自動進行的,無須手動觸發,否則就太過於麻煩了。

  • 在一些特殊情況下,如我們正在編寫一個效能基準,我們可以在執行之間呼叫System.gc()

程式碼演示是否出發GC操作

/**
 * System.gc()
 * -XX:+PrintGCDetails
 */
public class SystemGCTest {
    public static void main(String[] args) {
        new SystemGCTest();
        // 提醒JVM進行垃圾回收,但是並不一定會馬上執行 gc
        // 底層呼叫 Runtime.getRuntime().gc()
        System.gc(); // 執行結果也確實證明不一定及時進行 GC
        
        //System.runFinalization(); // 如果執行這行,就一定會呼叫下面 下麪的方法
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("SystemGCTest 執行了 finalize方法");
    }
}

// 輸出
SystemGCTest 執行了 finalize方法

當呼叫System.gc();時不一定會觸發銷燬的方法,但是呼叫System.runFinalization()強制呼叫 失去參照的物件的finalize()


手動 GC 來理解不可達物件的回收

/**
 * 區域性變數回收
 */
public class LocalVarGC {

    /**
     * 觸發 Minor GC 沒有回收物件,然後觸發 Full GC 將該物件存入 old區
     */
    public void localvarGC1() {
        byte[] buffer = new byte[10*1024*1024]; // 10M
        System.gc();
    }

    /**
     * 觸發 YoungGC 的時候,已經被回收了
     */
    public void localvarGC2() {
        byte[] buffer = new byte[10*1024*1024];
        buffer = null; // 失去參照物件了
        System.gc();
    }

    /**
     * 不會被回收,因爲它還存放在區域性變數表索引爲 1 的槽中(Slot複用)
     */
    public void localvarGC3() {
    	// 程式碼塊中的物件
        {
            byte[] buffer = new byte[10*1024*1024];
        }
        System.gc();
    }

    /**
     * 會被回收,因爲它還存放在區域性變數表索引爲1的槽中,但是後面定義的value把這個槽給替換了
     */
    public void localvarGC4() {
        {
            byte[] buffer = new byte[10*1024*1024];
        }
        // 多定義了一個變數
        int value = 10;
        System.gc();
    }

    /**
     * localvarGC5中的陣列已經被回收
     */
    public void localvarGC5() {
        localvarGC1();
        System.gc();
    }

	// main方法呼叫執行
    public static void main(String[] args) {
        LocalVarGC localVarGC = new LocalVarGC();
        localVarGC.localvarGC3();
    }
}

記憶體溢位與記憶體漏失

記憶體溢位 OOM

記憶體溢位相對於記憶體漏失來說,儘管更容易被理解,但是同樣的,記憶體溢位也是引發程式崩潰的罪魁禍首之一

由於GC一直在發展,所有一般情況下,除非應用程式佔用的記憶體增長速度非常快,造成垃圾回收已經跟不上記憶體消耗的速度,否則不太容易出現OOM的情況。

大多數情況下,GC會進行各種年齡段的垃圾回收,實在不行了就放大招,來一次獨佔式的Full GC操作,這時候會回收大量的記憶體,供應用程式繼續使用。

javadoc 中對 OutOfMemoryError的解釋是:沒有空閒記憶體,並且垃圾收集器也無法提供更多記憶體

首先說沒有空閒記憶體的情況:說明Java虛擬機器的堆記憶體不夠。原因有二:

  • Java虛擬機器的堆記憶體不夠

    • 比如:可能存在記憶體漏失問題;也很有可能就是堆的大小不合理,比如我們要處理比較可觀的數據量,但是沒有顯式指定JVM堆大小或者指定數值偏小。我們可以通過參數-Xms-Xmx來調整。
  • 程式碼中建立了大量大物件,並且長時間不能被垃圾收集器收集(存在被參照)

    • 對於老版本的 Oracle JDK,因爲永久代的大小是有限的,並且JVM對永久代垃圾回收(如常數池回收、解除安裝不再需要的型別)非常不積極,所以當我們不斷新增新型別的時候,永久代出現OutOfMemoryError也非常多見,尤其是在執行時存在大量動態型別生成的場合;類似intern字串快取佔用太多空間,也會導致OOM問題。對應的異常資訊,會標記出來和永久代相關:「java.lang.OutOfMemoryError:PermGen space"

隨着元數據區的引入,方法區記憶體已經不再那麼窘迫,所以相應的 OOM 有所改觀,出現 OOM,異常資訊則變成了:「java.lang.OutofMemoryError: Metaspace"。直接記憶體不足,也會導致 OOM。

這裏面隱含着一層意思是,在拋出OutOfMemoryError之前,通常垃圾收集器會被觸發,盡其所能去清理出空間。

  • 例如:在參照機制 機製分析中,涉及到 JVM 會去嘗試回收軟參照指向的物件等。
  • java.nio.BIts.reserveMemory()方法中,我們能清楚的看到,System.gc()會被呼叫,以清理空間。

當然,也不是在任何情況下垃圾收集器都會被觸發的

  • 比如,我們去分配一個超大物件,類似一個超大陣列超過堆的最大值,JVM可以判斷出垃圾收集並不能解決這個問題,所以直接拋出OutOfMemoryError

記憶體漏失

也稱作「儲存滲漏」。嚴格來說,只有物件不會再被程式用到了,但是GC又不能回收他們的情況,才叫記憶體漏失

但實際情況很多時候一些不太好的實踐(或疏忽)會導致物件的生命週期變得很長甚至導致OOM,也可以叫做寬泛意義上的「記憶體漏失」

儘管記憶體漏失並不會立刻引起程式崩潰,但是一旦發生記憶體漏失,程式中的可用記憶體就會被逐步蠶食,直至耗盡所有記憶體,最終出現OutOfMemory異常,導致程式崩潰。

  • 注意,這裏的儲存空間並不是指實體記憶體,而是指虛擬記憶體大小,這個虛擬記憶體大小取決於磁碟交換區設定的大小

Java使用可達性分析演算法,最上面的數據不可達,就是需要被回收的。後期有一些物件不用了,按道理應該斷開參照,但是存在一些鏈沒有斷開,從而導致沒有辦法被回收。

在这里插入图片描述

舉個例子

單例模式

  • 單例的生命週期和應用程式是一樣長的,所以單例程式中,如果持有對外部物件的參照的話,那麼這個外部物件是不能被回收的,則會導致記憶體漏失的產生。

一些提供 close 的資源未關閉導致記憶體漏失

  • 數據庫連線(dataSourse.getConnection() ),網路連線(socket)和io連線必須手動close,否則是不能被回收的。

Stop The World

stop-the-world,簡稱STW,指的是GC事件發生過程中,會產生應用程式的停頓,停頓產生時整個應用程式執行緒都會被暫停,沒有任何響應,有點像卡死的感覺,這個停頓稱爲STW

STW是JVM在後台自動發起和自動完成的。在使用者不可見的情況下,把使用者正常的工作執行緒全部停掉。

可達性分析演算法中==列舉根節點(GC Roots)==會導致所有Java執行執行緒停頓。

  • 分析工作必須在一個能確保一致性的快照中進行
  • 一致性指整個分析期間整個執行系統看起來像被凍結在某個時間點上
  • 如果出現分析過程中物件參照關係還在不斷變化,則分析結果的準確性無法保證

被STW中斷的應用程式執行緒會在完成GC之後恢復,頻繁中斷會讓使用者感覺像是網速不快造成電影卡帶一樣,所以我們需要減少STW的發生。

STW事件和採用哪款GC無關,所有的GC都有這個事件

  • 哪怕是G1也不能完全避免Stop-the-world情況發生,只能說垃圾回收器越來越優秀,回收效率越來越高,儘可能地縮短了暫停時間
  • 開發中不要用system.gc(),因爲會導致stop-the-world的發生

垃圾回收的並行與併發

併發

在操作系統中,是指一個時間段中幾個程式都處於已啓動執行到執行完畢之間,且這幾個程式都是在同一個處理器上執行

併發不是真正意義上的「同時進行」,只是CPU把一個時間段劃分成幾個時間片段(時間區間),然後在這幾個時間區間之間來回切換,由於CPU處理的速度非常快,只要時間間隔處理得當,即可讓使用者感覺是多個應用程式同時在進行。

也就是某一時刻仍然只有一個程式在執行

在这里插入图片描述

並行

當系統有一個以上CPU時,當一個CPU執行一個進程時,另一個CPU可以執行另一個進程,兩個進程互不搶佔CPU資源,可以同時進行,我們稱之爲並行(Parallel)。

其實決定並行的因素不是CPU的數量,而是CPU的核心數量,比如一個CPU多個核也可以並行。

適合科學計算,後臺處理等弱互動場景

在这里插入图片描述

併發和並行對比

  • 併發,指的是多個事情,在同一時間段內同時發生了。

  • 並行,指的是多個事情,在同一時間點上同時發生了。

  • 併發的多個任務之間是互相搶佔資源的。並行的多個任務之間是不互相搶佔資源的。

  • 只有在多CPU或者一個CPU多核的情況中,纔會發生並行;否則,看似同時發生的事情,其實都是併發執行的。

垃圾回收的並行與併發

併發和並行,在談論垃圾收集器的上下文語境中,它們可以解釋如下:

  • 並行(Paralle1):指多條垃圾收集執行緒並行工作,但此時使用者執行緒仍處於等待狀態

    • 如 ParNew、Parallel Scavenge、Parallel old;
  • 序列(Serial)

    • 相較於並行的概念,單執行緒執行
    • 如果記憶體不夠,則程式暫停,啓動 JVM垃圾回收器 進行垃圾回收。回收完,再啓動程式的執行緒。

在这里插入图片描述

  • 併發(Concurrent):指使用者執行緒與垃圾收集執行緒同時執行(但不一定是並行的,可能會交替執行),垃圾回收執行緒在執行時不會停頓使用者程式的執行
    • 使用者程式在繼續執行,而垃圾收集程式執行緒執行於另一個CPU上;
    • 比如CMS、G1

在这里插入图片描述

安全點與安全區域

安全點

程式執行時並非在所有地方都能停頓下來開始GC,只有在特定的位置才能 纔能停頓下來開始GC,這些位置稱爲「安全點(Safepoint)」。

Safe Point的選擇很重要

  • 如果太少可能導致GC等待的時間太長
  • 如果太頻繁可能導致執行時的效能問題

大部分指令的執行時間都非常短暫,通常會根據「是否具有讓程式長時間執行的特徵」爲標準。

  • 比如:選擇一些執行時間較長的指令作爲Safe Point,如方法呼叫回圈跳轉異常跳轉等。

如何在GC發生時,檢查所有執行緒都跑到最近的安全點停頓下來呢?

  • 搶先式中斷:(目前沒有虛擬機器採用了)首先中斷所有執行緒。如果還有執行緒不在安全點,就恢復執行緒,讓執行緒跑到安全點。
  • 主動式中斷:設定一箇中斷標誌,各個執行緒執行到Safe Point的時候主動輪詢這個標誌,如果中斷標誌爲真,則將自己進行中斷掛起。(有輪詢的機制 機製

安全區域

Safepoint 機制 機製保證了程式執行時,在不太長的時間內就會遇到可進入GC的Safepoint。

但是,**程式「不執行」的時候呢?**例如執行緒處於 Sleep 狀態或 Blocked 狀態,這時候執行緒無法響應JVM的中斷請求,「走」到安全點去中斷掛起,JVM也不太可能等待執行緒被喚醒。對於這種情況,就需要安全區域(Safe Region)來解決。

安全區域是指在一段程式碼片段中,物件的參照關係不會發生變化,在這個區域中的任何位置開始GC都是安全的

  • 我們也可以把Safe Region看做是被擴充套件了的Safepoint

執行流程:

  • 當執行緒執行到Safe Region的程式碼時,首先標識已經進入了Safe Region
    • 如果這段時間內發生GC,JVM會忽略標識爲Safe Region狀態的執行緒
  • 當執行緒即將離開Safe Region時,會檢查JVM是否已經完成GC,
    • 如果完成了,則繼續執行
    • 如果沒有完成,執行緒必須等待直到收到可以安全離開Safe Region的信號爲止;

強參照

概述

我們希望能描述這樣一類物件:當記憶體空間還足夠時,則能保留在記憶體中如果記憶體空間在進行垃圾收集後還是很緊張,則可以拋棄這些物件

【既偏門又非常高頻的面試題】強參照、軟參照、弱參照、虛參照有什麼區別?具體使用場景是什麼?

在JDK1.2版之後,Java對參照的概念進行了擴充,將參照分爲:

  • 強參照(Strong Reference)
  • 軟參照(Soft Reference)
  • 弱參照(Weak Reference)
  • 虛參照(Phantom Reference)

這4種參照強度依次逐漸減弱。除強參照外,其他3種參照均可以在java.lang.ref包中找到它們的身影。如下圖,顯示了這3種參照型別對應的類(FinalReference爲終端子參照),開發人員可以在應用程式中直接使用它們。

在这里插入图片描述

Reference子類中只有終端子參照是包內可見的,其他3種參照型別均爲public,可以在應用程式中直接使用

  • 強參照(StrongReference):最傳統的「參照」的定義,是指在程式程式碼之中普遍存在的參照賦值,即類似「Object obj=new Object()」這種參照關係。無論任何情況下,只要強參照關係還存在,垃圾收集器就永遠不會回收掉被參照的物件。(實際開發中,絕大多數都是強參照)
  • 軟參照(SoftReference):在系統將要發生記憶體溢位之前,將會把這些物件列入回收範圍之中進行第二次回收。如果這次回收後還沒有足夠的記憶體,纔會拋出記憶體流出異常。
  • 弱參照(WeakReference):被弱參照關聯的物件只能生存到下一次垃圾收集之前。當垃圾收集器工作時,無論記憶體空間是否足夠,都會回收掉被弱參照關聯的物件。
  • 虛參照(PhantomReference):一個物件是否有虛參照的存在,完全不會對其生存時間構成影響,也無法通過虛參照來獲得一個物件的範例。爲一個物件設定虛參照關聯的唯一目的就是能在這個物件被收集器回收時收到一個系統通知

強參照: 不回收

在Java程式中,最常見的參照型別是強參照(普通系統99%以上都是強參照),也就是我們最常見的普通物件參照,也是預設的參照型別

當在Java語言中使用new操作符建立一個新的物件,並將其賦值給一個變數的時候,這個變數就成爲指向該物件的一個強參照。

強參照的物件是可觸及的,垃圾收集器就永遠不會回收掉被參照的物件。

對於一個普通的物件,如果沒有其他的參照關係,只要超過了參照的作用域或者顯式地將相應(強)參照賦值爲 null ,就是可以當做垃圾被收集了,當然具體回收時機還是要看垃圾收集策略。

相對的,軟參照、弱參照和虛參照的物件是軟可觸及、弱可觸及和虛可觸及的,在一定條件下,都是可以被回收的

所以,強參照是造成Java記憶體漏失的主要原因之一

舉個例子

StringBuffer str = new StringBuffer("hello mogublog");

區域性變數 str 指向 StringBuffer 範例所在堆空間,通過 str 可以操作該範例,那麼 str 就是 StringBuffer 範例的強參照對應記憶體結構:

在这里插入图片描述
如果此時,再執行一個賦值語句

StringBuffer str = new StringBuffer("hello mogublog");
StringBuffer str1 = str;

對應的記憶體空間爲

在这里插入图片描述

那麼我們將 str = null; 則 原來堆中的物件也不會被回收,因爲還有其它物件指向該區域

本例中的兩個參照,都是強參照,強參照具備以下特點:

  • 強參照可以直接存取目標物件
  • 強參照所指向的物件在任何時候都不會被系統回收,虛擬機器寧願拋出OOM異常,也不會回收強參照所指向物件。
  • 強參照可能導致記憶體漏失

軟參照:記憶體不足即回收

軟參照是用來描述一些還有用,但非必需的物件

只被軟參照關聯着的物件,在系統將要發生記憶體溢位異常前,會把這些物件列進回收範圍之中進行第二次回收,如果這次回收還沒有足夠的記憶體,纔會拋出記憶體溢位異常,但是不是軟參照導致的,因爲軟參照都回收完了。

  • 注意,這裏的第一次回收是不可達的物件

軟參照通常用來實現記憶體敏感的快取。比如:快取記憶體就有用到軟參照。

  • 如果還有空閒記憶體,就可以暫時保留快取,當記憶體不足時清理掉,這樣就保證了使用快取的同時,不會耗盡記憶體。

垃圾回收器在某個時刻決定回收軟可達的物件的時候,會清理軟參照,並可選地把參照存放到一個參照佇列Reference Queue)。

類似弱參照,只不過Java虛擬機器會盡量讓軟參照的存活時間長一些,迫不得已才清理

一句話概括:

  • 當記憶體足夠時,不會回收軟參照可達的物件。記憶體不夠時,會回收軟參照的可達物件

在JDK1.2版之後提供了java.lang.ref.SoftReference類來實現軟參照

// 宣告強參照
Object obj = new Object();
// 建立一個軟參照
SoftReference<Object> sf = new SoftReference<>(obj);
obj = null; //銷燬強參照,這是必須的,不然會存在強參照和軟參照

弱參照:發現即回收

弱參照也是用來描述那些非必需物件,只被弱參照關聯的物件只能生存到下一次垃圾收集發生爲止

  • 在系統GC時,只要發現弱參照,不管系統堆空間使用是否充足,都會回收掉只被弱參照關聯的物件。

但是,由於垃圾回收器的執行緒通常優先順序很低,因此,並不一定能很快地發現持有弱參照的物件

  • 在這種情況下,弱參照物件可以存在較長的時間。

弱參照和軟參照一樣,在構造弱參照時,也可以指定一個參照佇列,當弱參照物件被回收時,就會加入指定的參照佇列,通過這個佇列可以跟蹤物件的回收情況

軟參照、弱參照都非常適合來儲存那些可有可無的快取數據

  • 如果這麼做,當系統記憶體不足時,這些快取數據會被回收,不會導致記憶體溢位。
  • 而當記憶體資源充足時,這些快取數據又可以存在相當長的時間,從而起到加速系統的作用。

在 JDK1.2 版之後提供了java.lang.ref.WeakReference類來實現弱參照

// 宣告強參照
Object obj = new Object();
// 建立一個弱參照
WeakReference<Object> sf = new WeakReference<>(obj);
obj = null; //銷燬強參照,這是必須的,不然會存在強參照和弱參照

弱參照物件與軟參照物件的最大不同就在於:

  • 當GC在進行回收時,需要通過演算法檢查是否回收軟參照物件
  • 而對於弱參照物件,GC總是進行回收。弱參照物件更容易、更快被GC回收

面試題:你開發中使用過WeakHashMap嗎?

  • WeakHashMap用來儲存圖片資訊,可以在記憶體不足的時候,及時回收,避免了OOM

虛參照:物件回收跟蹤

虛參照也稱爲「幽靈參照」或者「幻影參照」,是所有參照型別中最弱的一個

一個物件是否有虛參照的存在,完全不會決定物件的生命週期。如果一個物件僅持有虛參照,那麼它和沒有參照幾乎是一樣的隨時都可能被垃圾回收器回收

不能單獨使用,也無法通過虛參照來獲取被參照的物件

  • 當試圖通過虛參照的get()方法取得物件時,總是null

爲一個物件設定虛參照關聯的唯一目的在於跟蹤垃圾回收過程

  • 比如:能在這個物件被收集器回收時收到一個系統通知。

虛參照必須和參照佇列一起使用。虛參照在建立時必須提供一個參照佇列作爲參數

當垃圾回收器準備回收一個物件時,如果發現它還有虛參照,就會在回收物件後,將這個虛參照加入參照佇列,以通知應用程式物件的回收情況

由於虛參照可以跟蹤物件的回收時間,因此,也可以將一些資源釋放操作放置在虛參照中執行和記錄。

  • 虛參照無法獲取到我們的數據

在JDK1.2版之後提供了PhantomReference類來實現虛參照。

// 宣告強參照
Object obj = new Object();
// 宣告參照佇列
ReferenceQueue phantomQueue = new ReferenceQueue();
// 宣告虛參照(還需要傳入參照佇列)
PhantomReference<Object> sf = new PhantomReference<>(obj, phantomQueue);
obj = null; 

舉個例子

我們使用一個案例,來結合虛參照,參照佇列,finalize進行講解

public class PhantomReferenceTest {
    // 當前類物件的宣告
    public static PhantomReferenceTest obj;
    // 參照佇列
    static ReferenceQueue<PhantomReferenceTest> phantomQueue = null;

    @Override
    // finalize 方法只能被呼叫一次
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("呼叫當前類的finalize方法");
        obj = this;
    }

    public static void main(String[] args) {
    	// 設定一個執行緒操作參照佇列
        Thread thread = new Thread(() -> {
        	// 一直回圈
            while(true) {
            	// 一旦將 obj 物件回收,就會將此虛參照存放到參照佇列中
                if (phantomQueue != null) {
                    PhantomReference<PhantomReferenceTest> objt = null;
                    try {
                    	// 取出最近的參照
                        objt = (PhantomReference<PhantomReferenceTest>) phantomQueue.remove();
                    } catch (Exception e) {
                        e.getStackTrace();
                    }
                    // 不爲空就說明可以取出來
                    if (objt != null) {
                        System.out.println("追蹤垃圾回收過程:PhantomReferenceTest範例被GC了");
                    }
                }
            }
        }, "t1");
        // 前面設定爲死回圈,這裏要設爲守護執行緒以便終止:當程式中沒有非守護執行緒時,守護執行緒結束
        thread.setDaemon(true);
        thread.start();

        phantomQueue = new ReferenceQueue<>();
        obj = new PhantomReferenceTest();
        // 構造了PhantomReferenceTest物件的虛參照,並指定了參照佇列
        PhantomReference<PhantomReferenceTest> phantomReference = new PhantomReference<>(obj, phantomQueue);
        try {
            System.out.println(phantomReference.get());
            // 去除強參照
            obj = null;
            // 第一次進行GC,由於物件可復活,GC無法回收該物件
            System.out.println("第一次GC操作");
            System.gc();
            Thread.sleep(1000);
            if (obj == null) {
                System.out.println("obj 是 null");
            } else {
                System.out.println("obj 不是 null");
            }
            
            System.out.println("第二次GC操作");
            obj = null;
            System.gc();
            Thread.sleep(1000);
            if (obj == null) {
                System.out.println("obj 是 null");
            } else {
                System.out.println("obj 不是 null");
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {

        }
    }
}

// 最後執行結果
null
第一次GC操作
呼叫當前類的finalize方法
obj 不是 null
第二次GC操作
追蹤垃圾回收過程:PhantomReferenceTest範例被GC了
obj 是 null

從上述執行結果我們知道,第一次嘗試獲取虛參照的值,發現無法獲取的,這是因爲虛參照是無法直接獲取物件的值,然後進行第一次 GC,因爲會呼叫 finalize 方法,將物件復活了,所以物件沒有被回收;

但是呼叫第二次 GC 操作的時候,因爲 finalize 方法只能執行一次,所以就觸發了GC操作,將物件回收了,同時將會觸發第二個操作就是 將回收的值存入到參照佇列中

終端子參照

  • 它用於實現物件的finalize() 方法,也可以稱爲終端子參照

  • 無需手動編碼,其內部配合參照佇列使用

  • 在GC時,終端子參照入隊。由Finalizer執行緒通過終端子參照找到被參照物件呼叫它的finalize()方法,第二次GC時纔回收被參照的物件