Java 相比 C/C++ 最顯著的特點便是引入了自動垃圾回收 (下文統一用 GC 指代自動垃圾回收),它解決了 C/C++ 最令人頭疼的內存管理問題,讓程序員專注於程序本身,不用關心內存回收這些惱人的問題,這也是 Java 能大行其道的重要原因之一,GC 真正讓程序員的生產力得到了釋放,但是程序員很難感知到它的存在,這就好比,我們吃完飯後在桌上放下餐盤即走,服務員會替你收拾好這些餐盤,你不會關心服務員什麼時候來收,怎麼收。
有人說既然 GC 已經自動我們完成了清理,不瞭解 GC 貌似也沒啥問題。在大多數情況下確實沒問題,不過如果涉及到一些性能調優,問題排查等,深入地瞭解 GC 還是必不可少的,曾經美團通過調整 JVM 相關 GC 參數讓服務響應時間 TP90,TP99都下降了10ms+,服務可用性得到了很大的提升!所以深入瞭解 GC 是成爲一名優秀 Java 程序員的必修課!
垃圾回收分上下篇,上篇會先講垃圾回收理論,主要包括
-
GC 的幾種主要的收集方法:標記清除、標記整理、複製算法的原理與特點,各自的優劣勢
-
爲啥會有 Serial ,CMS, G1 等各式樣的回收器,各自的優劣勢是什麼,爲啥沒有一個統一的萬能的垃圾回收器
-
新生代爲啥要設置成 Eden, S0,S1 這三個區,基於什麼考慮呢
-
堆外內存不受 GC 控制,那該怎麼釋放呢
-
對象可回收,就一定會被回收嗎?
-
什麼是 SafePoint,什麼是 Stop The World
下篇主要講垃圾回收的實踐,主要包括
-
GC 日誌格式怎麼看
-
主要有哪些發生 OOM 的場景
-
發生 OOM,如何定位,常用的內存調試工具有哪些
本文會從以下幾方面來闡述垃圾回收
-
JVM 內存區域
-
如何識別垃圾
-
引用計數法
-
可達性算法
-
-
垃圾回收主要方法
-
標記清除法
-
複製法
-
標記整理法
-
分代收集算法
-
-
垃圾回收器對比
文字比較多,不過也爲了便於讀者理解加了不少 GC 的動畫,相信看完會有不少收穫
JVM 內存區域
要搞懂垃圾回收的機制,我們首先要知道垃圾回收主要回收的是哪些數據,這些數據主要在哪一塊區域,所以我們一起來看下 JVM 的內存區域
-
虛擬機棧:描述的是方法執行時的內存模型,是線程私有的,生命週期與線程相同,每個方法被執行的同時會創建棧楨(下文會看到),主要保存執行方法時的局部變量表、操作數棧、動態連接和方法返回地址等信息,方法執行時入棧,方法執行完出棧,出棧就相當於清空了數據,入棧出棧的時機很明確,所以這塊區域不需要進行 GC。
-
本地方法棧:與虛擬機棧功能非常類似,主要區別在於虛擬機棧爲虛擬機執行 Java 方法時服務,而本地方法棧爲虛擬機執行本地方法時服務的。這塊區域也不需要進行 GC
-
程序計數器:線程獨有的, 可以把它看作是當前線程執行的字節碼的行號指示器,比如如下字節碼內容,在每個字節碼`前面都有一個數字(行號),我們可以認爲它就是程序計數器存儲的內容記錄這些數字(指令地址)有啥用呢,我們知道 Java 虛擬機的多線程是通過線程輪流切換並分配處理器的時間來完成的,在任何一個時刻,一個處理器只會執行一個線程,如果這個線程被分配的時間片執行完了(線程被掛起),處理器會切換到另外一個線程執行,當下次輪到執行被掛起的線程(喚醒線程)時,怎麼知道上次執行到哪了呢,通過記錄在程序計數器中的行號指示器即可知道,所以程序計數器的主要作用是記錄線程運行時的狀態,方便線程被喚醒時能從上一次被掛起時的狀態繼續執行,需要注意的是,程序計數器是唯一一個在 Java 虛擬機規範中沒有規定任何 OOM 情況的區域,所以這塊區域也不需要進行 GC
-
本地內存:線程共享區域,Java 8 中,本地內存,也是我們通常說的堆外內存,包含元空間和直接內存,注意到上圖中 Java 8 和 Java 8 之前的 JVM 內存區域的區別了嗎,在 Java 8 之前有個永久代的概念,實際上指的是 HotSpot 虛擬機上的永久代,它用永久代實現了 JVM 規範定義的方法區功能,主要存儲類的信息,常量,靜態變量,即時編譯器編譯後代碼等,這部分由於是在堆中實現的,受 GC 的管理,不過由於永久代有 -XX:MaxPermSize 的上限,所以如果動態生成類(將類信息放入永久代)或大量地執行 String.intern (將字段串放入永久代中的常量區),很容易造成 OOM,有人說可以把永久代設置得足夠大,但很難確定一個合適的大小,受類數量,常量數量的多少影響很大。所以在 Java 8 中就把方法區的實現移到了本地內存中的元空間中,這樣方法區就不受 JVM 的控制了,也就不會進行 GC,也因此提升了性能(發生 GC 會發生 Stop The Word,造成性能受到一定影響,後文會提到),也就不存在由於永久代限制大小而導致的 OOM 異常了(假設總內存1G,JVM 被分配內存 100M, 理論上元空間可以分配 2G-100M = 1.9G,空間大小足夠),也方便在元空間中統一管理。綜上所述,在 Java 8 以後這一區域也不需要進行 GC
畫外音: 思考一個問題,堆外內存不受 GC控制,無法通過 GC 釋放內存,那該以什麼樣的形式釋放呢,總不能只創建不釋放吧,這樣的話內存可能很快就滿了,這裏不做詳細闡述,請看文末的參考文章
-
堆:前面幾塊數據區域都不進行 GC,那只剩下堆了,是的,這裏是 GC 發生的區域!對象實例和數組都是在堆上分配的,GC 也主要對這兩類數據進行回收,這塊也是我們之後重點需要分析的區域
如何識別垃圾
上一節我們詳細講述了 JVM 的內存區域,知道了 GC 主要發生在堆,那麼 GC 該怎麼判斷堆中的對象實例或數據是不是垃圾呢,或者說判斷某些數據是否是垃圾的方法有哪些。
引用計數法
最容易想到的一種方式是引用計數法,啥叫引用計數法,簡單地說,就是對象被引用一次,在它的對象頭上加一次引用次數,如果沒有被引用(引用次數爲 0),則此對象可回收
String ref = new String("Java");
以上代碼 ref1 引用了右側定義的對象,所以引用次數是 1
如果在上述代碼後面添加一個 ref = null,則由於對象沒被引用,引用次數置爲 0,由於不被任何變量引用,此時即被回收,動圖如下
看起來用引用計數確實沒啥問題了,不過它無法解決一個主要的問題:循環引用!啥叫循環引用
public class TestRC {
TestRC instance;
public TestRC(String name) {
}
public static void main(String[] args) {
// 第一步
A a = new TestRC("a");
B b = new TestRC("b");
// 第二步
a.instance = b;
b.instance = a;
// 第三步
a = null;
b = null;
}
}
按步驟一步步畫圖
到了第三步,雖然 a,b 都被置爲 null 了,但是由於之前它們指向的對象互相指向了對方(引用計數都爲 1),所以無法回收,也正是由於無法解決循環引用的問題,所以現代虛擬機都不用引用計數法來判斷對象是否應該被回收。
可達性算法
現代虛擬機基本都是採用這種算法來判斷對象是否存活,可達性算法的原理是以一系列叫做 GC Root 的對象爲起點出發,引出它們指向的下一個節點,再以下個節點爲起點,引出此節點指向的下一個結點。。。(這樣通過 GC Root 串成的一條線就叫引用鏈),直到所有的結點都遍歷完畢,如果相關對象不在任意一個以 GC Root 爲起點的引用鏈中,則這些對象會被判斷爲「垃圾」,會被 GC 回收。
如圖示,如果用可達性算法即可解決上述循環引用的問題,因爲從GC Root 出發沒有到達 a,b,所以 a,b 可回收
a, b 對象可回收,就一定會被回收嗎?並不是,對象的 finalize 方法給了對象一次垂死掙扎的機會,當對象不可達(可回收)時,當發生GC時,會先判斷對象是否執行了 finalize 方法,如果未執行,則會先執行 finalize 方法,我們可以在此方法裏將當前對象與 GC Roots 關聯,這樣執行 finalize 方法之後,GC 會再次判斷對象是否可達,如果不可達,則會被回收,如果可達,則不回收!
注意: finalize 方法只會被執行一次,如果第一次執行 finalize 方法此對象變成了可達確實不會回收,但如果對象再次被 GC,則會忽略 finalize 方法,對象會被回收!這一點切記!
那麼這些 GC Roots 到底是什麼東西呢,哪些對象可以作爲 GC Root 呢,有以下幾類
-
虛擬機棧(棧幀中的本地變量表)中引用的對象
-
方法區中類靜態屬性引用的對象
-
方法區中常量引用的對象
-
本地方法棧中 JNI(即一般說的 Native 方法)引用的對象
虛擬機棧中引用的對象
如下代碼所示,a 是棧幀中的本地變量,當 a = null 時,由於此時 a 充當了 GC Root 的作用,a 與原來指向的實例 new Test() 斷開了連接,所以對象會被回收。
public class Test {
public static void main(String[] args) {
Test a = new Test();
a = null;
}
}
方法區中類靜態屬性引用的對象
如下代碼所示,當棧幀中的本地變量 a = null 時,由於 a 原來指向的對象與 GC Root (變量 a) 斷開了連接,所以 a 原來指向的對象會被回收,而由於我們給 s 賦值了變量的引用,s 在此時是類靜態屬性引用,充當了 GC Root 的作用,它指向的對象依然存活!
public class Test {
public static Test s;
public static void main(String[] args) {
Test a = new Test();
a.s = new Test();
a = null;
}
}
方法區中常量引用的對象
如下代碼所示,常量 s 指向的對象並不會因爲 a 指向的對象被回收而回收
public class Test {
public static final Test s = new Test();
public static void main(String[] args) {
Test a = new Test();
a = null;
}
}
本地方法棧中 JNI 引用的對象
這是簡單給不清楚本地方法爲何物的童鞋簡單解釋一下:所謂本地方法就是一個 java 調用非 java 代碼的接口,該方法並非 Java 實現的,可能由 C 或 Python等其他語言實現的, Java 通過 JNI 來調用本地方法, 而本地方法是以庫文件的形式存放的(在 WINDOWS 平臺上是 DLL 文件形式,在 UNIX 機器上是 SO 文件形式)。通過調用本地的庫文件的內部方法,使 JAVA 可以實現和本地機器的緊密聯繫,調用系統級的各接口方法,還是不明白?見文末參考,對本地方法定義與使用有詳細介紹。
當調用 Java 方法時,虛擬機會創建一個棧楨並壓入 Java 棧,而當它調用的是本地方法時,虛擬機會保持 Java 棧不變,不會在 Java 棧禎中壓入新的禎,虛擬機只是簡單地動態連接並直接調用指定的本地方法。
JNIEXPORT void JNICALL Java_com_pecuyu_jnirefdemo_MainActivity_newStringNative(JNIEnv *env, jobject instance,jstring jmsg) {
...
// 緩存String的class
jclass jc = (*env)->FindClass(env, STRING_PATH);
}
如上代碼所示,當 java 調用以上本地方法時,jc 會被本地方法棧壓入棧中, jc 就是我們說的本地方法棧中 JNI 的對象引用,因此只會在此本地方法執行完成後纔會被釋放。
垃圾回收主要方法
上一節我們知道了可以通過可達性算法來識別哪些數據是垃圾,那該怎麼對這些垃圾進行回收呢。主要有以下幾種方式方式
-
標記清除算法
-
複製算法
-
標記整理法
標記清除算法
步驟很簡單
-
先根據可達性算法標記出相應的可回收對象(圖中黃色部分)
-
對可回收的對象進行回收操作起來確實很簡單,也不用做移動數據的操作,那有啥問題呢?仔細看上圖,沒錯,內存碎片!假如我們想在上圖中的堆中分配一塊需要連續內存佔用 4M 或 5M 的區域,顯然是會失敗,怎麼解決呢,如果能把上面未使用的 2M, 2M,1M 內存能連起來就能連成一片可用空間爲 5M 的區域即可,怎麼做呢?
複製算法
把堆等分成兩塊區域, A 和 B,區域 A 負責分配對象,區域 B 不分配, 對區域 A 使用以上所說的標記法把存活的對象標記出來(下圖有誤無需清除),然後把區域 A 中存活的對象都複製到區域 B(存活對象都依次緊鄰排列)最後把 A 區對象全部清理掉釋放出空間,這樣就解決了內存碎片的問題了。
不過複製算法的缺點很明顯,比如給堆分配了 500M 內存,結果只有 250M 可用,空間平白無故減少了一半!這肯定是不能接受的!另外每次回收也要把存活對象移動到另一半,效率低下(我們可以想想刪除數組元素再把非刪除的元素往一端移,效率顯然堪憂)
標記整理法
前面兩步和標記清除法一樣,不同的是它在標記清除法的基礎上添加了一個整理的過程 ,即將所有的存活對象都往一端移動,緊鄰排列(如圖示),再清理掉另一端的所有區域,這樣的話就解決了內存碎片的問題。
但是缺點也很明顯:每進一次垃圾清除都要頻繁地移動存活的對象,效率十分低下。
分代收集算法
分代收集算法整合了以上算法,綜合了這些算法的優點,最大程度避免了它們的缺點,所以是現代虛擬機採用的首選算法,與其說它是算法,倒不是說它是一種策略,因爲它是把上述幾種算法整合在了一起,爲啥需要分代收集呢,來看一下對象的分配有啥規律如圖示:縱軸代表已分配的字節,而橫軸代表程序運行時間
由圖可知,大部分的對象都很短命,都在很短的時間內都被回收了(IBM 專業研究表明,一般來說,98% 的對象都是朝生夕死的,經過一次 Minor GC 後就會被回收),所以分代收集算法根據對象存活週期的不同將堆分成新生代和老生代(Java8以前還有個永久代),默認比例爲 1 : 2,新生代又分爲 Eden 區, from Survivor 區(簡稱S0),to Survivor 區(簡稱 S1),三者的比例爲 8: 1 : 1,這樣就可以根據新老生代的特點選擇最合適的垃圾回收算法,我們把新生代發生的 GC 稱爲 Young GC(也叫 Minor GC),老年代發生的 GC 稱爲 Old GC(也稱爲 Full GC)。
畫外音:思考一下,新生代爲啥要分這麼多區?
那麼分代垃圾收集是怎麼工作的呢,我們一起來看看
分代收集工作原理
1、對象在新生代的分配與回收
由以上的分析可知,大部分對象在很短的時間內都會被回收,對象一般分配在 Eden 區
當 Eden 區將滿時,觸發 Minor GC
我們之前怎麼說來着,大部分對象在短時間內都會被回收, 所以經過 Minor GC 後只有少部分對象會存活,它們會被移到 S0 區(這就是爲啥空間大小 Eden: S0: S1 = 8:1:1, Eden 區遠大於 S0,S1 的原因,因爲在 Eden 區觸發的 Minor GC 把大部對象(接近98%)都回收了,只留下少量存活的對象,此時把它們移到 S0 或 S1 綽綽有餘)同時對象年齡加一(對象的年齡即發生 Minor GC 的次數),最後把 Eden 區對象全部清理以釋放出空間,動圖如下
當觸發下一次 Minor GC 時,會把 Eden 區的存活對象和 S0(或S1) 中的存活對象(S0 或 S1 中的存活對象經過每次 Minor GC 都可能被回收)一起移到 S1(Eden 和 S0 的存活對象年齡+1), 同時清空 Eden 和 S0 的空間。
若再觸發下一次 Minor GC,則重複上一步,只不過此時變成了 從 Eden,S1 區將存活對象複製到 S0 區,每次垃圾回收, S0, S1 角色互換,都是從 Eden ,S0(或S1) 將存活對象移動到 S1(或S0)。也就是說在 Eden 區的垃圾回收我們採用的是複製算法,因爲在 Eden 區分配的對象大部分在 Minor GC 後都消亡了,只剩下極少部分存活對象(這也是爲啥 Eden:S0:S1 默認爲 8:1:1 的原因),S0,S1 區域也比較小,所以最大限度地降低了複製算法造成的對象頻繁拷貝帶來的開銷。
2、對象何時晉升老年代
-
當對象的年齡達到了我們設定的閾值,則會從S0(或S1)晉升到老年代如圖示:年齡閾值設置爲 15, 當發生下一次 Minor GC 時,S0 中有個對象年齡達到 15,達到我們的設定閾值,晉升到老年代!
-
大對象 當某個對象分配需要大量的連續內存時,此時對象的創建不會分配在 Eden 區,會直接分配在老年代,因爲如果把大對象分配在 Eden 區, Minor GC 後再移動到 S0,S1 會有很大的開銷(對象比較大,複製會比較慢,也佔空間),也很快會佔滿 S0,S1 區,所以乾脆就直接移到老年代.
-
還有一種情況也會讓對象晉升到老年代,即在 S0(或S1) 區相同年齡的對象大小之和大於 S0(或S1)空間一半以上時,則年齡大於等於該年齡的對象也會晉升到老年代。
3、空間分配擔保
在發生 MinorGC 之前,虛擬機會先檢查老年代最大可用的連續空間是否大於新生代所有對象的總空間,如果大於,那麼Minor GC 可以確保是安全的,如果不大於,那麼虛擬機會查看 HandlePromotionFailure 設置值是否允許擔保失敗。如果允許,那麼會繼續檢查老年代最大可用連續空間是否大於歷次晉升到老年代對象的平均大小,如果大於則進行 Minor GC,否則可能進行一次 Full GC。
4、Stop The World
如果老年代滿了,會觸發 Full GC, Full GC 會同時回收新生代和老年代(即對整個堆進行GC),它會導致 Stop The World(簡稱 STW),造成挺大的性能開銷。
什麼是 STW ?所謂的 STW, 即在 GC(minor GC 或 Full GC)期間,只有垃圾回收器線程在工作,其他工作線程則被掛起。
畫外音:爲啥在垃圾收集期間其他工作線程會被掛起?想象一下,你一邊在收垃圾,另外一羣人一邊丟垃圾,垃圾能收拾乾淨嗎。
一般 Full GC 會導致工作線程停頓時間過長(因爲Full GC 會清理整個堆中的不可用對象,一般要花較長的時間),如果在此 server 收到了很多請求,則會被拒絕服務!所以我們要儘量減少 Full GC(Minor GC 也會造成 STW,但只會觸發輕微的 STW,因爲 Eden 區的對象大部分都被回收了,只有極少數存活對象會通過複製算法轉移到 S0 或 S1 區,所以相對還好)。
現在我們應該明白把新生代設置成 Eden, S0,S1區或者給對象設置年齡閾值或者默認把新生代與老年代的空間大小設置成 1:2 都是爲了儘可能地避免對象過早地進入老年代,儘可能晚地觸發 Full GC。想想新生代如果只設置 Eden 會發生什麼,後果就是每經過一次 Minor GC,存活對象會過早地進入老年代,那麼老年代很快就會裝滿,很快會觸發 Full GC,而對象其實在經過兩三次的 Minor GC 後大部分都會消亡,所以有了 S0,S1的緩衝,只有少數的對象會進入老年代,老年代大小也就不會這麼快地增長,也就避免了過早地觸發 Full GC。
由於 Full GC(或Minor GC) 會影響性能,所以我們要在一個合適的時間點發起 GC,這個時間點被稱爲 Safe Point,這個時間點的選定既不能太少以讓 GC 時間太長導致程序過長時間卡頓,也不能過於頻繁以至於過分增大運行時的負荷。一般當線程在這個時間點上狀態是可以確定的,如確定 GC Root 的信息等,可以使 JVM 開始安全地 GC。Safe Point 主要指的是以下特定位置:
-
循環的末尾
-
方法返回前
-
調用方法的 call 之後
-
拋出異常的位置 另外需要注意的是由於新生代的特點(大部分對象經過 Minor GC後會消亡), Minor GC 用的是複製算法,而在老生代由於對象比較多,佔用的空間較大,使用複製算法會有較大開銷(複製算法在對象存活率較高時要進行多次複製操作,同時浪費一半空間)所以根據老生代特點,在老年代進行的 GC 一般採用的是標記整理法來進行回收。
垃圾收集器種類
如果說收集算法是內存回收的方法論,那麼垃圾收集器就是內存回收的具體實現。Java 虛擬機規範並沒有規定垃圾收集器應該如何實現,因此一般來說不同廠商,不同版本的虛擬機提供的垃圾收集器實現可能會有差別,一般會給出參數來讓用戶根據應用的特點來組合各個年代使用的收集器,主要有以下垃圾收集器
-
在新生代工作的垃圾回收器:Serial, ParNew, ParallelScavenge
-
在老年代工作的垃圾回收器:CMS,Serial Old, Parallel Old
-
同時在新老生代工作的垃圾回收器:G1
圖片中的垃圾收集器如果存在連線,則代表它們之間可以配合使用,接下來我們來看看各個垃圾收集器的具體功能。
新生代收集器
Serial 收集器
Serial 收集器是工作在新生代的,單線程的垃圾收集器,單線程意味着它只會使用一個 CPU 或一個收集線程來完成垃圾回收,不僅如此,還記得我們上文提到的 STW 了嗎,它在進行垃圾收集時,其他用戶線程會暫停,直到垃圾收集結束,也就是說在 GC 期間,此時的應用不可用。
看起來單線程垃圾收集器不太實用,不過我們需要知道的任何技術的使用都不能脫離場景,在 Client 模式下,它簡單有效(與其他收集器的單線程比),對於限定單個 CPU 的環境來說,Serial 單線程模式無需與其他線程交互,減少了開銷,專心做 GC 能將其單線程的優勢發揮到極致,另外在用戶的桌面應用場景,分配給虛擬機的內存一般不會很大,收集幾十甚至一兩百兆(僅是新生代的內存,桌面應用基本不會再大了),STW 時間可以控制在一百多毫秒內,只要不是頻繁發生,這點停頓是可以接受的,所以對於運行在 Client 模式下的虛擬機,Serial 收集器是新生代的默認收集器
ParNew 收集器
ParNew 收集器是 Serial 收集器的多線程版本,除了使用多線程,其他像收集算法,STW,對象分配規則,回收策略與 Serial 收集器完成一樣,在底層上,這兩種收集器也共用了相當多的代碼,它的垃圾收集過程如下
ParNew 主要工作在 Server 模式,我們知道服務端如果接收的請求多了,響應時間就很重要了,多線程可以讓垃圾回收得更快,也就是減少了 STW 時間,能提升響應時間,所以是許多運行在 Server 模式下的虛擬機的首選新生代收集器,另一個與性能無關的原因是因爲除了 Serial 收集器,只有它能與 CMS 收集器配合工作,CMS 是一個劃時代的垃圾收集器,是真正意義上的併發收集器,它第一次實現了垃圾收集線程與用戶線程(基本上)同時工作,它採用的是傳統的 GC 收集器代碼框架,與 Serial,ParNew 共用一套代碼框架,所以能與這兩者一起配合工作,而後文提到的 Parallel Scavenge 與 G1 收集器沒有使用傳統的 GC 收集器代碼框架,而是另起爐竈獨立實現的,另外一些收集器則只是共用了部分的框架代碼,所以無法與 CMS 收集器一起配合工作。
在多 CPU 的情況下,由於 ParNew 的多線程回收特性,毫無疑問垃圾收集會更快,也能有效地減少 STW 的時間,提升應用的響應速度。
Parallel Scavenge 收集器
Parallel Scavenge 收集器也是一個使用複製算法,多線程,工作於新生代的垃圾收集器,看起來功能和 ParNew 收集器一樣,它有啥特別之處嗎
關注點不同,CMS 等垃圾收集器關注的是儘可能縮短垃圾收集時用戶線程的停頓時間,而 Parallel Scavenge 目標是達到一個可控制的吞吐量(吞吐量 = 運行用戶代碼時間 / (運行用戶代碼時間+垃圾收集時間)),也就是說 CMS 等垃圾收集器更適合用到與用戶交互的程序,因爲停頓時間越短,用戶體驗越好,而 Parallel Scavenge 收集器關注的是吞吐量,所以更適合做後臺運算等不需要太多用戶交互的任務。
Parallel Scavenge 收集器提供了兩個參數來精確控制吞吐量,分別是控制最大垃圾收集時間的 -XX:MaxGCPauseMillis 參數及直接設置吞吐量大小的 -XX:GCTimeRatio(默認99%)
除了以上兩個參數,還可以用 Parallel Scavenge 收集器提供的第三個參數 -XX:UseAdaptiveSizePolicy,開啓這個參數後,就不需要手工指定新生代大小,Eden 與 Survivor 比例(SurvivorRatio)等細節,只需要設置好基本的堆大小(-Xmx 設置最大堆),以及最大垃圾收集時間與吞吐量大小,虛擬機就會根據當前系統運行情況收集監控信息,動態調整這些參數以儘可能地達到我們設定的最大垃圾收集時間或吞吐量大小這兩個指標。自適應策略也是 Parallel Scavenge 與 ParNew 的重要區別!
老年代收集器
Serial Old 收集器
上文我們知道, Serial 收集器是工作於新生代的單線程收集器,與之相對地,Serial Old 是工作於老年代的單線程收集器,此收集器的主要意義在於給 Client 模式下的虛擬機使用,如果在 Server 模式下,則它還有兩大用途:一種是在 JDK 1.5 及之前的版本中與 Parallel Scavenge 配合使用,另一種是作爲 CMS 收集器的後備預案,在併發收集發生 Concurrent Mode Failure 時使用(後文講述),它與 Serial 收集器配合使用示意圖如下
Parallel Old 收集器
Parallel Old 是相對於 Parallel Scavenge 收集器的老年代版本,使用多線程和標記整理法,兩者組合示意圖如下,這兩者的組合由於都是多線程收集器,真正實現了「吞吐量優先」的目標
CMS 收集器
CMS 收集器是以實現最短 STW 時間爲目標的收集器,如果應用很重視服務的響應速度,希望給用戶最好的體驗,則 CMS 收集器是個很不錯的選擇!
我們之前說老年代主要用標記整理法,而 CMS 雖然工作於老年代,但採用的是標記清除法,主要有以下四個步驟
-
初始標記
-
併發標記
-
重新標記
-
併發清除
從圖中可以的看到初始標記和重新標記兩個階段會發生 STW,造成用戶線程掛起,不過初始標記僅標記 GC Roots 能關聯的對象,速度很快,併發標記是進行 GC Roots Tracing 的過程,重新標記是爲了修正併發標記期間因用戶線程繼續運行而導致標記產生變動的那一部分對象的標記記錄,這一階段停頓時間一般比初始標記階段稍長,但遠比並發標記時間短。
整個過程中耗時最長的是併發標記和標記清理,不過這兩個階段用戶線程都可工作,所以不影響應用的正常使用,所以總體上看,可以認爲 CMS 收集器的內存回收過程是與用戶線程一起併發執行的。
但是 CMS 收集器遠達不到完美的程度,主要有以下三個缺點
-
CMS 收集器對 CPU 資源非常敏感 原因也可以理解,比如本來我本來可以有 10 個用戶線程處理請求,現在卻要分出 3 個作爲回收線程,吞吐量下降了30%,CMS 默認啓動的回收線程數是 (CPU數量+3)/ 4, 如果 CPU 數量只有一兩個,那吞吐量就直接下降 50%,顯然是不可接受的
-
CMS 無法處理浮動垃圾(Floating Garbage),可能出現 「Concurrent Mode Failure」而導致另一次 Full GC 的產生,由於在併發清理階段用戶線程還在運行,所以清理的同時新的垃圾也在不斷出現,這部分垃圾只能在下一次 GC 時再清理掉(即浮雲垃圾),同時在垃圾收集階段用戶線程也要繼續運行,就需要預留足夠多的空間要確保用戶線程正常執行,這就意味着 CMS 收集器不能像其他收集器一樣等老年代滿了再使用,JDK 1.5 默認當老年代使用了68%空間後就會被激活,當然這個比例可以通過 -XX:CMSInitiatingOccupancyFraction 來設置,但是如果設置地太高很容易導致在 CMS 運行期間預留的內存無法滿足程序要求,會導致 Concurrent Mode Failure 失敗,這時會啓用 Serial Old 收集器來重新進行老年代的收集,而我們知道 Serial Old 收集器是單線程收集器,這樣就會導致 STW 更長了。
-
CMS 採用的是標記清除法,上文我們已經提到這種方法會產生大量的內存碎片,這樣會給大內存分配帶來很大的麻煩,如果無法找到足夠大的連續空間來分配對象,將會觸發 Full GC,這會影響應用的性能。當然我們可以開啓 -XX:+UseCMSCompactAtFullCollection(默認是開啓的),用於在 CMS 收集器頂不住要進行 FullGC 時開啓內存碎片的合併整理過程,內存整理會導致 STW,停頓時間會變長,還可以用另一個參數 -XX:CMSFullGCsBeforeCompation 用來設置執行多少次不壓縮的 Full GC 後跟着帶來一次帶壓縮的。
G1(Garbage First) 收集器
G1 收集器是面向服務端的垃圾收集器,被稱爲駕馭一切的垃圾回收器,主要有以下幾個特點
-
像 CMS 收集器一樣,能與應用程序線程併發執行。
-
整理空閒空間更快。
-
需要 GC 停頓時間更好預測。
-
不會像 CMS 那樣犧牲大量的吞吐性能。
-
不需要更大的 Java Heap
與 CMS 相比,它在以下兩個方面表現更出色
-
運作期間不會產生內存碎片,G1 從整體上看採用的是標記-整理法,局部(兩個 Region)上看是基於複製算法實現的,兩個算法都不會產生內存碎片,收集後提供規整的可用內存,這樣有利於程序的長時間運行。
-
在 STW 上建立了可預測的停頓時間模型,用戶可以指定期望停頓時間,G1 會將停頓時間控制在用戶設定的停頓時間以內。
爲什麼G1能建立可預測的停頓模型呢,主要原因在於 G1 對堆空間的分配與傳統的垃圾收集器不一器,傳統的內存分配就像我們前文所述,是連續的,分成新生代,老年代,新生代又分 Eden,S0,S1,如下
而 G1 各代的存儲地址不是連續的,每一代都使用了 n 個不連續的大小相同的 Region,每個Region佔有一塊連續的虛擬內存地址,如圖示
除了和傳統的新老生代,倖存區的空間區別,Region還多了一個H,它代表Humongous,這表示這些Region存儲的是巨大對象(humongous object,H-obj),即大小大於等於region一半的對象,這樣超大對象就直接分配到了老年代,防止了反覆拷貝移動。那麼 G1 分配成這樣有啥好處呢?
傳統的收集器如果發生 Full GC 是對整個堆進行全區域的垃圾收集,而分配成各個 Region 的話,方便 G1 跟蹤各個 Region 裏垃圾堆積的價值大小(回收所獲得的空間大小及回收所需經驗值),這樣根據價值大小維護一個優先列表,根據允許的收集時間,優先收集回收價值最大的 Region,也就避免了整個老年代的回收,也就減少了 STW 造成的停頓時間。同時由於只收集部分 Region,可就做到了 STW 時間的可控。
G1 收集器的工作步驟如下
-
初始標記
-
併發標記
-
最終標記
-
篩選回收
可以看到整體過程與 CMS 收集器非常類似,篩選階段會根據各個 Region 的回收價值和成本進行排序,根據用戶期望的 GC 停頓時間來制定回收計劃。
總結
本文簡述了垃圾回收的原理與垃圾收集器的種類,相信大家對開頭提的一些問題應該有了更深刻的認識,在生產環境中我們要根據不同的場景來選擇垃圾收集器組合,如果是運行在桌面環境處於 Client 模式的,則用 Serial + Serial Old 收集器綽綽有餘,如果需要響應時間快,用戶體驗好的,則用 ParNew + CMS 的搭配模式,即使是號稱是「駕馭一切」的 G1,也需要根據吞吐量等要求適當調整相應的 JVM 參數,沒有最牛的技術,只有最合適的使用場景,切記!
理論有了,下一篇我們會進入手動操作環節,我們會一起來動手操作一些 demo,做一些實驗,來驗證我們看到的一些現象,比如對象一般分配在新生代,什麼情況下會直接到老年代,該怎麼實驗?發生了OOM,該用哪些工具調試呢?等等,敬請期待!