深入學習Java虛擬機器——垃圾收集器與記憶體分配策略

江左煤郎發表於2018-08-27

垃圾回收操作的步驟:首先確定物件是否死亡,然後進行回收

1. 如何判斷物件是否死亡

1.1 引用計數法

    1.引用計數法:給物件新增一個引用計數器,每當有一個地方引用它時,計數器值就加1,當引用失效時就減1,任何時刻,計數器為0的物件是不可能在被使用的。

    2.優缺點:優點是實現簡單,判定效率高;缺點是很難解決物件間相互迴圈引用的問題,所以如今的主流Java虛擬機器都沒使用該方法進行管理記憶體。比如以下程式碼

/**
 * 
 * @ClassName:ReferenceCountGC
 * @Description:引用計數法無法解決的物件間互相迴圈引用的問題
 * @author: 
 * @date:2018年7月29日
 */
public class ReferenceCountGC {
	public Object obj;
	public static void main(String[] args) {
		ReferenceCountGC a=new ReferenceCountGC();
		ReferenceCountGC b=new ReferenceCountGC();
		a.obj=b;
		b.obj=a;
		a=null;
		b=null;
		
		//假設此處進行GC,若虛擬機器採用引用計數法,則無法回收a,b兩個物件
		System.gc();
	}
}

1.2 可達性分析演算法(根追蹤演算法)

    1. 可達性分析演算法:通過一系列的“GC Roots”的物件為起點,從這些節點開始往下搜尋,搜尋所走過的路徑稱為引用鏈,當一個物件到GC Roots沒有任何引用鏈相連,則證明該物件時不可用的。以下圖為例,

物件obj1,2,3,4到GC Roots都是可達的,所以這四個物件都是不可回收的,而obj5,6,7雖然有引用關係,但無法到達GC Roots,所以他們將會被判定為可回收物件。

    2. 可作為GC Roots的物件包括以下幾種:

(1)虛擬機器棧中的引用的物件

(2)方法區中靜態屬性引用的物件

(3)方法區中常量引用的物件

(4)本地方法棧引用的物件

1.3 再談引用

    1. 引用分類:

(1)強引用:類似於 Object obj=new Object() 這類的引用,只要強引用還存在,垃圾收集器永遠不會回收該類引用的物件。

(2)軟引用:用來描述一些好有用但並非必需的物件,在系統將要發生記憶體溢位異常之前,會把這些物件進行回收,如果這次回收之後還沒有足夠的記憶體就會發生記憶體溢位異常。JDK提供了SoftReference類來實現軟引用。

(3)弱引用:用來描述非必需物件,但它的強度比弱引用更弱一些,被弱引用關聯的物件只能生存到下一次垃圾收集發生之前,無論記憶體是否足夠,都會回收掉只被弱引用關聯的物件。JDK使用WeakReference類來實現弱引用。

(4)虛引用:他是最弱的一種引用關係,一個物件是否有虛引用的存在,完全不會對其生存時間產生影響,也無法通過一個虛引用來取得一個物件例項,一個物件設定虛引用關聯的唯一目的就是能在這個物件被收集器回收時收到一個系統通知。Jdk中使用PhantomReference類來實現虛引用。

1.4 物件是否死亡

    1. 即使在可達性演算法分析中不可達的物件,也並不是直接被判定為死亡,而是進行一次標記,要真正確定一個物件是否死亡,至少需要兩次標記過程:如果物件在進行可達性分析後發現沒有與GC Roots相連線的引用鏈那麼他將會被第一次標記並進行第一次篩選,篩選的條件是該物件是否有必要執行finalize()方法,物件沒有覆蓋finalize()方法或finalize()方法已經被呼叫過,則都沒有必要執行;如果被判定為有必要執行,則該物件會被放置在一個佇列中,並在稍後由一個由虛擬機器自動建立的、低優先順序的finalizer執行緒去執行該佇列中所有物件的finalize()方法,虛擬機器會執行該物件的finalize()方法,但不會保證等待它執行結束,因為如果執行物件的finalize()方法時非常緩慢或發生死迴圈就有可能導致該佇列中的其他物件處於等待中,甚至導致虛擬機器崩潰。finalize()方法的執行是物件逃脫死亡的最後一次機會,在執行finalize()方法後,GC將對佇列中的物件進行第二次小規模標記。如果在finalize()方法執行時重新建立與引用鏈上的任意一個物件建立關聯即可避免回收,否則如果在此次finalize()執行後仍沒有逃脫標記佇列,那麼基本就會被回收。物件自我拯救例項程式碼如下

public class FinalizeEscapeGC {
	public static FinalizeEscapeGC SAVE_HOOK=null;
	public void isAlive(){
		System.out.println("Object is alive");
	}
	//重寫finalize()方法,第一次標記時使虛擬機器判定該物件需要執行finalize()方法
	@Override
	protected void finalize() throws Throwable {
		// TODO Auto-generated method stub
		super.finalize();
		System.out.println("finalize() excute");
		FinalizeEscapeGC.SAVE_HOOK=this;//此行程式碼對該物件進行的拯救
	}
	public static void main(String[] args) throws InterruptedException {
		SAVE_HOOK=new FinalizeEscapeGC();
		
		//初次拯救:成功
		SAVE_HOOK=null;
		System.gc();
		Thread.sleep(1000);
		if(SAVE_HOOK!=null){
			SAVE_HOOK.isAlive();
		}else{
			System.out.println("Object is dead");
		}
		
		//第二次拯救:失敗
		SAVE_HOOK=null;
		System.gc();
		Thread.sleep(1000);
		if(SAVE_HOOK!=null){
			SAVE_HOOK.isAlive();
		}else{
			System.out.println("Object is dead");
		}
	}
}

執行結果:
finalize() excute
Object is alive
Object is dead

注意:每個物件的finalize() 方法只會被自動呼叫一次,在下一次回收時不會被執行,所以在上述程式碼中第一次拯救成功,第二次拯救失敗。對於finalize()這個方法不推薦使用,或者說禁止使用,更推薦使用try-finally或者其他方式。

1.5 回收方法區

    1. 方法區(在某些虛擬機器中被稱之為永久代)的垃圾收集主要回收兩部分內容:廢棄常量與無用的類。

(1)回收廢棄常量與回收堆中的普通物件類似,以常量池中字面量的回收為例,假如一個字串“abc”進入了常量池,但當前系統中沒有任何一個String物件是叫做“abc”的,換句話說,也就是沒有任何String物件引用常量池中的“abc”常量,也沒有其他地方引用這個字面量,如果此時發生記憶體回收,而且有必要的話,這個“abc”就會被清理出常量池。常量池中其他的類(介面),方法,欄位的符合引用也與此類似。

擴充套件:關於String的建立

(1)String str = “abc”建立物件的過程

1 首先在常量池中查詢是否存在內容為”abc”字串物件

2 如果不存在則在常量池中建立”abc”,並讓str引用該物件

3 如果存在則直接讓str引用該物件

至 於”abc”是怎麼儲存,儲存在哪?常量池屬於類資訊的一部分,而類資訊反映到JVM記憶體模型中是對應存在於JVM記憶體模型的方法區,也就是說這個類資訊 中的常量池概念是存在於在方法區中,而方法區是在JVM記憶體模型中的堆中由JVM來分配的,所以”abc”可以說存在於堆中。一般這種情況下,”abc”在編譯時就被寫入位元組碼中,所以class被載入時,JVM就為”abc”在常量池中 分配記憶體,所以和靜態區差不多。

(2)String str = new String(“abc”)建立例項的過程

1 首先在堆中(不是常量池)建立一個指定的物件”abc”,並讓str引用指向該物件

2 在字串常量池中檢視,是否存在內容為”abc”字串物件

3 若存在,則將new出來的字串物件與字串常量池中的物件聯絡起來

4 若不存在,則在字串常量池中建立一個內容為”abc”的字串物件,並將堆中的物件與之聯絡起來

(3)String str1 = “abc”; String str2 = “ab” + “c”; str1==str2是ture

是因為String str2 = “ab” + “c”會查詢常量池中時候存在內容為”abc”字串物件,如存在則直接讓str2引用該物件,顯然String str1 = “abc”的時候,上面說了,會在常量池中建立”abc”物件,所以str1引用該物件,str2也引用該物件,所以str1==str2

(4)String str1 = “abc”; String str2 = “ab”; String str3 = str2 + “c”; str1==str3是false

是因為String str3 = str2 + “c”涉及到變數(不全是常量)的相加,所以會生成新的物件,其內部實現是先new一個StringBuilder,然後 append(str2),append(“c”);然後讓str3引用toString()返回的物件

(2)回收無用類:類需要滿足3個條件才能算是無用類,

  1. 該類的所有的例項都已經被回收,也就是說堆中不存在該類以及其子類的任何物件
  2. 載入該類的ClassLoader已經被回收
  3. 該類對應的java.lang.Class物件沒有在任何地方被引用,沒有在任何地方通過反射來訪問該類

滿足以上三個條件就是無用類,此時虛擬機器可以對其進行回收,但不是必須回收。在大量使用反射、動態代理、CGLib等頻繁定義ClassLoader的場景都需要虛擬機器具備類解除安裝功能,保證永久代不會溢位。

2. 物件的回收——垃圾收集演算法

2.1 標記-清除演算法(Mark-Sweep)

    1. 演算法思想:分為兩個階段,標記和清除;首先對要進行回收的物件進行標記,然後清除。

    2. 缺點:

(1)效率低,無論是標記過程還是清除過程效率都很低。

(2)浪費記憶體空間,標記清除後會造成大量的不連續的記憶體碎片,導致無法為後續分配較大記憶體的物件時無法分配,從而引起又一次的垃圾清理動作。

2.2 複製演算法(Copying)

    1. 演算法思想:將記憶體分為大小相等的兩塊,每次只使用其中一塊。當正在使用的這塊記憶體即將用完時,就將所有存貨的物件複製到另一塊記憶體中,然後將使用過的上一塊記憶體全部清空。

    2. 優缺點:優點是效率相較於標記-清除演算法較高,也不會存在大量記憶體碎片的情況,只需移動堆頂指標,順序分配記憶體即可,實現簡單。

缺點是對記憶體空間消耗大,可使用記憶體僅為原來的一半,記憶體代價高。

    3. 應用:商業虛擬機器大多選用該演算法對堆中的新生代中的物件進行回收。對於新生代區域中的物件,幾乎98%都是“朝生夕死”的,所以不需要按照1比1劃分記憶體空間,而是將新生代的記憶體空間劃分為較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor。當回收時,將Eden區和Survivor區存活的物件一次性複製到另一塊Survivor空間上,最後清理掉Eden和剛才用過的Survivor空間。HotSpot虛擬機器預設的Eden和Survivor區的大小比例時8:1,也就是說新生代中可用記憶體空間為整個新生代的90%。如果另一塊Survivor空間沒有足夠的記憶體空間存放上一次垃圾回收從新生代中存活的物件時,這些物件將直接通過分配擔保機制進入老年代。

2.3 標記-整理演算法(Mark-Compact)

對於新生代所採用的的複製演算法,在物件存活率較高時因為需要大量的複製操作而導致效率變低,並且還需要額外的空間進行分配擔保避免浪費50%的新生代記憶體空間。而為了應對老年代區域存活率較高的特點,甚至是被使用記憶體中所有物件都全部存活的極端情況,所以不採用此演算法。

    1. 演算法思想:首先是標記所有的可回收物件,然後將所有的存活的物件向同一端移動,保證所有的存活物件所佔記憶體空間都是連續的時候,直接清理邊界以外的記憶體。

2.4 分代收集演算法

    1. 演算法思想:也就是依據物件的存活週期將記憶體分為幾塊。一般是吧Java堆分為新生代、老年代和永久代,永久代不做討論。對於新生代和老年代分別採用不同的收集演算法,以此保證垃圾收集的高效性。比如新生代中每次垃圾收集時都會有大量的物件死去,只有少量物件存活,那麼就用複製演算法。而老年代中物件存活率高而且沒有額外空間對它進行分配擔保,所以就必須使用標記-整理或標記-清除演算法。

3.  垃圾收集器——對GC相關演算法的實現

3.1 可達性分析演算法中列舉根節點

    1. 在可達性分析演算法中需要從GC Roots節點找引用鏈,而可以作為GC Roots的節點主要是在全域性性的引用(常量,靜態屬性等)以及執行上下文(棧楨中的本地變數表)中。但如果有方法區達到幾百兆記憶體,此時在逐個檢查引用,那麼將會消耗大量時間。

    另外,GC耗時的另一個體現為GC停頓,在GC工作正在進行時,Java虛擬機器必須終止其他所有的Java執行執行緒,隨著堆的擴大這個暫停時間也會越久,因為可達性分析工作必須在一個能確保一致性的快照中進行,“一致性”指的是在整個分析期間不可以出現物件引用關係處於不斷變化的情況。所以對於 System.gc()方法時禁止在程式中使用的,因為顯式宣告是做堆記憶體全掃描,也就是 Full GC,是需要停止所有的活動的,也就是上面所說的終止其他所有執行緒,對於程式是無法接受的。

    2. HotSpot虛擬機器使用一組稱為OopMap的資料結構來直接得知哪些地方存放物件引用,在類載入完成的時候,HotSpot就把物件內什麼偏移量上是什麼型別的資料計算出來,在JIT編譯過程中,也會在特定位置記錄下棧和暫存器中哪些位置是引用。

3.2 垃圾收集器

    1. 收集演算法是記憶體回收的方法論,而垃圾收集器就是記憶體回收的具體實現。對於不同的虛擬機器可能會有不同的收集器實現,對於HotSpot虛擬機器,其總共有6種垃圾收集器用於不同分代的垃圾收集。如下圖所示,兩個收集器之間的連線表示可以搭配使用,所處區域表示用於老年代或新生代。

    沒有最好的收集器,也沒有萬能的收集器,只有最合適的收集器。

並行垃圾收集器:指多條垃圾收集執行緒並行工作,但使用者執行緒處於等待狀態

併發垃圾收集器:指使用者執行緒與垃圾收集執行緒併發執行或並行執行,使用者程式繼續執行,而垃圾收集執行緒執行在另一個CPU上

    2. Serial收集器:單執行緒收集器,也就是說當它在進行垃圾收集時必須暫停其他所有執行緒,直到該收集器的執行緒執行結束,該動作由虛擬機器後臺自動執行,使用者不可見。

(1)採用演算法:新生代使用複製演算法,老年代使用標記-整理演算法。

(2)優缺點:在單執行緒情況下(也就是隻有垃圾收集器執行緒執行),該收集器具有簡單高效的特點,但問題就是GC時導致所有應用程式的暫停,所以在後來的收集器就出現了併發收集器,使暫停時間儘量縮短,但無法完全消除。

    3. ParNew收集器:Serial收集器的並行版,可以使用多條執行緒收集垃圾,其餘行為與Serial收集器完全相同,比如GC暫停,控制引數設定,應用的收集演算法,物件分配策略和回收策略等。只有此收集器可以與CMS收集器配合工作。

(1)優缺點:與Serial收集器相比,其最大的優點就是可以使用多條執行緒進行垃圾回收,在單執行緒中其效率不會比Serial收集器更好,由於執行緒互動的開銷,在CPU較少的情況下,都無法保證可以超越erial收集器。但是隨著CPU數量的增加,其效率肯定要更好。缺點與Serial收集器相同,會發生GC時暫停現象。

    4. Parallel Scavenge收集器:專用於新生代的收集器,使用複製演算法,並且是並行的多執行緒收集器,用於達到一個可控制的  使用者程式碼執行時間/(垃圾收集時間+使用者程式執行時間),即吞吐量。虛擬機器會依據當前系統執行狀態自動調整Parallel Scavenge收集器的控制引數,比如停頓時間或最大的吞吐量,這種調節被稱為GC自適應調節策略,而該策略也是Parallel Scavenge收集器與PreNew收集器的區別。

    5. Serial Old收集器:該收集器是Serial收集器的老年代版本,即針對老年代進行收集。同樣為單執行緒收集器,使用標記-整理演算法。可以與Parallel Scavenge收集器搭配使用或作為CMS的後備方案。

    6. Parallel Old收集器:Parallel Scavenge收集器的老年代版本,使用標記-整理演算法,並行收集器。

    7. CMS收集器:該收集器以獲取最短停頓時間為目標的收集器,是併發收集器,使用標記-清除演算法,分為4個步驟,初始標記,併發標記,重新標記,併發清除,其中初始標記和重新標記仍會發生GC暫停。初始標記是進行標記GC Roots直接關聯的物件,併發標記是進行GC Roots根追蹤,重新標記是修正併發標記期間使用者程式繼續執行而導致的標記變動的物件的標記記錄。

(1)優缺點:

缺點有

  • CMS對CPU資源非常敏感
  • 無法處理浮動垃圾導致可能出現“Concurrent Model Failure”失敗而導致另一次Full GC的發生。由於使用者程式的不斷執行,那麼就會有垃圾產生,但如果部分垃圾出現在標記之後就會導致CMS無法處理,只有在下一次GC時進行處理,這一部分垃圾就被稱為浮動垃圾。
  • 標記-清除演算法導致的記憶體空間碎片化。

優點是響應速度快,系統停頓時間短。

    8. 理解GC日誌:

    最前方的數字(比如  “ 88.11:”)表示GC活動發生的時間,即從虛擬機器啟動以來經過得秒數;

    緊跟的“GC”或“Full GC”表示此處垃圾回收的停頓型別,“Full GC”表示會暫停其他所有使用者程式的執行緒活動,而使用者程式中顯示呼叫System.gc()方法也會導致Full GC,所以不建議呼叫該方法;

    而接下來的 [DefNew,[Tenured,[Perm分別表示在新生代,老年代或持久代進行的垃圾回收,但對於不同的收集器,對於物件分帶的名稱也可能不同;對於具體年代方括號內的如“333k->3k”表示GC前該記憶體區域使用量—>GC後該記憶體區域使用量,後再跟在這個區域GC所用時間;    

    而在方括號之外的表示GC前堆已使用空間—>GC後堆已使用空間。

4 記憶體分配與垃圾回收策略

4.1 記憶體分配

    物件的記憶體分配,絕大部分在堆上分配,主要分配與新生代的Eden區,如果啟動了T本地執行緒分配緩衝,按執行緒優先分配在TLAB上。少數情況下分配在老年代,沒有絕對確定的分配規則。其細節取決於當前使用的是哪一種垃圾收集器組合。

        1. 以下有幾種較為普遍的物件分配策略:

  • 絕大部分新物件優先在新生代中的Eden區分配,當Eden沒有足夠空間進行分配時,虛擬機器則會進行一次新生代GC。
  • 大物件直接進入老年代,大物件指大量連續記憶體空間的Java物件,比如極長的字串或陣列,在程式中更應該避免大量的”朝生夕死”的大物件。
  • 虛擬機器為每個物件定義了一個物件年齡計數器,如果物件在Eden區出生並經過第一次新生代GC後仍然存活,則物件年齡就會加1,並且該物件能被Survivor區容納的話,就將還存活的物件將被複制到 Survivor 區(兩個中的一個),當物件每熬過一次新生代GC後,年齡就會加1,當物件年齡超過15(預設,可以通過控制引數設定)時,將被複制“年老區(Tenured)”。
  • 動態物件年齡判斷,即虛擬機器並不一定要求物件年齡必須達到最大年齡才能晉升老年代,當新生代中的相同年齡的存活物件的大小總和大於Survivor區的空間的一半時(也就是說正在使用的Survivor1區或Survivor2區記憶體空間不足時),年齡大於或等於該年齡的可以直接進入老年代。
  • 空間分配擔保,在進行新生代GC之前,虛擬機器會檢查老年代最大可用連續記憶體空間是否大於新生代所有物件總空間,如果成立那麼新生代GC就是安全的。但是可能會出現新生代GC後新生代中有大量的物件存活,導致Survivor區無法容納,此時就需要老年代分配擔保,把Survivor區無法容納的物件直接進入老年代,但前提是老年代本身具有足夠的空間,而是否採用這種方式承擔風險是可以通過控制引數設定的。


相關文章