深入學習Java虛擬機之——垃圾收集算法與垃圾收集器

今天我們將一起學習Java虛擬機使用垃圾收集算法和常見的垃圾收集器。Java虛擬機內存區域的程序計數器、虛擬機棧和本地方法棧3個區域是隨線程而生,隨線程而滅;棧中的棧幀隨着方法的進入和退出出棧和入棧。每一個棧幀中分配多少內存基本上是在類結構確定下來的時候就已知的,因此這個幾個區域的內存分配和回收都具備確定性,在這幾個區域就不需要過多考慮回收問題,因爲方法結束或者線程結束時,內存自然就跟着回收了。而Java堆和方法區就不一樣,一個接口中的多個類實現需要的內存可能不一樣,一個方法中的多個分支需要的內存也可能不一樣,我們只有在程序處於運行期間是才能知道會創建哪些對象,這部分內存和回收是動態的,垃圾收集器所關注的是這部分內存。


一、判斷對象是否存活

在垃圾收集器對對象進行回收前,首先需要判斷哪些對象是存活的。

1、引用計數算法

給對象添加一個引用計數器,每當有一個地方引用它時,計數器就加1;當引用失效時,計數器值就減1;任何時候計數器爲0的對象就是不可能在被使用的。但是這樣算法的存在的問題是很難解決對象之間相互循環引用的問題。當前主流的Java虛擬機都沒有采用這樣的算法。我們看如下列子:testGC()方法執行後,objA和objB會不會被GC呢?


package gc;

public class ReferenceCountingGC
{
	public Object instance =null;
	 
	private static final int _1MB=1024*1024;
	
	private byte[] bigSize=new byte[2*_1MB];
	
	public static void testGC()
	{
		ReferenceCountingGC objA=new ReferenceCountingGC();
		ReferenceCountingGC objB=new ReferenceCountingGC();
		
		objA.instance=objB;
		objB.instance=objA;
		
		objA=null;
		objB=null;
		
		System.gc();
	}
	
	public static void main(String[] args)
	{
		testGC();
	}
}

GC日誌輸出結果:

[GC (System.gc()) [PSYoungGen: <strong>5735K->584K</strong>(18944K)] 5735K->592K(62976K), 0.0008309 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen:<strong> 584K->0K</strong>(18944K)] [ParOldGen: 8K->514K(44032K)] 592K->514K(62976K), [Metaspace: 2502K->2502K(1056768K)], 0.0058089 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] 
Heap
 PSYoungGen      total 18944K, used 491K [0x00000000eb400000, 0x00000000ec900000, 0x0000000100000000)
  eden space 16384K, 3% used [0x00000000eb400000,0x00000000eb47aff0,0x00000000ec400000)
  from space 2560K, 0% used [0x00000000ec400000,0x00000000ec400000,0x00000000ec680000)
  to   space 2560K, 0% used [0x00000000ec680000,0x00000000ec680000,0x00000000ec900000)
 ParOldGen       total 44032K, used 514K [0x00000000c1c00000, 0x00000000c4700000, 0x00000000eb400000)
  object space 44032K, 1% used [0x00000000c1c00000,0x00000000c1c808e8,0x00000000c4700000)
 Metaspace       used 2511K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 273K, capacity 386K, committed 512K, reserved 1048576K

對象objA和對象objB都有字段instance,進行objA.instance=objB及objB.instance=objA賦值操作,實際上着兩個對象再無任何引用,他們互相引用對方,導致引用計數都不爲0,於是引用計數算法無法通知GC收集器回收他們。但是結果是被收回了(5735K->584K),所以我們的虛擬機採用的不是引用計數算法。


2、 可達性分析算法

在主流的商用程序語言的主流實現中,都是通過可達分析算法來判斷對象是否存活的。這個算法的基本思想就是通過一系列成爲“GC Roots”的對象作爲起始點,從這些節點開始向下搜索,搜索所走過的路徑成爲引用鏈(Reference Chain),當一個對象到GC Roots沒有任何引用鏈是,則證明對象是不可用的,將會被判定爲可回收的對象,但是不會被立即回收,需要被標記兩次之後纔會被回收。

2.1 Java語言中,可作爲GC Roots的對象有:

1) 虛擬機棧(棧幀中本地變量表)中引用的對象。

2)方法區中類靜態屬性引用的對象。

3)方法區中常量引用的對象。

4) 本地方法棧中JNI(一般說的Native方法)引用的對象。


二、引用

無論是引用計數法判斷對象的引用數量,還是可達性分析算法判斷對象的引用鏈是否可達,判斷對象是否存活都與“引用”有關。Java中的引用分爲強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)、虛引用(Phantom Reference)4種,這種引用強度一次逐漸減弱。

1) 強引用就是指在程序代碼之中普遍存在的,類似"Object obj=new Object()"這類的引用,只要強引用存在,垃圾收集器永遠不會回收掉被引用的對象。

2) 軟引用是用來描述一些還有用但並非必須的對象。對於軟引用關聯着的對象,在系統將要發生內存溢出異常之前,將會把這些對象列進回收範圍之中進行第二次回收。如果這次回收還沒有足夠的內存,纔會拋出內存異常。在JDK1.2之後,提供了SoftReference類來實現軟引用。

3) 弱引用也是用來描述非必須對象的,但是它的強度比軟引用更弱一些,被弱引用關聯的對象只能生存到下一次垃圾收集發生之前。當垃圾收集器工作時,無論當前內存是否足夠,都會回收掉只被弱引用關聯的對象,在JDK1.2之後,提供了WeakReference類來實現弱引用。

4) 虛引用是最弱的一種引用關係。一個對象是否所有虛引用存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個對象實例。爲一個對象設置虛引用關聯唯一目的就是能在這個對象唄收集器回收時得到一個系統通知。在JDK1.2後,提供了PhantomReference類來實現虛引用。


三、對象的自我拯救

即使在可達分析算法中不可達的對象,也並非立即就被回收,需要經過兩次標記。如果對象在進行可達性分析後發現沒有與GC Roots相連接的引用鏈,那它將會被第一次標記並且進行一次篩選,篩選的條件是此對象是否有必要執行finalize()方法。當對象沒有覆蓋finalize()方法或者finalize()方法被虛擬機已經調用過,虛擬機將這兩種都視爲沒有必須要執行。

如果這個對象被判定爲由必要執行finalize()方法,那麼這個對象將會放置在一個F-Queue的隊列中,並在稍後由一個虛擬機自動建立的、低優先級的Finalizer線程去執行。如果對象在finalize()方法中重新與引用鏈上的任何對象建立關聯即可,比如把自己(this關鍵字)賦值給某個類的變量或者對象的成員變量,那在第二次標記是它將被移除即將回收的集合;如果對象這個時候還沒有逃脫,那基本上他就被回收了。來看如下代碼:


package gc;

/**
 * 此代碼演示了兩點
 * 1、對象可以在被GC時自我拯救
 * 2、這種自救機會只有一次
 * @author Administrator
 *
 */
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 method 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();
				
		Thread.sleep(5000);
		if(SAVE_HOOK!=null)
		{
			SAVE_HOOK.isAlive();
		}else
		{
			System.out.println("no, i am dead");
		}
		
	}
	
	
	
	
	
	
	
	
}

輸出結果:

finalize method executed
yes,i am still alive
no, i am dead

第一次拯救成功,第二次卻失敗。任何一個對象的finalize()方法都只會被對象自動調用一次,如果對象面臨下一次回收,它的finalize()方法會被再次執行。需要注意的時,最好不要使用該方法來拯救對象。


四、回收方法區

在堆中,尤其是在新生代中,常規應用進行一次垃圾收集一般可以回收70%~95%的空間,而在方法區的垃圾收集效率遠低於此。方法區的垃圾收集主要回收兩部分內容:廢棄常量和無用的類。回收廢棄常量與回收Java堆中的對象非常相似。以常量池中字面量的回收爲例,例如一個字符串“abc"已經進入了常量池中,但是當前系統沒有任何一個String對象是叫做”abc“的,換句話說,就是沒有任何String對象引用常量池中的”abc"常量,也沒有其他地方引用了這個字面量,如果這時發生內存回收,而且必要的話,這個“abc"常量就會被系統清理出常量池。常量池中其他類、方法、字段的符號引用也與此類似。

判定一個類爲無用類的3個條件:

1) 該類所有實力都已經被回收,也就是Java堆中不存在該類的任何實例。

2)加載該類的ClassLoader已經被回收。

3) 該類對應的java.lang.Class對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。


五、垃圾收集算法

1、標記-清除算法

算法分爲標記清除兩個階段:首先標記出所需要回收的對象,在標記完成後統一回收所有標記的對象,它的標記過程就是上面提到的。這種算法主要有兩個不足:一是效率問題,標記和清除兩個過程的效率都不高;另一個是空間問題,標記清除之後會產生大量不連續的內存碎片,空間碎片太多可能會導致以後在程序運行過程中需要分配打對象時,無法找到足夠的連續內存而不得不提前出發另一次垃圾收集動作。

2、複製算法

它將可能內存按容量劃分爲大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活着的對象複製到另外一塊上面,然後再把已使用過的內存空間一次清理掉。這樣使得每次都是對整個半區進行內存回收,內存分配時也就不用考慮內存碎片等複雜情況,只要移動堆頂指針,按順序分配內存即可。這種算法效率高,代價是將內存壓縮爲原來的一半。現在的商業虛擬機都採用這種手機算法來回收新生代。HotSpot虛擬機默認Eden和Survivor的大小比例是8:1,當然這樣沒有辦法保證每次回收都只有不多於10%的對象存活,當Survivor空間不夠用的時候,需要依賴其他內存(老年代)進行分配擔保。

3、標記-整理算法

複製收集算法在對象存活率較高是就要進行較多的複製操作,效率將會變低。根據老年代的特點,提出了標記-整理算法,標記過程任然與“標記-清除算法”一樣,但是後續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然後直接清理掉端邊界以外的內存。

4、 分代收集算法

當代商業虛擬機的垃圾收集算法都採用“分代收集算法”,根據對象存活週期的不同將內存劃分爲幾塊。一般是Java堆中分爲新生代和老年代,這樣就可以根據各個年代的特點採用歲適當的收集算法。在新生代中,每次垃圾收集時都發現有大批對象死去,只有少量存活,那就選用複製算法只需要付出少量存活對象的複製成本就可以完成收集。而老年代中因爲對象的存活率高,沒有額外空間對它進行擔保,就必須採用“標記-清理算法”或者“標記-整理算法”來實現回收。


六、垃圾收集器(針對HotSpot虛擬機而言)

1、Serial收集器

這個收集器時一個單線程的收集器,進行垃圾收集時,必須暫停其他所有工作線程,直到垃圾收集結束。目前用於Client模式下的默認新生代收集器。其優點是,簡單而高效(與其他收集器的單線程相比),對於限定單個CPU的環境來說,Serial收集器由於沒有線程交互的開銷,垃圾收集效率十分高。

2、ParNew收集器

ParNew收集器時Serial收集器的多線程版本,除了使用多線程進行垃圾收集之外,其餘行爲包括Serial收集器可用的所有控制參數、收集算法、Stop The World、對象分配規則等與Serial收集器一樣。目前是許多運行在Server模式下的虛擬機中首選的新生代收集器,其中一個與性能無關的但很重要的原因是,除了Serial收集器外,目前只有他能與CMS(Concurrent Mark Sweep)收集器配個工作。


3、Parallel Scavenge收集器

Parallel Scavenge收集器是一個新生代收集器,它也是使用複製算法的收集器,又是並行的多線程收集器。該收集器的目標是達到一個可控 的吞吐量。所謂吞吐量就是CPU用於運行用戶代碼的時間與CPU總消耗時間的比值,即吞吐量=運行用戶代碼時間/(用戶代碼運行時間+垃圾收集時間),虛擬機總共運行了100分鐘,其中垃圾收集花掉了1分鐘,那吞吐量就是99%。

高吞吐量可以高效率得利用CPU時間,儘快完成程序的運算任務,主要適合在後臺運算而不需要太多交互的任務。Parallel Scavenge收集器提供了兩個參數用於精確控制吞吐量,分別是控制最大垃圾收集停頓時間-XX:MaxGCPauseMillis參數以及直接設置吞吐量大小的-XX:GCTimeRatio參數。


4、 Serial Old收集器

Serial Old收集器時Serial收集器的老年代版本,同樣也是一個單線程收集器,使用“標記-整理算法”。這個收集器的主要意義也是在於給Client模式下的虛擬機使用。如果在Server模式下,它還有兩大用途:一種用途是在JDK1.5以及之前的版本中與Parallel Scavenge收集器搭配使用,另一種用途就是作爲CMS收集器的後備預案,在併發收集發生Concurrent Mode Failure時使用。


5、 Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多線程和“標記-整理”算法。在注重吞吐量以及CPU資源銘感的場合,都可以優先考慮Parallel Scavenge加Parallel Old收集器。


6、 CMS收集器

CMS(Concurrent Mark Sweep)收集器是一種以獲得最短回收停頓時間爲目標的收集器。目前很大一部分的Java應用集中在互聯網站或B/S系統的服務端上,這類應用尤其重視服務的響應速度,希望系統停頓時間短暫,以給用戶帶來良好的體驗。CMS是一種基於“標記-清除”算法實現的 ,運作過程分爲四個步驟:

1)初始標記

2)併發標記

3)重新標記

4)併發清除

其中,初始標記、重新標記這兩個步驟仍然需要“stop the world"。初始標記僅僅實在是標記一下GC Roots能直接關聯到的對象,速度很快,併發標記階段就是進行GC Roots Tracing的過程,而重新標記階段則是爲了修改併發標記期間因爲用戶程序持續運作而導致標記產生變動的那一部分對象的標記記錄,這個階段的停頓時間一般會比初始標記階段稍微長一些,但遠比並發標記時間短。

由於整個過程耗時最長的併發標記和併發清除過程收集線程都可以與用戶線程一起工作,所以,總體上來說,CMS收集器的內存回收過程是與用戶線程一起併發執行的。


缺點如下:

1)CMS收集器對CPU資源非常敏感,在併發的時候佔用CPU資源會導致應用程序變慢,總吞吐量會降低,在CPU數量少的情況下會很明顯。


2)CMS收集器無法處理浮動垃圾,可能出現“Concurrent Mode Failure"失敗而導致另一次Full GC的產生。由於CMS併發清理階段用戶線程還在運行着,伴隨程序運行自然還會有新的垃圾產生,這一部分垃圾出現在標記過程之後,CMS無法在當次收集中處理掉他們,這一部分叫做”浮動垃圾“。


3) 由於CMS是基於“標記-清除”算法實現的收集器,這必然會產生很多空間碎片,將會給大對象分配帶來很大的麻煩,往往老年代還有很大的空間剩餘,但是無法找到足夠大的連續空間來分配當前對象,不得不再次出發一次Full GC。爲了解決這個問題,CMS收集器提供了一個-XX:+UseCMSCompactAtFullCollection開關參數(默認開啓),用於CMS收集器頂不住要進行Full GC是開啓內存碎片的合併整理過程,內存整理的過程是無法併發的,空間碎片問題沒有了,但停頓時間不得不變長。


7、G1收集器

G1(Garbage-First)收集器時當今收集器技術發展的最前沿成果之一。G1是一款面向服務端應用的垃圾收集器,與其他GC收集器相比,G1具備如下特點:


1) 並行與併發:G1能允許利用多CPU、多核環境下的硬件優勢,使用多個CPU來縮短Stop-The-World停頓的時間,部分其他收集器原本需要停頓Java線程執行的GC動作,G1收集器仍然可以通過併發的方式讓Java程序繼續執行。


2)分代收集:與其他收集器一樣,分代概念在G1中依然得以保留。雖然G1可以不需要其他收集器配個就能獨立管理整個GC堆,但它能夠採用不同的方式去處理新創建的對象和已經存活了一段時間、熬過多次GC的舊對象以獲得更好的收集效果。


3)空間整合:與CMS的標記-清除算法不同,G1從整體來看是採用基於標記-整理算法實現的收集器,從局部上來看是基於複製算法實現的。這兩種算法都不會在G1運行期間產生內存空間碎片,收集後能夠提供規整的可用內存。


4)可預測的停頓:這是G1相對於CMS的另一優勢,降低停頓時間是G1和CMS共同的關注點,但G1除了追求低停頓外,還能建立可預測的停頓時間模型,能讓使用者明確指定在一個長度爲M毫秒的時間片段內,消耗在垃圾收集上的時間不得超過N毫秒。

在G1之前的其他收集器進行收集的範圍是整個新生代或者老年代,而G1不再是這樣。使用G1收集器時,Java堆的內存佈局就與其他收集器有很大差別,他將整個Java堆劃分爲多個大小相等的獨立區域(Region),雖然還保留有新生代和老年代的概念,但新生代和老年代不在是物理隔離的了,他們都是一部分Region的集合。

G1收集器之所以能建立可預測的停頓時間模型,是因爲它可以有計劃地避免在整個Java堆中進行全區域的垃圾收集。G1跟蹤各個Region裏面的垃圾堆積的價值大小,在後臺維護一個優先列表,每次根據允許的收集時間,優先回收價值大的Region。


關於垃圾收集器就到這裏,細節的地方就不在這裏多說了。

參考:

《深入java虛擬機》































發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章