1. 程式人生 > >JVM初窺:垃圾收集器(一)

JVM初窺:垃圾收集器(一)

參考書籍:《深入理解Java虛擬機器——JVM高階特性與最佳實踐(第2版)

Java語言出來之前,程式開發更多的是使用C或者C++語言,然而在C或者C++語言中存在一個很大的矛盾:建立物件時要不斷地呼叫物件的構造方法來為物件開闢空間,物件用完之後又需要不斷地去呼叫析構方法來釋放它所佔的記憶體空間,既要寫構造方法,又要寫析構方法,很多時候都是在做重複的工作。為了解決這一矛盾,垃圾收集器(Garbage Collection,GC)應運而生。

說起垃圾收集器(Garbage Collection,GC),大部分人都把這項技術當作Java語言的伴生產物。事實上,GC的歷史比Java久遠,1960年誕生於MIT的Lisp是第一門真正使用記憶體動態分配和垃圾收集技術的語言。
當Lisp還在胚胎時期時,人們就在思考GC需要完成的3件事情:
  • 哪些記憶體需要回收?
  • 什麼時候回收?
  • 如何回收?
由於Java執行時資料區域中的程式計數器、虛擬機器棧、本地方法棧3個區域隨執行緒而生,隨執行緒而滅;棧中的棧幀隨著方法的進入和退出而有條不紊地執行著入棧和出棧操作。每一個棧幀中分配多少記憶體基本上是在類結構確定下來時就已知的,因此這幾個區域的記憶體分配和回收都具備確定性,在這幾個區域就不需要過多考慮回收的問題,因為方法結束或者執行緒結束時,記憶體自然就跟隨著回收了。

垃圾收集器回收的重點區域是:Java堆和方法區。

“存活”or“死去”

垃圾收集器在對物件進行回收之前,需先判斷物件是“存活”著,還是已經“死去”(即不可能再被任何途徑使用的物件)。垃圾收集器只會對那些已經“死去”的物件進行回收。
常用的判斷物件是否存活的演算法有以下兩種:
1、  引用計數演算法
引用計數(Reference Counting)演算法是垃圾收集器中的早期策略。在這種演算法中,堆中每個物件(不是引用)都有一個引用計數。對於一個物件 A,只要有任何一個物件引用了 A,則A 的引用計數器就加 1,當引用失效時,引用計數器就減 1。任何時刻引用計數為0的物件就是不可能再被使用的,即已經“死去”。
引用計數演算法實現簡單,判定效率也很高。但是這個演算法有明顯的缺陷,對於迴圈引用的情況下,迴圈引用的物件就不會被回收。如A=B,B=A, 此時,物件 A 和物件B 的引用計數器都不為 0。但是在系統中卻不存在任何第 3 個物件引用了 A 或 B。也就是說,A 和 B 是應該被回收的垃圾物件,但由於垃圾物件間相互引用,從而使垃圾收集器無法識別,引起記憶體洩漏。
2、  根搜尋演算法
這種演算法的基本思路是通過一系列名為“GC Roots”的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈(Reference Chain),當一個物件到GC Roots沒有任何引用鏈相連(用圖論的話來說,就是從GC Roots到這個物件不可達)時,就證明此物件是不可用的。如下圖所示(藍色表示依然存活的物件,橙色表示可回收的物件),物件ObjD、ObjE雖然彼此有關聯,但是它們到GC Roots是不可達的,所以它們將會被判定為是可回收的物件。


在Java語言裡,可作為GC Roots的物件包括下面幾種:
  • 虛擬機器棧(棧幀中的本地變量表)中引用的物件。
  • 方法區中的類靜態屬性引用的物件。
  • 方法區中的常量引用的物件。
  • 本地方法棧中JNI(Native方法)引用的物件。

再談引用

無論是通過引用計數演算法判斷物件的引用數量,還是通過根搜尋演算法判斷物件的引用鏈是否可達,判斷物件是否存活都與“引用有關”,所以我們在此再談談引用。

在JDK 1.2之後,Java對引用的概念進行了擴充,將引用分為強引用( Strong Reference)、軟引用( Soft Reference)、弱引用( Weak Reference)、虛引用( Phantom Reference)四種,這四種引用強度依次逐漸減弱。
1、強引用就是指在程式程式碼之中普遍存在的,類似“Object obj = new Object()”這類的引用,只要強引用還存在,垃圾收集器永遠不會回收掉被引用的物件。當記憶體空間不足,Java虛擬機器寧願丟擲OutOfMemoryError錯誤,使程式異常終止,也不會靠隨意回收具有強引用的物件來解決記憶體不足的問題。如果不使用時,可以賦值obj=null,顯示的設定obj為null,則GC認為該物件不存在引用,這時候就可以回收此物件。

2、 軟引用用來描述一些還有用,但並非必需的物件。對於軟引用關聯著的物件,在系統將要發生記憶體溢位異常之前,將會把這些物件列進回收範圍之中進行第二次回收。如果這次回收還沒有足夠的記憶體,才會丟擲記憶體溢位異常。在 JDK 1.2 之後,提供了 SoftReference 類來實現軟引用。

軟引用可以和一個引用佇列(ReferenceQueue)聯合使用,如果軟引用所引用的物件被垃圾回收器回收,Java虛擬機器就會把這個軟引用加入到與之關聯的引用佇列中。

軟引用主要應用於記憶體敏感的快取記憶體,在Android系統中經常使用到。一般情況下,Android應用會用到大量的預設圖片,這些圖片很多地方會用到。如果每次都去讀取圖片,由於讀取檔案需要硬體操作,速度較慢,會導致效能較低。所以我們考慮將圖片快取起來,需要的時候直接從記憶體中讀取。但是,由於圖片佔用記憶體空間比較大,快取很多圖片需要很多的記憶體,就可能比較容易發生OutOfMemory異常。這時,我們可以考慮使用軟引用技術來避免這個問題發生。SoftReference可以解決oom的問題,每一個物件通過軟引用進行例項化,這個物件就以cache的形式儲存起來,當再次呼叫這個物件時,那麼直接通過軟引用中的get()方法,就可以得到物件中的資源資料,這樣就沒必要再次進行讀取了,直接從cache中就可以讀取得到,當記憶體將要發生OOM的時候,GC也能回收該記憶體,防止oom發生。
3、弱引用也是用來描述非必需物件的,但是它的強度比軟引用更弱一些,被弱引用關聯的物件只能生存到下一次垃圾收集發生之前。,當JVM進行垃圾回收時,無論當前記憶體是否充足,都會回收掉只被弱引用關聯的物件。在JDK 1.2之後,用WeakReference類來實現弱引用。弱引用與軟引用的區別在於:只具有弱引用的物件擁有更短暫的生命週期。
以下程式碼用於對比在記憶體充足的情況下,呼叫System.gc()之後,軟引用與弱引用關聯物件的不同回收結果。
import java.lang.ref.WeakReference;

/**
 * VM Args:-XX:+PrintHeapAtGC
 */
public class ReferenceTest {

	public static void main(String[] args) {
		byte[] bytes = new byte[1024 * 1024 * 5];
		/**
		 * 取消softBytes所在行註釋,在記憶體充足的情況下,GC不會回收所關聯的byte陣列。
		 */
		// SoftReference<byte[]> softBytes=new SoftReference<byte[]>(bytes);
		/**
		 * 取消weakBytes所在行註釋,在記憶體充足的情況下,GC也會成功回收所關聯的byte陣列。
		 */
		// WeakReference<byte[]> weakBytes=new WeakReference<byte[]>(bytes);
		bytes = null;
		System.gc();
	}
}

執行程式,根據PrintHeapAtGC所打印出來的堆記憶體回收日誌,可得出以下結論:軟引用關聯的物件未被回收,而弱引用關聯的物件被成功回收了。

4、虛引用也稱為幽靈引用或者幻影引用,它是最弱的一種引用關係。和前面的軟引用、弱引用不同,它並不影響物件的生命週期。在JDK 1.2之後,用PhantomReference類來實現虛引用。如果一個物件與虛引用關聯,則跟沒有引用與之關聯一樣,在任何時候都可能被垃圾回收器回收。為一個物件設定虛引用關聯的唯一目的就是能在這個物件被收集器回收時收到一個系統通知。

死裡逃生----finalize()方法

被判定為已經“死亡”的物件並不是一定會被JVM回收。一個物件在被回收之前,至少要經歷兩次標記過程:初次被判定為已經“死亡”後,物件將會被第一次標記並且進行一次篩選,篩選的條件是此物件是否有必要執行finalize()方法。當物件沒有覆蓋finalize()方法,或者finalize()方法已經被虛擬機器呼叫過,虛擬機器將這兩種情況都視為“沒有必要執行”(即意味著直接回收)。如果這個物件被判定為有必要執行finalize()方法,那麼這個物件將會放置在一個叫做F-Queue的佇列之中,並在稍後由一個由虛擬機器自動建立的、低優先順序的Finalizer執行緒去執行它。這裡所謂的“執行”是指虛擬機器會觸發這個方法,但並不承諾會等待它執行結束,這樣做的原因是,如果一個物件在finalize()方法中執行緩慢,或者發生了死迴圈(更極端的情況),將很可能會導致F-Queue佇列中其他物件永久處於等待,甚至導致整個記憶體回收系統崩潰。

finalize()方法是物件逃脫死亡命運的最後一次機會,稍後GC將對F-Queue中的物件進行第二次小規模的標記,如果物件要在finalize()中成功拯救自己——只要重新與引用鏈上的任何一個物件建立關聯即可,譬如把自己(this關鍵字)賦值給某個類變數或者物件的成員變數,那在第二次標記時它將被移除出“即將回收”的集合;如果物件這時候還沒有逃脫,那基本上它就真的被回收了。

public class FinalizeEscapeGC {
	public static FinalizeEscapeGC SAVE_HOOK = null;

	public void isAlive() {
		System.out.println("yes,i am still alive:)");
	}

	@Override
	protected void finalize() throws Throwable {
		super.finalize();
		System.out.println("finalize mehtod executed!");
		FinalizeEscapeGC.SAVE_HOOK = this;
	}

	public static void main(String[] args) throws Throwable {
		SAVE_HOOK = new FinalizeEscapeGC();
		// 物件第一次成功拯救自己
		SAVE_HOOK = null;
		System.gc();
		// 因為finalize方法優先順序很低,所以暫停0.5秒以等待它
		Thread.sleep(500);
		if (SAVE_HOOK != null) {
			SAVE_HOOK.isAlive();
		} else {
			System.out.println("no,i am dead:(");
		}
		// 下面這段程式碼與上面的完全相同,但是這次自救卻失敗了
		SAVE_HOOK = null;
		System.gc();
		// 因為finalize方法優先順序很低,所以暫停0.5秒以等待它
		Thread.sleep(500);
		if (SAVE_HOOK != null) {
			SAVE_HOOK.isAlive();
		} else {
			System.out.println("no,i am dead:(");
		}
	}
}

執行結果:

finalize mehtod executed!
yes,i am still alive:)
no,i am dead:(
從執行結果來看SAVE_HOOK物件的finalize()方法確實被GC收集器觸發過,,並且在被收集前成功逃脫了。

另一個值得注意的地方是,程式碼中有兩段完全一樣的程式碼片段,執行結果卻是一次逃脫成功,一次失敗,這是因為任何一個物件的finalize()方法都只會被系統自動呼叫一次,如果對面面臨下一次回收,它的finalize()方法不會被再次執行,因此第二段程式碼的自救行動失敗了。

回收方法區

很多人以為方法區(或者HotSopt VM中的永久代)是沒有垃圾收集的,Java虛擬機器規範中確實說過可以不要求虛擬機器在方法區實現垃圾收集,而且價效比一般較低,在對的新生代生一般能回收70%~95%的空間,而永久代遠低於此。永久代的垃圾收集主要回收兩部分內容:廢棄常量和無用的類。 回收廢棄常量與回收Java堆中的物件非常相似。以常量池中字面量的回收為例,若字串“abc”已經進入常量池中,但當前系統沒有任何String物件引用常量池中的“abc”常量,也沒有其他地方引用該字面量,若發生記憶體回收,且必要的話,該“abc”就會被系統清理出常量池。常量池中其他的類(介面)、方法、欄位的符號引用與此類似。無用的類需要滿足3個條件:
  • 該類所有的例項都已經被回收,即Java堆中不存在該類的任何例項; 
  • 載入該類的ClassLoader已經被回收; 
  • 該類對應的java.lang.Class物件沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。
虛擬機器可以對滿足上述3個條件的無用類進行回收,此處僅僅是“可以”,而並不是和物件一樣,不使用了就必然回收。