深入理解JVM - 垃圾蒐集器與內存分配策略

一、概述

       垃圾收集(Garbage Collection, GC),歷史比Java更久遠,1960年誕生於MIT的Lisp是第一門真正使用內存動態分配和垃圾收集技術的語言。當Lisp還在胚胎時期時,人們就在思考GC需要完成的三件事情:

  • 哪些內存需要回收
  • 什麼時候回收
  • 如何回收

爲什麼瞭解GC和內存分配?

       當需要排查各種內存溢出,內存泄漏問題時,當垃圾收集成爲系統達到更高併發量的瓶頸時,就需要對這些“自動化”的技術實施必要的監控和調節。

垃圾蒐集器主要關注的內存範圍

       程序計數器、虛擬機棧、本地方法棧三個區域隨線程而生,隨線程而滅;棧中的棧幀隨着方法的進入和退出而有條不紊地執行着出棧和入棧操作。每一個棧幀(jvm爲每個新創建的線程都分配一個堆棧。堆棧以幀爲單位保存線程的狀態。jvm對堆棧只進行兩種操作:以幀爲單位的壓棧和出棧操作。)隨着方法的進入和退出而有條不紊地執行着出棧和入棧操作。每一個棧幀中分配多少內存基本上是在類結構確定下來時就已知的(儘管在運行期會由JIT編譯器進行一些優化,但在本章基於概念模型的討論中,大體上可以認爲是編譯器可知的),因此這幾個區域的內存分配和回收都具備確定性,在這幾個區域內不需要過多考慮回收的問題,因爲方法結束或縣城結束時,內存就自然就跟隨着回收了。而Java堆和方法區則不一樣,一個接口中的多個實現類需要的內存可能不一樣,一個方法中的多個分支需要的內存也可能不一樣,我們只有在程序處於運行期間時才能知道會創建哪些對象,這部分內存的分配和回收都是動態的,垃圾收集器所關注的是這部分內存。

二、如何判斷對象已死

1.引用計數算法

給對象中添加一個引用計數器,每當有一個地方引用它時,計數器值就加1;當引用失效時,計數器值就減1;任何時刻計數器都爲0的對象就是不可能再被使用的。

優點:實現簡單,判定效率高,大部分情況下它都是一個不錯的算法。

缺點:很難解決對象之間的相互循環引用的問題。(所以Java並沒有選用引用計數算法)

//示例代碼
A  a =new A();
B  b =new B();
A.data=b;
B.data=a;

2.根搜索算法

通過一系統的名爲“GC Roots”的對象作爲起始點,從這些節點開始向下搜索,搜索所走過的路徑成爲引用鏈(Reference Chain),當一個對象到GC Roots沒有任何引用鏈相連(用圖論的話來說就是從GC Roots到這個對象不可達)時,則證明對象是不可用的。

3.再談引用

在JDK1.2之前,Java中的引用的定義很傳統:如果reference類型的數據中存儲的數值代表的是另外一塊內存的起始地址,就稱這塊內存代表着一個引用。

JDK1.2之後,Java對引用的概念進行了擴充,將引用分爲強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)、虛引用(Phantom Reference)四種,這四種引用強度依次逐漸減弱。 

  • 強引用就是指在程序代碼之中普遍存在的,類似“Object obj =new Object()” 這類的引用,只要強引用還存在,垃圾收集器永遠不會回收掉被引用的對象。
  • 軟引用用來描述一些還有用,但並非必須的對象。對於軟引用關聯着的對象,在系統將要發生內存溢出異常之前,將會把這些對象列進回收範圍之中並進行第二次回收。如果這次回收還是沒有足夠的內存,纔會拋出內存溢出異常。在JDK1.2之後,提供了SoftReference類實現軟引用。下面例子摘自其他文章,點擊查看來源
    Browser prev = new Browser();               // 獲取頁面進行瀏覽
    SoftReference sr = new SoftReference(prev); // 瀏覽完畢後置爲軟引用		
    if(sr.get()!=null){ 
    	rev = (Browser) sr.get();           // 還沒有被回收器回收,直接獲取
    }else{
    	prev = new Browser();               // 由於內存喫緊,所以對軟引用的對象回收了
    	sr = new SoftReference(prev);       // 重新構建
    }

     

  • 弱引用也是用來描述非必須對象的,但是它的強度比軟引用更弱一些,被弱引用關聯的對象只能生存到下一次垃圾收集之前。當垃圾收集器工作時,無論當前內存是否足夠,都會回收掉只被弱引用關聯的對象。在JDK1.2之後,提供了WeakReference類來實現弱引用。簡而言之,在垃圾回收器線程掃描它所管轄的內存區域的過程中,一旦發現了只具有弱引用的對象,不管當前內存空間足夠與否,都會回收它的內存。不過,由於垃圾回收器是一個優先級很低的線程,因此不一定會很快發現那些只具有弱引用的對象。
    String str=new String("abc");    
    WeakReference<String> abcWeakRef = new WeakReference<String>(str);
    str=null;  

     

  • 虛引用也被稱爲幽靈引用或者幻影引用,它是最弱的一種引用關係。一個對象是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個對象實例。作用:爲一個對象設置虛引用關聯的唯一目的就是能在這個對象被收集器回收時收到一個系統通知

4.根搜索算法判定對象死亡的過程

在該算法中宣告一個對象死亡,至少要經歷兩次標記過程:

1.如果對象在進行根搜索後發現沒有與GC Roots相連接的引用鏈,進行第一次標記並篩選。篩選條件:此對象是否有必要執行finalize()方法。當對象沒有覆蓋finalize()方法,或者finalize()方法已經被虛擬機調用過,都是沒有必要。會直接進行回收。

2.如果有必要執行finalize()方法,則該對象將會被放置在一個F-Queue的隊列中,稍後虛擬機會自動建立一個低優先級的Finalizer線程去執行該對象的finalize()方法,執行finalize方法完畢後,GC會再次判斷該對象是否可達,若不可達,第二次標記,進行回收,否則,對象“復活”。

注:finalize()是Object的protected方法,子類可以覆蓋該方法以實現資源清理工作,GC在回收對象之前調用該方法。

根搜索算法判定對象死亡的過程演示代碼

/**
 * 1.此代碼演示對象在被GC時自我拯救
 * 2.這種自救的機會只有一次,因爲一個對象的finalize()方法最多隻會被系統自動調用一次
 *
 */
public class FinalizeEscapeGC {
	public static FinalizeEscapeGC SAVE_HOOK=null;
	
	public void isAlive(){
		System.out.println("I am Alive");
	}
	protected void finalize()throws Throwable{ 
		super.finalize();
		System.out.println("finalize method executed!");
		FinalizeEscapeGC.SAVE_HOOK=this;  //引用自救
	}
	public static void main(String[] args) throws InterruptedException {
		SAVE_HOOK=new FinalizeEscapeGC();
		SAVE_HOOK=null;
		System.gc();//該語句之後,GC將其第一次標記,但是由於自救,該對象沒有被第二次標記,活了下來
		Thread.sleep(500);  //由於Finalizer方法優先級很低,暫停0.5s,以等待它
		if(SAVE_HOOK!=null){ //驗證結果
			SAVE_HOOK.isAlive();
		}else{
			System.out.println("Dead");
		}
		
		//下面這段代碼與上面的完全相同,但是這次自救卻失敗了。
		SAVE_HOOK=null;
		System.gc();//該語句之後,GC將其第一次標記,進行篩選,由於finalize()方法已經執行過一次,所以會直接被回收
		Thread.sleep(500);  //由於Finalizer方法優先級很低,暫停0.5s,以等待它
		if(SAVE_HOOK!=null){ //驗證結果
			SAVE_HOOK.isAlive();
		}else{
			System.out.println("Dead");
		}
	}
}

5.回收方法區

永久代的垃圾收集主要回收兩部分內容:廢棄常量和無用的類。

回收廢棄常量與回收Java堆中的對象非常類似。以常量池中字面量的回收爲例,例如一個字符串“abc”已經進入了常量池中,但是當前系統沒有任何一個String對象是叫做“abc”的,歡呼華說是沒有任何String對象引用常量池中的“abc”常量,也沒有其他地方引用了這個字面量,如何在這時候發售內存回收,而且必要的話,這個“abc”常量就會被系統“請”出常量池。常量池中的其他類(接口)、方法、字段的符號引用也與此類似。

判斷常量是否廢棄比較簡單,而判定一個無用的類需要同時滿足下面3個條件才能算是“無用的類”:

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

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

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

虛擬機可以對滿足上述3個條件的無用類進行回收,這裏說的僅僅是“可以”,而不是和對象一樣,不適用了就會必然回收。是否對類進行回收,HotSpot虛擬機提供了-Xnoclassgc參數進行控制,還可以使用-verbose:class及-XX:+TranceClassLoading、-XX:+TraceClassUnLoading查看類的加載和卸載信息。-verbose:class和-XX:+TraceClassLoading可以在Product版的虛擬機中使用,但是-XX:TraceClassUnLoading參數需要fastdebug版的虛擬機支持。

在大量使用反射、動態代理、CGLib等bytecode框架的場景,以及動態生成JSP和OSGi這類頻繁自定義ClassLoader的場景都需要虛擬機具備類卸載的功能,以保證永久代不會溢出。

三、垃圾蒐集算法

1.標記-清除算法

最基礎的收集算法是“標記-清除”(Mark-Sweep)算法,如它的名字一樣,算法分爲“標記”和“清除”兩個階段:首先標記出所有需要回收的對象,在標記完成後同意回收掉所有被標記的對象,它的標記過程在正如前面說的那樣相同。

缺點:1)效率問題,標記和清除過程的效率都不高;、

            2)另外一個是空間問題,標記清除之後會產生大量不連續的內存碎片,空間碎片太多可能會導致,當程序在以後的運行過程中需要分配較大對象時無法找到足夠的連續內存而不得不提前出發另一次垃圾蒐集動作。

2.複製算法爲了解決效率問題,一種稱爲“複製”(Copying)的蒐集算法出現了,它將可用內存按容量劃分爲大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活着的對象複製到另外一塊上面,然後再把已使用過的內存空間一次清理掉。

優點:實現簡單,運行高效。

缺點:內存縮小爲原來的一半。

         現在的商業虛擬機都採用這種蒐集算法來回收新生代,IBM的專門研究表明,新生代中的對象98%是朝生夕死的,所以並不需要按照1:1的比例來劃分內存空間,而是將內存分爲一塊比較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor。當回收事,將Eden和Survivor中還存活着的對象一次性拷貝到另外一塊Survivor空間上,最後清理掉Eden和剛纔使用過的Survivor的空間。HotSpot虛擬機默認Eden和Survivor的大小比例是8:1,也就是每次新生代可用內存空間爲整合新生代容量的90%,只有10%的內存是會被“浪費”得。當然,98%的對象可回收只是一般場景下的數據,我們沒有辦法保證每次回收都只有不多於10%的對象存活,當Survivor空間不夠時,需要依賴其他內存(這裏指老年代)進行分配擔保(Handle Promotion)

3.標記-整理算法

複製蒐集算法在對象存活率較高時就要執行較多的複製操作,效率將會變低。更關鍵的是,如果不想浪費50%的空間,就需要有額外的空間進行分配擔保,以應對被使用的內存中所有對象100%存活的極端情況,所以在老年代一般不能直接選用這種算法。

根據老年代的特點,有人提出了另外一種“標記-整理”(Mark-Compact)算法,標記過程仍然與“標記-清除”算法一樣,但後續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然後直接清理掉端邊界以外的內存。

4.分代蒐集算法

當前商業虛擬機的垃圾蒐集都採用“分代收集”(Generational Collection)算法,這種算法並沒有什麼新的思想,只是根據對象的存活週期的不同將內存劃分爲幾塊。一般把Java堆分爲新生代和老年代,這樣就可以根據各個年代的特點採用最適當的蒐集算法。

四、垃圾蒐集器

如果說收集算法是對內存回收的方法論,垃圾收集器就是內存回收的具體實現。

1.Serial收集器

曾經是虛擬機新生代收集的唯一選擇,是一個單線程的收集器。但它單線程的意義並不是說明它只會使用一個CPU或一條收集線程去完成垃圾收集工作,更重要的是在它進行垃圾收集時,必修暫停其他所有的工作線程,直到它收集結束。

新生代採用複製算法,老年代採用標記-整理算法

優點:簡單高效,運行在Client模式下的虛擬機是個很好的選擇。

2.ParNew收集器

ParNew收集器其實就是Serial收集器的多線程版本,除了使用多條線程進行垃圾收集之外,其餘行爲包括Serial收集器可用的所有控制參數、收集算法、Stop The World 、對象分配規則、回收策略等都與Serial收集器完全一樣。

3.Parallel Scavenge收集器

Parallel Scavenge收集器也是一個新生代收集器,它也是使用複製算法的收集器,又是並行的多線程收集器。它的目標是達到一個可控制的吞吐量(Throughput,吞吐量=運行用戶代碼時間/(運行代碼時間+垃圾收集時間))

4.Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同樣是一個單線程收集器,使用“標記-整理”算法。

5.Parallel Old 收集器

Parallel Old是Parallel Scavenge 收集器的老年代版本,使用多線程和“標記-整理”算法。

6.CMS收集器

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間爲目標的收集器。基於“標記-清除”算法實現的。收集過程分爲四個步驟,包括:初始標記、併發標記、重新標記、併發清除。

優點:併發收集、低停頓。

缺點:對CPU資源非常敏感。無法處理浮動垃圾,收集結束時會產生大量空間碎片。

7. G1收集器

G1(Garbage First) 收集器是當前收集器技術發展的最前沿成果,它與前面的CMS收集器相比有兩個顯著的改進:一是G1收集器是基於“標記-整理”算法實現的收集器。二是它可以非常精確地控制停頓,既能讓使用者明確指定在一個長度M毫秒的時間片內,消耗在垃圾收集上的時間不得超過N毫秒,這幾乎已經是實時Java(RTSJ)的垃圾收集器的特徵了。

G1將整個Java堆(包括新生代、老年代)劃分爲多個大小固定的獨立區域(Region),並且跟蹤這些區域裏面的垃圾堆積程度,在後臺維護一個優先列表,每次根據允許的收集時間,優先回收垃圾最多的區域(這就是Garbage First名稱的由來)

五、內存分配與回收策略

Java技術體系中所提倡的自動內存管理最終可以歸結爲自動化地解決了兩個問題:給對象分配內存以及回收分配給對象的內存。

1.對象優先在Eden分配

2.大對象直接進入老年代

所謂大對象就是指,需要大量連續內存空間的Java對象,最典型的大對象就是那種很長的字符串及數組。

3.長期存活的對象將進入老年代。虛擬機給每個對象定義了一個對象年齡(Age)計數器。如果對象在Eden出生並經過第一次Minor GC後仍存活,並且能被Surivor容納的話,將被移動到Survivor空間中,並將對象年齡設爲1.對象在Survivor區中每熬過一次Minor GC,年齡就增加一歲,當它的年齡到一定程度(默認爲15歲)時,就會被晉升到老年代中。對象晉升老年代的年齡,可以通過參數-XX:MaxTenuringThreshold設置。

4.動態對象年齡判定。爲了更多地適應不同程度的內存狀態,虛擬機並不總是要求對象的年齡必須達到MaxTenuringThreshold才能晉升老年代,如果在Survivor空間中相同年齡所有對象大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的對象就可以直接進入老年代,無須等到MaxTenuringThreshold。

5.空間分配擔保。在發生Minor GC(指新生代GC動作),虛擬機會檢測之前每次晉升到老年代的平均水平是否大於老年代的剩餘空間大小,如果大於,則改爲直接進行一次Full GC。如果小於,則查看HandlePromotionFailure設置是否允許擔保失敗;如果成功,那麼只會進行進行Minor GC;如果不循序,則也要改爲進行一次Full GC。

前面提高過,新生代使用收集算法,但爲了內存利用率,只使用其中一個Survivor空間來作爲輪換備份,因此當出現大量對象在Minor GC後仍然存活的情況時,就需要老年代進行分配擔保,讓Survivor無法容納的對象直接進入老年代。與生活中的貸款擔保類似,老年代要進行這樣的擔保,前提是老年代本身還有容納這些對象的剩餘空間,一共有多少對象會活下來,在實際完成內存回收之前是無法明確知道的,所以只好取之前每一次回收晉升到老年代對象容量的平均大小值作爲經驗值,與老年代的剩餘空間進行比較,決定是否進行Full GC來讓老年代騰出更多空間。

 

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