深入理解 JVM 的垃圾回收機制

1、如何判斷對象已 “死”

Java 堆中存放着幾乎所有的對象實例,垃圾回收器在堆進行垃圾回收前,首先要判斷這些對象那些還存活,那些已經 “死去”。判斷對象是否已“死” 有如下幾種算法:

1.1 引用計數法

引用計數法描述的算法爲:給對象增加一個引用計數器,每當有一個地方引用它時,計數器就 + 1;當引用失效時,計數器就 - 1;任何時刻計數器爲 0 的對象就是不能再被使用的,即對象已 “死”。
引用計數法實現簡單,判定效率也比較高,在大部分情況下都是一個比較好的算法。比如 Python 語言就是採用的引用計數法來進行內存管理的。
但是,在主流的 JVM 中沒有選用引用計數法來管理內存,最主要的原因是引用計數法無法解決對象的循環引用問題。

範例:循環引用問題

/**
  * JVM參數:-XX:+PrintGC
  *
*/
public class Test {
	public Object instance = null;
	private static int _1MB = 1024 * 1024;
	private byte[] bigSize = new byte[2 * _1MB];
	public static void testGC() {
		Test test1 = new Test();
		Test test2 = new Test();
		test1.instance = test2;
		test2.instance = test1;
		test1 = null;
		test2 = null;
		// 強制JVM進行垃圾回收
		System.gc();
	}
	public static void main(String[] args) {
		testGC();
	}
}
程序輸出:[GC (System.gc()) 6092K->856K(125952K), 0.0007504 secs]

從結果可以看出,GC 日誌包含 "6092K->856K(125952K)",意味着虛擬機並沒有因爲這兩個對象互相引用就不回收他們。即 JVM 並不使用引用計數法來判斷對象是否存活。

1.2 可達性分析算法

在上面講了,Java 並不採用引用計數法來判斷對象是否已 “死”,而採用“可達性分析” 來判斷對象是否存活(同樣採用此法的還有 C#、Lisp - 最早的一門採用動態內存分配的語言)。
此算法的核心思想:通過一系列稱爲 “GC Roots” 的對象作爲起始點,從這些節點開始向下搜索,搜索走過的路徑稱爲 “引用鏈”,當一個對象到 GC Roots 沒有任何的引用鏈相連時(從 GC Roots 到這個對象不可達) 時,證明此對象不可用。以下圖爲例:

對象 Object5 —Object7 之間雖然彼此還有聯繫,但是它們到 GC Roots 是不可達的,因此它們會被判定爲可回收對象。

在 Java 語言中,可作爲 GC Roots 的對象包含以下幾種:

  1. 虛擬機棧 (棧幀中的本地變量表) 中引用的對象。
  2. 方法區中靜態屬性引用的對象
  3. 方法區中常量引用的對象
  4. 本地方法棧中 (Native 方法) 引用的對象

在 JDK1.2 以前,Java 中引用的定義很傳統: 如果引用類型的數據中存儲的數值代表的是另一塊內存的起始地址,就稱這塊內存代表着一個引用。這種定義有些狹隘,一個對象在這種定義下只有被引用或者沒有被引用兩種狀態。
我們希望能描述這一類對象: 當內存空間還足夠時,則能保存在內存中;如果內存空間在進行垃圾回收後還是非常緊張,則可以拋棄這些對象。很多系統中的緩存對象都符合這樣的場景。
在 JDK1.2 之後,Java 對引用的概念做了擴充,將引用分爲強引用 (Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference) 和虛引用 (Phantom Reference) 四種,這四種引用的強度依次遞減。

  1. 強引用: 強引用指的是在程序代碼之中普遍存在的,類似於 "Object obj = new Object()" 這類的引用,只要強引用還存在,垃圾回收器永遠不會回收掉被引用的對象實例。
  1. 軟引用: 軟引用是用來描述一些還有用但是不是必須的對象。對於軟引用關聯着的對象,在系統將要發生內存溢出之前,會把這些對象列入回收範圍之中進行第二次回收。如果這次回收還是沒有足夠的內存,纔會拋出內存溢出異常。在 JDK1.2 之後,提供了 SoftReference 類來實現軟引用。
  2. 弱引用: 弱引用也是用來描述非必需對象的。但是它的強度要弱於軟引用。被弱引用關聯的對象只能生存到下一次垃圾回收發生之前。當垃圾回收器開始進行工作時,無論當前內容是否夠用,都會回收掉只被弱引用關聯的對象。在 JDK1.2 之後提供了 WeakReference 類來實現弱引用。
  3. 虛引用: 虛引用也被稱爲幽靈引用或者幻影引用,它是最弱的一種引用關係。一個對象是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個對象實例。爲一個對象設置虛引用的唯一目的就是能在這個對象被收集器回收時收到一個系統通知。在 JDK1.2 之後,提供了 PhantomReference 類來實現虛引用。

生存還是死亡?
即使在可達性分析算法中不可達的對象,也並非 "非死不可" 的,這時候他們暫時處在 "緩刑" 階段。要宣告一個對象的真正死亡,至少要經歷兩次標記過程: 如果對象在進行可達性分析之後發現沒有與 GC Roots 相連接的引用鏈,那它將會被第一次標記並且進行一次篩選,篩選的條件是此對象是否有必要執行finalize() 方法。當對象沒有覆蓋finalize() 方法或者finalize() 方法已經被 JVM 調用過,虛擬機會將這兩種情況都視爲 "沒有必要執行",此時的對象纔是真正 "死" 的對象。
如果這個對象被判定爲有必要執行finalize() 方法,那麼這個對象將會被放置在一個叫做 F-Queue 的隊列之中,並在稍後由一個虛擬機自動建立的、低優先級的 Finalizer 線程去執行它(這裏所說的執行指的是虛擬機會觸發finalize() 方法)。finalize() 方法是對象逃脫死亡的最後一次機會,稍後 GC 將對 F-Queue 中的對象進行第二次小規模標記,如果對象在finalize() 中成功拯救自己 (只需要重新與引用鏈上的任何一個對象建立起關聯關係即可),那在第二次標記時它將會被移除出 "即將回收" 的集合;如果對象這時候還是沒有逃脫,那基本上它就是真的被回收了。

範例:對象自我拯救

public class Test {
	public static Test test;
	public void isAlive() {
		System.out.println("I am alive :)");
	}
	@Override
	protected void finalize() throws Throwable {
		super.finalize();
		System.out.println("finalize method executed!");
		test = this;
	}
	public static void main(String[] args)throws Exception {
		test = new Test();
		test = null;
		System.gc();
		Thread.sleep(500);
		if (test != null) {
			test.isAlive();
		}else {
			System.out.println("no,I am dead :(");
		}
		// 下面代碼與上面完全一致,但是此次自救失敗
		test = null;
		System.gc();
		Thread.sleep(500);
		if (test != null) {
			test.isAlive();
		}else {
			System.out.println("no,I am dead :(");
		}
	}
}

從上面代碼示例我們發現,finalize 方法確實被 JVM 觸發,並且對象在被收集前成功逃脫。
但是從結果上我們發現,兩個完全一樣的代碼片段,結果是一次逃脫成功,一次失敗。這是因爲,任何一個對象的finalize() 方法都只會被系統自動調用一次,如果相同的對象在逃脫一次後又面臨一次回收,它的finalize() 方法不會被再次執行,因此第二段代碼的自救行動失敗。

2、回收方法區

方法區 (永久代) 的垃圾回收主要收集兩部分內容:廢棄常量和無用類。
回收廢棄常量和回收 Java 堆中的對象十分類似。以常量池中字面量 (直接量) 的回收爲例,假如一個字符串 "abc" 怡景進入了常量池中,但是當前系統沒有任何一個 String 對象引用常量池中的 "abc" 常量,也沒有其他地方引用這個字面量,如果此時發生 GC 並且有必要的話,這個 "abc" 常量會被系統清理出常量池。常量池中的其他類(接口)、方法、字段的符號引用也與此類似。
判定一個類是否是 "無用類" 則相對複雜很多。類需要同時滿足下面三個條件纔會被算是 "無用的類"

  1. 該類的所有實例都已經被回收 (即在 Java 堆中不存在任何該類的實例)
  2. 加載該類的 ClassLoader 已被回收
  3. 該類對應的 Class 對象沒有任何其他地方被引用,無法在任何地方通過反射訪問該類的方法

JVM 可以對同時滿足上述 3 個條件的無用類進行回收,也僅僅是 “可以” 而不是必然。在大量使用反射、動態代理等場景都需要 JVM 具備類卸載的功能來防止永久代的溢出。

3、垃圾回收算法

3.1 標記 - 清除算法

“標記 - 清除” 算法是最基礎的收集算法。算法分爲標記和清除兩個階段:首先標記出所有需要回收的對象,在標記完成後統一回收所有被標記的對象 (標記過程參見 1.2 可達性分析)。後續的收集算法都是基於這種思路並對其不足加以改進而已。
“標記 - 清除” 算法的不足主要有兩個:

  1. 效率問題:標記和清除這兩個過程的效率都不高
  2. 空間問題:標記清除後會產生大量不連續的內存碎片,空間碎片太多可能會導致以後在程序運行中需要分配較大對象時,無法找到足夠連續內存而不得不提前觸發另一次垃圾收集。
3.2 複製算法 (新生代回收算法)

“複製”算法是爲了解決 “標記 - 清除” 的效率問題。它將可用內存按容量劃分爲大小相等的兩塊,每次只使用其中一塊。當這塊內存需要進行垃圾回收時,會將此區域還存活着的對象複製到另一塊上面,然後再把已經使用過的內存區域一次清理掉。這樣做的好處是每次都是對整個半區進行內存回收,內存分配時也就不需要考慮內存碎片等的複雜情況,只需要移動堆頂指針,按順序分配即可。此算法實現簡單,運行高效。算法的執行流程如下圖:

現在的商用虛擬機 (包括 HotSpot) 都是採用這種收集算法來回收新生代

新生代中 98% 的對象都是 "朝生夕死" 的,所以並不需要按照 1 : 1 的比例來劃分內存空間,而是將內存 (新生代內存) 分爲一塊較大的 Eden(伊甸園)空間和兩塊較小的 Survivor(倖存者)空間,每次使用 Eden 和其中一塊 Survivor(兩個 Survivor 區域一個稱爲 From 區,另一個稱爲 To 區域)。當回收時,將 Eden 和 Survivor 中還存活的對象一次性複製到另一塊 Survivor 空間上,最後清理掉 Eden 和剛纔用過的 Survivor 空間。
當 Survivor 空間不夠用時,需要依賴其他內存 (老年代) 進行分配擔保
HotSpot 默認 Eden 與 Survivor 的大小比例是 8 : 1,也就是說 Eden:Survivor From : Survivor To = 8:1:1。所以每次新生代可用內存空間爲整個新生代容量的 90%, 而剩下的 10% 用來存放回收後存活的對象。

HotSpot 實現的複製算法流程如下:

  1. 當 Eden 區滿的時候,會觸發第一次 Minor gc,把還活着的對象拷貝到 Survivor From 區;當 Eden 區再次出發 Minor gc 的時候,會掃描 Eden 區和 From 區,對兩個區域進行垃圾回收,經過這次回收後還存活的對象,則直接複製到 To 區域,並將 Eden 區和 From 區清空。
  2. 當後續 Eden 區又發生 Minor gc 的時候,會對 Eden 區和 To 區進行垃圾回收,存活的對象複製到 From 區,並將 Eden 區和 To 區清空
  3. 部分對象會在 From 區域和 To 區域中複製來複制去,如此交換 15 次 (由 JVM 參數 MaxTenuringThreshold 決定,這個參數默認是 15),最終如果還存活,就存入老年代。
3.3 標記整理算法 (老年代回收算法)

複製收集算法在對象存活率較高時會進行比較多的複製操作,效率會變低。因此在老年代一般不能使用複製算法。
針對老年代的特點,提出了一種稱之爲 “標記 - 整理算法”。標記過程仍與“標記 - 清除” 過程一致,但後續步驟不是直接對可回收對象進行清理,而是讓所有存活對象向一端移動,然後直接清理掉端邊界以外的內存。流程圖如下:

3.4 分代收集算法

當前 JVM 垃圾收集都採用的是 "分代收集 (Generational Collection)" 算法,這個算法並沒有新思想,只是根據對象存活週期的不同將內存劃分爲幾塊。
一般是把 Java 堆分爲新生代和老年代。在新生代中,每次垃圾回收都有大批對象死去,只有少量存活,因此我們採用複製算法;而老年代中對象存活率高、沒有額外空間對它進行分配擔保,就必須採用 "標記 - 清理" 或者 "標記 - 整理" 算法。

面試題: 請問了解 Minor GC 和 Full GC 麼,這兩種 GC 有什麼不一樣嗎?

  1. Minor GC 又稱爲新生代 GC : 指的是發生在新生代的垃圾收集。因爲 Java 對象大多都具備朝生夕滅的特性,因此 Minor GC(採用複製算法) 非常頻繁,一般回收速度也比較快。
  2. Full GC 又稱爲老年代 GC 或者 Major GC : 指發生在老年代的垃圾收集。出現了 Major GC,經常會伴隨至少一次的 Minor GC(並非絕對,在 Parallel Scavenge 收集器中就有直接進行 Full GC 的策略選擇過程)。Major GC 的速度一般會比 Minor GC 慢 10 倍以上。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://blog.csdn.net/yubujian_l/article/details/80804708