深入理解 JVM(4)垃圾收集器與內存分配策略

轉載請註明原創出處,謝謝!

HappyFeet的博客

相信大家在平時的編程中或多或少會遇到一些關於內存溢出以及內存泄漏的問題;那麼出現這些問題如何排查呢?

Java 虛擬機一直在默默地爲 Java 程序員做着垃圾收集(GC)的工作,既如此,那 Java 程序員是不是就不需要了解 JVM 的 GC 機制了呢?當然不是!當需要排查各種內存溢出、內存泄漏問題時,當垃圾收集成爲系統達到更高併發量的瓶頸時,我們就需要對 JVM 的 GC 機制實施必要的監控與調節。

接下來我們來看看 JVM 是如何 GC 的?


1、哪些內存需要回收?

前面介紹了 Java 運行時內存區域的各個部分,其中程序計數器、虛擬機棧、本地方法棧是線程私有的,隨線程而生,隨線程而滅。棧中的棧楨隨着方法的進入和退出而有條不絮地執行着出棧和入棧操作。每一個棧楨中分配多少內存基本上是在類結構確定下來時就已知的,因此這幾個區域的內存分配和回收具有確定性。當線程結束或方法結束時,內存跟着回收了。

Java 堆和方法區 則不一樣,一個接口中的多個實現需要的內存可能不一樣,一個方法的多個分支需要的內存可能也不一樣,而且我們只有在程序運行期間才能知道會創建哪些對象,這部分內存的分配和回收都是動態的,垃圾收集器關注的內存也就是這一部分內存。

2、判斷哪些對象可以回收

可以回收的對象其實就是不會再被用到的對象!

那麼如何判斷 不會再被用到的對象 呢?有兩種方法:

(1)引用計數法

引用計數法的算法很簡單:給對象添加一個引用計數器,每當有一個地方引用它時,計數器值加 1 ;當引用失效時,計數器的值減 1 ;任何時候引用計數器爲 0 的對象就是不可能再使用的。

客觀上來說,引用計數法實現簡單,效率也很高,但是有一個弊端就是:它無法處理循環引用的問題。

例如:對象 A 和 B,A 中有一個成員變量引用了 B,並且 B 中有一個成員變量引用了 A,這兩個對象形成了循環引用,導致 A 和 B 的引用計數器的值永遠是大於 0 的,這樣,即便程序中其它地方沒有使用 A 和 B 的引用,A 和 B 兩個對象也不會被回收。代碼如下(如果虛擬機採用的是引用計數法,那麼 a 和 b 這兩個對象永遠也無法回收):

public class Ref {

	private Object ins = null;
	private static final int _1MB = 1024 * 1024;
	private byte[] bigSize = new byte[2 * _1MB];
		
	public static void testGC() {
		Ref a = new Ref();
		Ref b = new Ref();
		a.ins = b;
		b.ins = a;

		a = null;
		b = null;
		
		System.gc();
	}
}

(2)可達性分析法(虛擬機的主流實現)

主要思想:通過一系列的稱爲 “GC Roots” 的對象作爲起始點,從這些節點開始向下搜索,搜索經過的路徑稱爲引用鏈,當一個對象與 “GC Roots” 沒有任何引用鏈相連(也就是從 “GC Root” 到這個對象不可達)時,則證明此對象是不可用的。

Java 中可作爲 “GC Roots” 的對象:

​ a)虛擬機棧(棧楨中的本地變量表)中引用的對象;

​ b)方法區中類靜態屬性引用的對象;

​ c)方法區中常量引用的對象;

​ d)本地方法棧中 Native 方法引用的對象。

3、垃圾收集算法

(1)標記-清除算法

先標記所有需要回收的對象,在標記完成後統一回收。

不足:標記和清除兩個過程的效率都不高;回收完會產生大量不連續的內存碎片,空間碎片太多可能會導致以後在程序運行過程中需要分配較大對象時,無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作。如圖:

垃圾-清除算法

(2)複製算法

基本思想:將內存按容量劃分爲大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活的對象複製到另一塊上面,然後再把已使用過的內存空間一次性清理掉,避免了內存碎片的問題。只不過這種算法的代價是將內存縮小爲原來的一半。如圖:

複製算法

現在的商業虛擬機都採用這種收集算法來回收新生代,IBM 公司的專門研究表明,新生代中的對象 98% 都是 “朝生夕死” 的。根據這個特點,將新生代的內存分爲一塊較大的 Eden 空間和兩塊較小的 Survivor 空間,每次使用 Eden 和其中一塊 Survivor 。當回收時,將 Eden 和 Survivor 中還存活的對象一次性複製到另外一塊 Survivor 空間上,最後清理掉 Eden 和剛纔用過的 Survivor 空間。HotSpot 虛擬機默認 Eden 和 Survivor 的大小比例是 8 : 1 ,所以只有 10% 的內存會被 “浪費” 。(實際上就是依據新生代中對象的特點對複製算法做了一個優化,減小了內存的浪費)

當然,98% 可回收只是一般情況,也有特殊情況,當新生代中存活的對象較多時(大於 Survivor 空間)需要依賴老年代進行分配擔保。

(3)標記-整理算法

複製算法對於存活率較高的情況會進行較多的複製操作,效率不高。針對存活率較高的特點,有人提出了另外一種 “標記-整理” 算法,該算法與 “標記-清除” 算法的區別在於標記之後,讓所有存活的對象都向一端移動,然後直接清理掉端邊界以外的內存。如圖:

標記-整理算法

(4)分代收集算法

分代收集就是根據對象存活週期的不同將內存劃分爲幾塊。一般是把 Java 堆分爲新生代和老年代,這樣就可以根據各個年代的特點採用最適當的收集算法。新生代中對象存活率不高,採用複製算法,只需要付出少量存活對象的複製成本就可以完成收集。而老年代中因爲對象存活率高、沒有額外空間對它進行分配擔保,就必須使用 “標記-整理” 或者 “標記-清除” 算法來進行回收。

4、內存分配策略

(1)對象優先在 Eden 分配:

大多數情況下,對象在新生代 Eden 區中分配。

(2)大對象直接進入老年代:

大對象指的是需要大量連續內存空間的 Java 對象。 Java 虛擬機提供了一個 -XX:PretenureSizeThreshold 參數,令大於這個設置值的對象直接在老年代分配。這樣做的目的是避免在 Eden 區及兩個 Survivor 區之間發生大量的內存複製。

(3)長期存活的對象將進入老年代

虛擬機給每個對象定義了一個對象年齡計數器,對象每在 Survivor 區中 “熬過” 一次 Minor GC ,年齡就增加 1 歲,當它的年齡增加到一定程度(默認爲 15 歲),就將會被晉升到老年代中。對象晉升老年代的年齡閥值,可以通過參數 -XX:MaxTenuringThreshold 設置。

5、什麼情況下會觸發 GC ?

總的來說是:當空間不足,無法給新對象分配內存時觸發 GC。

具體情況是這樣子的:

大多數情況下,對象在新生代 Eden 區中分配。當 Eden 區沒有足夠的空間分配時,虛擬機將發起一次 Minor GC 。在發生 Minor GC 之前,虛擬機會先檢查老年代最大可用的連續空間是否大於新生代所有對象總空間,如果成立,代表 Minor GC 是安全的。如果不成立,虛擬機會查看 HandlePromotionFailure 設置值是否允許擔保失敗。如果允許,那麼會繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代對象的平均大小,如果大於,將嘗試進行一次 Minor GC ,儘管這次 Minor GC 是有風險的;如果小於,或者 HandlePromotionFailure 設置不允許冒險,則需要進行一次 Full GC 。

Minor GC 和 Full GC 的區別:
新生代 GC(Minor GC):指發生在新生代的垃圾收集動作,因爲 Java 對象大多都具備朝生夕滅的特性,所以 Minor GC 非常頻繁,一般回收速度也比較快。
老年代 GC(Major GC / Full GC):只發生在老年代的 GC ,出現了 Major GC ,經常會伴隨至少一次的 Minor GC(但也不是絕對的,在 Parallel Scavenge 收集器的收集策略裏面就有直接進行 Major GC 的策略選擇過程)。Major GC 的速度一般會比 Minor GC 慢 10 倍以上。

6、結語

內存回收與垃圾收集器在很多時候都是影響系統性能、併發能力的主要因素之一,虛擬機提供了多種不同的收集器以及大量的調節參數,實際應用中應根據具體情況選擇最優的收集方式,以獲取最高的性能。

參考資料:

(1)《深入理解 Java 虛擬機》周志明 著.

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