天天看點

深入學習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虛拟機》