1. 程式人生 > >虛擬機器學習之二:垃圾收集器和記憶體分配策略

虛擬機器學習之二:垃圾收集器和記憶體分配策略

1.物件是否可回收

1.1引用計數演算法

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

客觀來說,引用計數演算法的實現簡單,判定效率高,在大部分情況下都是不錯的演算法,但是在主流的java虛擬機器裡面都沒有選用該演算法進行記憶體管理,主要原因是它很難解決物件之間相互迴圈引用的情況。如下面程式碼例子:

配置:輸出垃圾回收日誌

-XX:+PrintGC

 

程式碼: 

public class ReferenceCountingGC {

	private ReferenceCountingGC instance = null;

	private static final int _1M = 1024 * 1024;

	private byte[] bsize = new byte[2 * _1M];

	public static void testGC() {
		ReferenceCountingGC rc1 = new ReferenceCountingGC();
		ReferenceCountingGC rc2 = new ReferenceCountingGC();
		
		//兩個物件互相引用
		rc1.instance = rc2;
		rc2.instance = rc1;
		
		rc1 = null;
		rc2 = null;
		//提醒虛擬機器執行垃圾回收
		System.gc();
	}
	
	public static void main(String[] args) {
		testGC();
	}

}

執行結果:

[GC (System.gc())  6092K->736K(125952K), 0.0009621 secs]
[Full GC (System.gc())  736K->612K(125952K), 0.0068694 secs]

 從執行結果中可以清楚看到,GC日誌中包含6092K->736K,意味著虛擬機器並沒有因為這兩個物件相互引用就不回收它們,這也從側面說明虛擬機器並不是通過引用計數演算法來判斷物件是否或者。(配置-XX:+PrintGC或者-verbose:gc輸出基本回收資訊,配置-XX:+PrintGCDetails可以輸出詳細的GC資訊)。

1.2可達性分析演算法

在虛擬機器的主流實現中,都是通過可達性分析演算法來判定物件是否存活的。這個演算法的基本思路就是:通過一系列的稱為“GC Roots”的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈,當一個物件到GC Roots沒有任何引用鏈相連(也就是從GC Roots到這個物件不可達)時,則證明此物件是不可用的。

在java語言中可以作為“GC Roots”的物件包括以下幾種:

  • 虛擬機器棧中引用的物件,也就是棧幀中本地變量表中的物件。
  • 方法區中靜態屬性引用的物件。
  • 方法區中常量引用的物件。
  • 本地方法棧中JNI(一般說的是Native方法)引用的物件。

如下面圖中所示:雖然obj5、obj6、obj7之間相互引用但是它們到GC Roots沒有可達的呼叫鏈,所以他們將會被判定為可回收的物件。

1.3物件引用類別

在JDK1.2之前定義引用:如果reference型別的資料中儲存的數值代表另一塊記憶體的起始地址,就稱這塊記憶體代表著一個引用。這種定義雖然比較純粹但是太過狹隘,我們實際中更希望能代表一種情況:當記憶體空間足夠時,則保留在記憶體之中,當記憶體空間在進行垃圾回收之後依然比較緊張,則可以拋棄這些物件。所以在JDK1.2之後java就對引用概念進行了擴充,將引用分為強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)、虛引用(Phantom Reference)。這四種引用強度依次減弱。

  • 強引用:這種引用在程式碼中普遍存在,類似“Object obj = new Object()”這類的引用只要引用還在,垃圾收集器永遠不會回收掉被引用的物件。
  • 軟引用:這種引用用來描述一些“還有用但並非必須”的物件,對於軟引用關聯的物件,在系統將要發生記憶體溢位之前,會將這些物件列入回收物件之中進行二次回收,如果回收之後依然沒有足夠的記憶體,才會丟擲記憶體溢位異常。
  • 弱引用:是用來描述非必須物件的,但它的強度比軟引用更弱一些,被若引用關聯的物件只能生存到下一次垃圾回收之前,當垃圾收集器工作時,無論當前記憶體是否足夠都會回收掉被弱引用關聯的物件。
  • 虛引用:也被成為幽靈引用或者幻影引用,它是最弱的一種引用關係。一個物件是否有虛引用存在完全不會對其生存時間產生影響,也不能通過虛引用取得一個物件例項。為物件設定虛引用的唯一目的就是能夠在物件被回收時收到一個通知。

1.4finalize方法

即使在可達性分析演算法中不可達的物件,也並非是“非死不可”的,這時候他們暫時處於“緩刑”階段,要真正宣佈一個物件死亡,至少要經理兩次標記過程:如果物件在進行可達性分析後發現沒有與GC Roots相連線的呼叫鏈,那麼這個物件將會被進行第一次標記並且進行一次篩選,篩選的條件就是是否有必要執行finalize方法,如果沒有覆蓋該方法或者已經被虛擬機器呼叫過,就會被認為“沒有必要執行”。如果被判定為有必要執行finalize方法,就會將物件放置在一個叫做“F-Queue”的佇列中,並由一個虛擬機器自建的優先順序低的執行緒去執行它(虛擬機器只是觸發呼叫,並不保證執行成功或完成)。如果在執行finalize方法的過程中物件重新與引用鏈上的任何一個物件建立關聯則在稍後的第二次標記中該物件就會被移除“即將回收佇列”,如果這時候依然沒有和引用鏈上的物件建立關聯,則該物件就會被回收。虛擬機器呼叫物件finalize方法只有一次,不會進行第二次呼叫。如下程式碼例項:

程式碼:

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 Exception {
		SAVE_HOOK = new FinalizeEscapeGC();
		//物件第一次拯救自己
		SAVE_HOOK = null;
		System.gc();
		//因為虛擬機器呼叫finalize方法優先順序比較低,暫停1s等待執行。
		Thread.sleep(1000);
		if(SAVE_HOOK != null){
			SAVE_HOOK.isAlive();
		}else{
			System.out.println("I am dead!");
		}
		
		//第二次時不會再呼叫finalize方法
		SAVE_HOOK = null;
		System.gc();
		Thread.sleep(1000);
		if(SAVE_HOOK != null){
			SAVE_HOOK.isAlive();
		}else{
			System.out.println("I am dead!");
		}
		
		
	}
}

執行結果:

finalize method executed!
yes,I am still alive!
I am dead!

可以看到回收的第一次執行了finalize方法然後物件沒有被回收,第二次時沒有呼叫finalize方法,物件被回收掉了。

這種方法雖然能在物件被回收時自救一次,但在編寫程式碼時不建議使用此種操作。

1.5回收方法區(JDK8中是回收元空間)

按照JDK7介紹,永久代中的垃圾收集主要回收兩部分內容:廢棄常量和無用的類。

廢棄常量:廢棄常量的回收和java堆中物件的回收非常類似。以常量池為例,如果一個字串“abc”已經進入常量池中,但是當前系統中沒有任何一個String字串物件叫做“abc”,也就是沒有任何一個物件引用這個“abc”常量,也沒有任何一個地方引用這個字面量。這個時候發生記憶體回收,有必要的話常量池中的“abc”常量會被系統清理出常量池。

無用的類:類的回收判定比較嚴格要滿足一下三個條件才可以會被回收。

  • 該類所有的例項都已經被回收。
  • 載入該類的ClassLoader也已經被回收。
  • 該類對應的class物件也沒有在任何地方被引用,也就是不能通過反射訪問該類。

2.垃圾收集演算法

2.1標記-清除演算法

標記-清除演算法:最基礎的收集演算法,主要分為“標記”和“清除”兩個階段完成,首先標記出所有需要回收的物件,在標記完成之後進行統一回收。之所以說它時最基礎的收集演算法是因為後續的收集演算法都是基於這種思路進行對其不足進行改進而得到的。

這種演算法有兩種不足:第一個就是這兩個階段的效率都不高;第二個是在標記清除之後會產生大量的不連續的記憶體碎片,空間碎片太多可能會導致在後面程式執行過程中如果分配較大物件時,無法找到足夠的連續記憶體空間,而不得不提前進行下一次垃圾回收。

2.2複製演算法

複製演算法:將可用記憶體分為大小相等的兩部分,每次只使用其中的一塊,當這一塊記憶體用完,就進行垃圾回收將還存活的物件複製到另一塊上面,然後清除掉剛才使用的記憶體空間。這樣做的好處就是每次對整個板塊記憶體進行回收,不用考慮記憶體碎片等複雜問題。但是這種演算法的代價就是講記憶體可用空間直接縮小了一半。

在商業虛擬機器中都使用這種演算法來回收新生代。IBM研究表明大部分情況新生代中有98%的物件會被第一次收集時被回收掉,所以在實現中把記憶體分為較大的一塊Eden空間和兩個較小的Survivor空間,比例是8:1:1.每次使用時將新建立的物件分配到Eden區其中一個Surivivor區儲存上次回收存活下來的物件,當進行垃圾收集時將Eden和使用中的Survivor中的存活物件複製到另一個Survivor中,然後清空Eden和使用過的Survivor空間,依次迴圈使用。當然並不是每次存活的物件都不足10%,當存活物件大於10%時Surivivor中的空間就不夠使用,就需要依賴其他記憶體進行分配擔保(老年代)。也就是當另一塊Surivivor記憶體不夠時就會將存活的物件分配到老年代中。

2.3標記-整理演算法

複製演算法在物件存活率較高時就要進行較多的複製操作,從而降低效率。還要預留擔保空間,以應對存活物件較多時新生代記憶體不夠分配的情況。所以在老年代提出了“標記-整理”演算法,標記同樣跟前面的“標記-清除”演算法中標記操作一樣,但是標記之後不會將物件清除掉,而是將物件移動到整塊記憶體空間的一端,然後直接清理掉邊界以外的記憶體。

2.4分代收集演算法

當前商業虛擬機器都採用“分代收集演算法”,這種演算法只是將整塊記憶體按照物件存活週期分為幾個塊,一般把java堆分為新生代、老年代。這樣就可以根據各個年代特點使用不同演算法進行收集。例如在新生代每次回收時都有少量物件可以存活,就是用複製演算法,將少量存活物件複製到Survivor區。而老年代物件存活率比較高只有少量物件會被清除掉,就選用“標記-清除”或者“標記-整理”演算法。

3.HotSpot演算法實現

3.1列舉根節點

在可達性分析演算法中可以作為GC Roots節點的主要在全域性性引用(例如常量、靜態屬性)或者執行上下文(棧中本地變量表)中。在查詢呼叫鏈的時候並不會這個檢查這裡面的引用,因為這樣會消耗很多時間。

HotSopt實現中,使用一組稱為OopMap的資料結構,在類載入完成的時候就已經計算出來物件“哪些”偏移量上面儲存“哪些”資料型別。例如:在JIT編譯過程中會在特定位置記錄棧和暫存器中哪些位置是引用。這樣在GC掃描的時候可以直接引用。

另外在執行GC 的時候所有java執行緒都必須停頓下來(Stop The World),因為在執行可達性分析演算法的時候物件的引用關係不能發生變化。

3.2安全點

上面提到記錄引用的特定位置稱為“安全點”,執行緒在GC的時候需要暫停執行,但並不是在任何地方都可以停下來的,需要執行緒跑到“安全點”上時如果這時候有GC標識就暫停執行,這樣可以保證在GC時引用不會發生變化。

3.3安全區域

安全區域:指在一段程式碼片段之中,引用關係不會發生變化。這個區域中的任何位置開始GC 都是安全的。

4.垃圾收集器

4.1Serial收集器

新生代收集器,複製演算法。

Serial收集器是最基本、發展歷史最悠久的收集器。這個收集器是一個單執行緒的收集器,它有一條專門的執行緒負責垃圾收集工作,更重要的是它在垃圾回收的時候要停止所有其他執行緒。主要用在Client模式下(使用者的桌面場景中)。

4.2ParNew收集器

新生代收集器,複製演算法。

ParNew收集器是Serial收集器的多執行緒版本。除了使用多執行緒進行垃圾收集之外其他的所有包括控制引數、收集演算法、Stop The World、物件分配規則、回收策略等都與Serial完全一樣。

4.3Paralle Scavenge收集器

Parallel Scavenge收集器是一個新生代收集器,也是使用複製演算法的收集器,有事並行的多執行緒收集器。

Paralle Scavenge收集器的特點就是關注吞吐量:執行使用者程式碼時間 / CPU總執行時間(使用者程式碼時間+垃圾收集時間)。

Paralle Scavenge收集器提供了可以配置精準控制吞吐量的引數。所以又稱為“吞吐量優先”收集器。

4.4Serial Old收集器

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

主要給client模式下的虛擬機器使用。

4.5Parallel Old收集器

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多執行緒和“標記-整理”演算法。和Paralle Scavenge收集器搭配實現名副其實的“吞吐量優先”收集器。

4.6CMS收集器

CMS收集器(Concurrent Mark Sweep)是一種以獲取最短回收停頓時間為目標的收集器。CMS收集器主要分四個步驟:

  • 初始標記
  • 併發標記
  • 重新標記
  • 併發清除

初始標記、重新標記:這兩個步驟雖然很快但是還是需要“Stop The World”。

併發標記:進行GC Roots tracing,在這個階段jvm收集執行緒會和使用者執行緒並行執行。(時間較長,降低使用者系統資訊)。

缺點:

  • 佔用使用者CPU資源,4核以上伺服器至少佔用1/4CPU資源。
  • 產生“浮動垃圾”由於CMS收集器和使用者執行緒併發執行,在收集過程中使用者執行緒可能產生新的垃圾物件。
  • 標記清除演算法產生碎片記憶體空間,多次執行標記清除回收之後要進行一次記憶體壓縮。

4.7G1收集器

G1收集器是當今收集技術最前沿成果之一。

特點:

  • 並行和併發,縮短“Stop The World”時間,讓使用者執行緒和收集執行緒併發執行。
  • 分代收集
  • 空間整合,不會產生記憶體碎片。
  • 可預測停頓時間,可以讓使用者明確指定在一個長度為M毫米的時間片段內,消耗在垃圾收集上的時間不得超過N毫秒。

G1之前的收集器收集範圍都是整個新生代或者老年代。而G1收集器將整個java堆劃分為多個大小相等的獨立區域(Region),雖然還保留著新生代和老年代,但新生代和老年代不再是物理隔離的了,他們都是有一部分Region的集合組成。G1維護一個優先列表記錄每個Region回收的價值大小,每次根據允許收集時間,首先回收價值最大的Region。

4.8理解GC日誌

[GC (System.gc()) [PSYoungGen: 1331K->32K(38400K)] 1943K->644K(125952K), 0.0004471 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 32K->0K(38400K)] [ParOldGen: 612K->611K(87552K)] 644K->611K(125952K), [Metaspace: 2662K->2662K(1056768K)], 0.0076396 secs] [Times: user=0.03 sys=0.00, real=0.01 secs] 

[GC 和[Full GC 代表收集停頓來下,Full表示有停頓及“Stop The World”。

[PSYongGen 和 [ParOldGen、[Metaspace表示GC發生的區域,[PSYongGen新生代;[ParOldGen老年代;[Metaspace元空間。

區域後面“[ ]”之內的32K->0K(38400K) 表示:該區域回收之前佔用容量->回收之後佔用容量(該區域總容量)。

"[ ]"之外的644K->611K(125952K)表示:java堆GC之前的佔用容量->GC之後佔用容量(java堆總用量)。

5記憶體分配與回收策略

5.1物件優先在Eden分配

通過例子講解:首先建立4個數組物件allocation1、allocation2、allocation3、allocation4,佔用空間分別為2M、2M、2M、4M,然後指定虛擬機器堆記憶體20M,新生代記憶體10M,新生代中Eden區域Survivor區域佔比為8:1。

public class EdenTest {

	private static final int _1MB = 1024 * 1024;

	private static void testAllocation() {
		byte[] allocation1, allocation2, allocation3, allocation4;
		allocation1 = new byte[2 * _1MB];
		allocation2 = new byte[2 * _1MB];
		allocation3 = new byte[2 * _1MB];
		allocation4 = new byte[4 * _1MB];
	}

	public static void main(String[] args) {
		testAllocation();
	}

}

使用Serial+Serial Old收集器組合進行記憶體回收(UserSerialGC配置指定收集器)

-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+PrintGCDetails -XX:+UseSerialGC

執行日誌:

[GC (Allocation Failure) [DefNew: 7292K->613K(9216K), 0.0050167 secs] 7292K->6757K(19456K), 0.0050693 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
Heap
 def new generation   total 9216K, used 4791K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  51% used [0x00000000fec00000, 0x00000000ff014930, 0x00000000ff400000)
  from space 1024K,  59% used [0x00000000ff500000, 0x00000000ff599460, 0x00000000ff600000)
  to   space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
 tenured generation   total 10240K, used 6144K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,  60% used [0x00000000ff600000, 0x00000000ffc00030, 0x00000000ffc00200, 0x0000000100000000)
 Metaspace       used 2668K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 288K, capacity 386K, committed 512K, reserved 1048576K

日誌解讀

GC (Allocation Failure):表示向young generation(eden)給新物件申請空間,但是young generation(eden)剩餘的合適空間不夠所需的大小導致的minor gc。

DefNew:表示新生代使用Serial序列GC垃圾收集器,defNew提供新生代空間資訊。

7292K->613K(9216K):新生代佔用記憶體7292K -> 收集器回收之後佔用記憶體613K(新生代可用記憶體9216K)。

7292K->6757K(19456K):java堆被佔用記憶體7292K -> 收集器回收之後佔用記憶體6757K (堆記憶體總空間19456K)。

堆記憶體的使用分配情況看