基礎篇
一、了解JVM記憶體結構
Java 虛拟機定義了各種在程式執行期間使用的運作時資料區域。這些資料區域有一些是在Java虛拟機啟動時建立的,并在Java虛拟機退出時銷毀,有一些資料區域是每個線程獨有的,線上程建立時建立,線上程銷毀時銷毀,根據《Java虛拟機規範》的規定,Java虛拟機運作時所需要管理的資料區域主要如下圖所示:
程式計數器(線程私有)
程式計數器是一塊非常小的記憶體區域,因為它隻是用來記錄記個數,可以看作是目前線程執行的位元組碼的行号訓示器,分支、循環、跳轉、異常處理、線程恢複等基礎功能都需要依賴這個計數器來完成。
因為JVM虛拟機的多線程是通過CPU時間片輪轉來實作的,是以就肯定會發生某一個線程代碼未執行完就被中斷執行,那麼當下次再執行獲得時間片執行時就需要這個記錄的行号來告訴線程應該從什麼地方開始執行。
如果線程正在執行的是一個Java方法,這個計數器記錄的是正在執行的虛拟機位元組碼指令的位址;如果正在執行的是本地(Native)方法,這個計數器值則應為空(Undefined)。此記憶體區域是唯 一一個在《Java虛拟機規範》中沒有規定任何OutOfMemoryError情況的區域。
Java虛拟機棧(線程私有)
虛拟機棧是一種先進先出的資料結構,主要作用是用來存放目前線程運作方法時所需要的記憶體空間,每個方法在被執行的時候,虛拟機都會建立一個棧幀用于存儲局部變量表、操作數棧、傳回位址、動态連結等資訊,方法執行完畢後棧幀就會被移除虛拟機棧。
異常類型
這塊區域會産生兩種異常:StackOverflowError、OutOfMemoryError。
1. StackOverflowError
表示棧的深度超過虛拟機所允許的最大深度。
java複制代碼public class Test {
static int count = 0;
public static void main(String[] args) {
try {
A();
} catch (Throwable e) {
System.out.println(count);
e.printStackTrace();
}
}
private static void A() {
count++;
A();
}
}
這塊區域預設大小為1M,執行21053次逾時棧的最大深度,當然你可以通過-Xss的方式進行修改,比如我們改為-Xss2m表示大小調整為2M。
2. OutOfMemoryError
Java虛拟機的棧區域是支援動态擴充的,那麼當棧擴充無法申請到足夠的記憶體時,就會抛出OutOfMemoryError異常。
棧的動态擴充隻是Java虛拟機規範中有支援,但具體情況還要看具體的虛拟機開發商,比如我們常用的HotSpot虛拟機的棧就是不可以動态擴充的。
本地方法棧(線程私有)
這塊區域與虛拟機棧功能相似,虛拟機是管理Java中的方法,本地方法棧是管理由C語言實作的方法,也就是調用native的方法。(HotSpot直接把本地方法棧和虛拟機棧合二為一了。)
異常類型
一樣會發生StackOverflowError、OutOfMemoryError。
方法區(線程共享)
方法區主要是用于存儲已被虛拟機加載的類的資訊、常量、靜态變量等資料。
關于方法區和永久代
方法區是Java虛拟機規範所定義的空間,是一種規範,而HotSpot在JDK1.8之前,并沒有嚴格按照Java虛拟機規範來設計,而是設計了一個名為永久代的部分,并且為了垃圾收集器的分代設計又把永久代放入了堆空間以便管理。是以要注意不要把方法區和永久代搞混了,因為實際上對于其他虛拟機,比如J9來說是不存在永久代這個概念的。
當然HotSpot也意識到如果把永久代放在堆空間内,可能會出現因為永久代的使用過多,導緻堆空間記憶體溢出的問題,是以為了隔離這種影響,從JDK8開始,永久代也改名為元空間,并将其移出堆空間,轉而是把這部分資料存儲到了本地記憶體中。
異常類型
對方法區中的資料回收條件是非常苛刻的,但是又不能完全不回收,這一部分空間同樣會出現OutOfMemoryError異常。
永久代溢出:OutOfMemoryError: PermGen space
元空間溢出:OutOfMemoryError: Metaspace
堆(線程共享)
堆空間是最大的一塊記憶體空間,主要作用就是用來存放建立的對象,幾乎所有的對象都是存放在堆空間的(當然有些特殊的場景存在,比如:堆外配置設定、對象逃逸、棧上配置設定等一些為了提高性能的優化手段,了解即可),是以這也是我們需要重點關注的一部分區域,我們平常所談論的垃圾回收,分代收集也都是針對這一部分空間的對象處理。
異常類型
同樣這一部分也會出現OutOfMemoryError:Java heap space異常。
運作時常量池
運作時常量池是方法區的一部分,主要用來存放編譯期間生成的符合引用和字面量。
異常類型
既然運作時常量池是方法區的一部分,自然受到方法區記憶體的限制,當常量池無法再申請到記憶體時會抛出OutOfMemoryError異常,具體異常類型根據不同的JDK版本來決定。
直接記憶體
這一部分區實際上并不是《Java虛拟機規範》中所定義的記憶體區域,但是由于在JDK1.4開始新加入了NIO類,使得Java通過native函數可以直接配置設定堆外記憶體,并通過對象引用對這塊記憶體進行操作,避免資料在Java堆和native堆中的來回複制,進而提高性能。
異常類型
這塊區域不受限于Java堆的大小限制,而是受限伺服器本身的記憶體容量,是以也會出現OutOfMemoryError異常。
二、關于垃圾回收
1. 如何判斷一個對象是垃圾
引用計數法
這是一種非常簡單的方法,為每一個對象中添加一個計數器,當對象被引用時,計數器就加1,當引用被釋放時,計數器就減1,最後如果計數器為0,則表示該對象沒有任何引用關系了,即為垃圾對象。
這是一種實作簡單,判定效率較高的方法,也有一些著名的應用案例,比如Python語言中就使用了這種方式,但是在JVM中并沒有使用這種算法,因為它存在一個明顯的問題:循環引用。
如下圖所示,A引用B,B引用A,除此之外再無其他任何對象引用了這個兩個對象,是以這兩個對象應當為垃圾對象,但卻因為計數器都不為0,是以不能被回收。
可達性分析法
為了避免上述的問題,在JVM中采用的是另一種可達性分析法來判斷對象是否存活,這個算法的思想就是通過判斷一系列被稱為GCRoots的根對象,并作為起點,根據引用關系向下查找,查找過的路徑稱為引用鍊,如果某個對象到GCRoots對象沒有任何一條引用鍊,則判斷此對象為可回收對象。
如下圖所示,D、G對象與GCRoots沒有任何引用鍊關系是以為可回收對象。
GCRoots的範圍
- 在虛拟機棧(棧幀中的本地變量表)中引用的對象,譬如各個線程被調用的方法堆棧中使用到的參數、局部變量、臨時變量等。
- 在方法區中類靜态屬性引用的對象,譬如Java類的引用類型靜态變量。
- 在方法區中常量引用的對象,譬如字元串常量池(String Table)裡的引用。
- 在本地方法棧中JNI(即通常所說的Native方法)引用的對象。
- Java虛拟機内部的引用,如基本資料類型對應的Class對象,一些常駐的異常對象(比如 NullPointExcepiton、OutOfMemoryError)等,還有系統類加載器。
- 所有被同步鎖(synchronized關鍵字)持有的對象。
- 反映Java虛拟機内部情況的JMXBean、JVMTI中注冊的回調、本地代碼緩存等。
除此之外根據某些垃圾收集器的選用,還有可能會存在臨時性的GCRoot對象,因為垃圾劃代收集的方式,比如掃描新生代對象的時候,還需要考慮被老年代中對象引用的情況,此時老年代中的對象也可視為GCRoot對象。
2. 哪些區域需要垃圾回收
對于線程獨享的記憶體區域來說,例如:程式計數器、Java虛拟機棧、本地方法棧這些,他們的生命周期都與線程相同,是以是不需要單獨對其進行管理的。
真正需要進行GC管理的主要就是線程共享的堆(Heap)和方法區(Method area)這兩塊區域了。
3. 垃圾回收的算法
3.1 标記-清除
我們首先介紹标記清除算法,因為這是一種最基礎的垃圾回收算法,它的回收過程主要分為兩個階段:1、根據可達性分析算法,标記每一個存活的對象,2、将沒有被标記的對象作為垃圾對象進行回收。當然也可以反過來标記。
回收之前記憶體狀态
回收之後記憶體狀态
優點:
- 整個回收過程隻需要簡單的設定一個标記位,相對而言系統資源消耗較少,速度較快。
- 整個回收過程不會移動存活的對象。
- 相比複制算法,記憶體使用率高。
缺點:
- 執行效率不穩定,如果标記的是存活對象,那麼存活對象較多時就需要大量的标記和清除,如果标記的是可回收對象,那麼可回收對象較多時就需要大量的标記和清除。
- 記憶體碎片問題,從上圖中也可以看出,再一次回收完成後記憶體未使用空間看起來依然很零碎,這樣将導緻大對象因為沒有連續的記憶體空間而無法被配置設定。
3.2 标記-複制
使用标記複制算法當存活對象較少時的可以得到不錯的收益,基本的算法思想是将記憶體分為兩個大小相等的區域,配置設定對象時每次隻使用其中的一塊區域,當這塊區域用完時,就把還存活的對象複制到另一塊區域上,然後再把這塊區域已使用的空間直接清除。
回收之前記憶體狀态
回收之後記憶體狀态
優點:
- 如果大部分對象都是可回收的,那麼隻需要複制少量存活的對象,效率較高。
- 不存在記憶體碎片的問題。
- 配置設定對象簡單,隻需移動堆頂指針,按順序配置設定即可。
缺點:
- 記憶體使用率較低,需要空出一半的記憶體空間用來確定容得下存活的對象。
- 存活對象在記憶體中的位置會發生變化,需要移動對象的引用位址。
- 同樣隻适合存活對象較少的場景,如果存活對象較多就會複制大量的存活對象。
3.3 标記-整理
标記整理算法同時解決了标記清除的記憶體碎片問題和标記複制的記憶體浪費的問題,相比标記清除算法,标記整理多個一步整理階段,即移動存活對象,讓存活對象向堆的一端移動,然後再清理掉其餘的記憶體空間。
回收之前記憶體狀态
回收之後記憶體狀态
優點:
- 解決了記憶體碎片的問題。
- 解決複制算法的記憶體浪費的問題。
缺點:
- 同樣如果存活對象較多,每次移動存活對象又會帶來不小的開銷。
三、對象配置設定政策
一般自動記憶體管理都需要解決的以下三個問題:
- 為新對象配置設定空間。
- 确定存活對象。
- 回收死亡對象所占用的空間。
其中第2個問題實際上要解決的就是如何判斷一個對象是垃圾的?這個在前面的文章中已經有介紹,第3個問題實際上就是垃圾回收的方式,這個在後面的文章中也會介紹,本節再來看看對于對象配置設定的問題是如何解決的。
首先我們依然基于分代劃分的思想,将堆空間分為新生代、老年代,其中新生代一般又被分為一個Eden區和兩個Survivor區。
1. 對象優先在Eden區配置設定
大多數情況下,對象肯定是優先配置設定在Eden區的,如果Eden區空間不足,就會觸發一次新生代的回收(也可以叫做:Minor GC或YGC)。
TLAB
本地線程配置設定緩沖,記憶體配置設定實際上被按照不同的線程劃分在不同的記憶體之間進行,每個線程在Eden區中中有一塊獨享的小區域,這樣做的好處是可以減少同步處理帶來的性能消耗。
可以使用-XX:TLABSize設定大小。
2. 大對象直接進入老年代
大對象一般指的是那種需要占用連續的記憶體空間的對象,比如很大的一個數組對象。
為什麼大對象不優先在Eden區配置設定?
首先我們知道Eden區的對象都是預設被我們假設為朝生夕死的對象,在Eden區中的對象預設需要經曆15次垃圾回收(動态年齡)才會被放入老年代,是以假設這個大對象不是一個短命鬼,那麼我們就需要在記憶體中來回複制15次,這必然會降低垃圾回收的效率,是以幹脆直接放入老年代,以避免大對象的頻繁複制過程。
寫代碼時應該注意避免大對象的頻繁産生
了解這個配置設定原則後,我們平時在寫代碼就應當盡量避免不必要的大對象産生,尤其是那種朝生夕死的大對象,因為這樣的對象就會頻繁的進入老年代,并且如果老年代的連續記憶體空間不足,就會頻繁的觸發FullGC,因為要為大對象整理出連續的記憶體空間。
同時大對象必然需要消耗更多的記憶體複制的開銷。
使用-XX:PretenureSizeThreshold這個參數可以設定大對象的門檻值,不過要注意這個參數隻對Serial和ParNew兩款新生代收集器有效。
配置設定示範
java複制代碼public class Test {
public static void main(String[] args) throws InterruptedException {
byte[] bytes = new byte[1024*1024*1];//配置設定1M記憶體
Thread.sleep(Integer.MAX_VALUE);//讓程式休眠,觀察記憶體情況
}
}
設定JVM參數,JDK1.8環境
-Xms20m(堆的初始大小)
-Xmx20m(堆的最大大小)
-XX:NewSize=10m(新生代的初始大小)
-XX:MaxNewSize=10m(新生代的最大大小)
我們可以通過jmap指令檢視heap的配置設定情況
Eden區一共使用了5M,4M大約來自JDK本身啟動時所需加載的對象所占用的記憶體空間。
設定-XX:PretenureSizeThreshold=1024(機關為byte),垃圾收集器為Serial,再看一下效果。
這時候1M的byte數組就被直接配置設定到了老年代中了。
3. 長期存活的對象進入老年代
對象首先被配置設定到Eden區,當發生MinorGC後,如果對象仍然存活,那麼就會被移動到Survivor區,此時對象的年齡就會+1歲,當到達指定年齡後對象仍然存活,這樣的對象就屬于長期存活的對象,那麼就會被放入老年代中,這樣做的好處當然是為了減少對象在新生代中來回複制帶來的性能消耗。
使用-XX:MaxTenuringThreshold參數可以配置年齡的大小,其中parallel預設為15,CMS預設為6。
示例示範
java複制代碼public class Test {
public static void main(String[] args) {
byte[] b1 = new byte[1024 * 256];
byte[] b2 = new byte[1024 * 1024 * 1];
byte[] b3 = new byte[1024 * 1024 * 2];
byte[] b4 = new byte[1024 * 1024 * 2];
}
}
當使用預設年齡時,發生MinorGC後,有一部分對象進入Survivor區。
java複制代碼[GC (Allocation Failure)
Desired survivor size 1048576 bytes, new threshold 2 (max 2)
[PSYoungGen: 6350K->1016K(9216K)] 6350K->4476K(19456K), 0.0015667 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
PSYoungGen total 9216K, used 3230K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
eden space 8192K, 27% used [0x00000000ff600000,0x00000000ff8299b8,0x00000000ffe00000)
from space 1024K, 99% used [0x00000000ffe00000,0x00000000ffefe020,0x00000000fff00000)
to space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
ParOldGen total 10240K, used 3460K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
object space 10240K, 33% used [0x00000000fec00000,0x00000000fef61010,0x00000000ff600000)
Metaspace used 3193K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 349K, capacity 388K, committed 512K, reserved 1048576K
Process finished with exit code 0
當設定-XX:MaxTenuringThreshold=0後,發現Survivor區沒有存活對象了。
java複制代碼[GC (Allocation Failure)
Desired survivor size 1048576 bytes, new threshold 0 (max 0)
[PSYoungGen: 6350K->0K(9216K)] 6350K->4417K(19456K), 0.0017623 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
PSYoungGen total 9216K, used 2214K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
eden space 8192K, 27% used [0x00000000ff600000,0x00000000ff829960,0x00000000ffe00000)
from space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
to space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
ParOldGen total 10240K, used 4417K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
object space 10240K, 43% used [0x00000000fec00000,0x00000000ff050420,0x00000000ff600000)
Metaspace used 3181K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 348K, capacity 388K, committed 512K, reserved 1048576K
Process finished with exit code 0
4. 對象動态年齡判斷
在HotSpot虛拟機設計中,并不是完全要等對象年齡到達-XX:MaxTenuringThreshold設定的值以後才會被放入老年代,也有一種例如的情況,當Survivor空間中相同年齡所有對象大小的總和大于Survivor空間的一半,年齡大于或等于該年齡的對象就可以直接進入老年代。
5. 棧上配置設定
幾乎所有的對象都是配置設定在堆記憶體中,但是還有一種比較特殊的配置設定方式是配置設定在棧上,這是借助于逃逸分析來輔助實作的,逃逸分析中指出如果對象的作用域不會逃出方法或者線程之外,也就是無法通過其他途徑通路到這個對象,那麼就可以對這個對象采取一定程度的優化,這其中就包含了:棧上配置設定。
棧上配置設定的好處在于,對象可以随着棧的出棧過程被自然的銷毀,節省了堆中垃圾回收所消耗的性能。
對象配置設定大緻的流程圖
四、對象的引用關系
在Java中引用類型分别有強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)、虛引用(Phantom Reference)這4 種類型,對應的引用強度依次減弱。
1. 強引用
對于直接引用通過new關鍵字生成的對象就是強引用關系,此類引用必須由垃圾收集器判斷确定沒有任何引用關系後才能被回收。
2. 軟引用
軟引用是用來處理一些有用但是并非必需的對象,這些對象就可以用軟引用關聯,軟引用關聯的對象會在系統将要發生記憶體溢出OOM之前回收。
下面這段代碼示範了在記憶體不足時,将要發生OOM,是以軟引用對象被回收。
-Xms10m -Xmx10m,設定最大記憶體10M。
java複制代碼public class Test {
public static void main(String[] args) {
byte[] b = new byte[1024 * 1024 * 5];
SoftReference softReference = new SoftReference(b);
b = null;
System.gc();
System.out.println("b對象沒有任何引用,手動調用gc,b對象被回收:" + b + " ,軟引用對象:" + softReference.get());
//由于已經存在5M對象,再配置設定6M肯定不夠配置設定,是以為了避免OOM,軟引用對象被回收。
byte[] b1 = new byte[1024 * 1024 * 6];
System.out.println("b1對象,超過記憶體最大值,觸發垃圾回收,b1對象:" + b1 + " ,軟引用對象被回收:" + softReference.get());
}
}
3. 弱引用
弱引用和軟體引用作用相似,隻不過弱引用級别更低,在下一次垃圾回收時就會被回收,無論目前記憶體是否足夠。
java複制代碼public class Test {
public static void main(String[] args) {
byte[] b = new byte[1024 * 1024 * 1];
WeakReference weakReference = new WeakReference(b);
b = null;
System.out.println("弱引用對象在gc前:" + weakReference.get());
System.gc();
System.out.println("弱引用對象在gc後:" + weakReference.get());
}
}
4. 虛引用
對引用對象完全沒有影響,随時可能被回收,唯一目的是對象在回收時能收到一個通知。
使用場景
軟引用:一般都可以作為緩存使用,當做一種淘汰政策,可避免OOM。
弱引用:也可作為緩存使用,但是會更快的被清除,是以與軟引用的緩存級别不相同,更适用于一些臨時、短期緩存。 另外還可參考ThreadLocal中的一種使用場景,就是自動删除,避免由于key的記憶體洩露,key有一個強引用和一個弱引用,一旦強引用沒了,就希望key能夠自動回收,而不需要主要删除這個key,因為删除這個key可能比較麻煩,是以就可以通過弱引用實作。
虛引用:java直接記憶體就使用了虛引用的方式,堆中有一個對象,儲存了堆外記憶體的引用,當這個對象被回收時,會收到通知,則回收堆外記憶體。
提高篇
一、分代垃圾回收算法
前面在基礎篇中提到了三種垃圾回收算法,實際上每種垃圾收集器都有各自的優缺點,沒有一種算法可以适應所有的場景,是以在JVM中并沒有完全的采用其中任意一種垃圾回收算法,而是根據不同的場景選擇合适的算法。
分代收集
為了滿足選擇合适的垃圾回收算法,JVM中采用了分代收集的理論進行設計,它建立在如下兩個分代假設之上:
- 弱分代假說:絕大多數對象都是朝生夕死的。
- 強分代假說:熬過越多次垃圾收集過程的對象就越難以消亡。
基于這兩個假設的基礎上,JVM對堆空間進行了劃分,并根據對象的年齡(熬過多次的對象)劃分到不同的空間上,是以就劃分出了:新生代、老年代。
新生代
基于弱分代假說理論,大多數對象都是可回收的,是以可以采用标記複制算法。
老年代
基于強分代假說理論,把在新生代經曆過多次回收都存活的對象,放入老年代,那麼老年代中的對象大部分就都是不可回收的,是以可以采用标記清除或者标記整理算法,隻需要少量的标記和清除可回收對象即可。
同時新生代中的對象因為死的快,而老年代中的對象大多數都是難以消亡的,是以把這個兩部分區域劃分開來,就又能以不同回收頻率去進行回收,老年代的回收頻率往往要遠低于新生代的回收頻率。
對标記複制算法的改進
現在我們知道新生代可以采用标記複制算法進行回收,而标記複制算法的缺點就是需要空出一半的記憶體空間,那麼在JVM中實際上并沒有這樣做,IBM公司曾有一項專門研究對新生代朝生夕滅的特點做了更量化的诠釋——新生代中有98%的對象是熬不過第一輪GC的。是以并不需要按照1:1的比例來劃分新生代的記憶體空間。
依據這一項研究,HotSpot虛拟機就又把新生代劃分為了3個部分:Eden、Survivor0、Survivor1,他們的比例預設為8:1:1,也就是說兩個Survivor區采用完全複制的算法,這樣一來僅僅浪費了新生代的10%的記憶體空間。
當然理論之下總有意外,如果存活的對象就是超過了10%怎麼辦?當然這時候一般就會依靠老年代來進行擔保了。
标記清除還是标記整理?
HotSpot中關注吞吐量的Parallel Scavenge收集器是基于标記整理算法的,而關注延遲的CMS收集器則是基于标記清除算法的。
為什麼Parallel Scavenge選擇标記整理,CMS選擇标記清除?
标記清除和标記整理的主要差別就在于對象的移動整理,如果移動則記憶體回收時會更加複雜,如果不移動則對象配置設定時會更加複雜,是以标記清除算法在垃圾回收時速度更快、停頓時間更短,而标記整理算法雖然停頓時間稍長,但是對象的配置設定和通路則相比标記清除更加快,又因為配置設定和通路的頻率要遠遠高于垃圾回收的頻率,是以從總比來看吞吐量是要高于标記清除算法的。
CMS中還有一種特殊的做法,就是一般情況下采用标記清除算法,直到碎片程度太嚴重的時候可以再采用标記整理算法。
二、三色标記法
三色标記法是JVM中用來标記對象是否為垃圾的一種方法,主要是針對CMS、G1等垃圾收集器使用的,這類收集器都有一個垃圾回收線程與使用者線程同時執行的并發過程,這是一般标記清除算法不能支援的。
三色标記法就是為了使垃圾掃描階段能夠與使用者線程并發執行而産生的,因為傳統的标記清除算法,必須要暫停所有使用者線程。
在傳統的标記清除算法下,隻有兩個狀态位:0、1,比如0:表示未辨別,1:表示對象可達,一次掃描結束後,清除所有狀态為0的,然後再把狀态為1的重置為0。
但是如果與使用者線程同時執行就不能這樣玩了,因為一旦同時執行就有可能在一條鍊未全部掃描完的情況下,使用者線程改變了這條鍊上的引用關系,比如現在有一條引用鍊:A--->B--->C--->D,當掃描到C時,A和B都被辨別為1,此時C到D的引用關系被删除,那麼D對象就不能确定是否為垃圾對象,因為有可能D又被使用者線程設定為其他對象的引用了,那麼為了D不被誤删,隻能讓D的辨別也為1,但是如果D就是沒有被其他對象引用了,那麼D就逃過了這次垃圾收集的過程,這就會造成大量的浮動垃圾。
當然肯定也不能設定為0,因為0在未掃描之前雖然表示的是未标記對象,但是在掃描開始後就表示垃圾對象了。
是以上述問題很明顯就是缺少了一個表示中間狀态的過程,由于線程同時進行,是以引用鍊上的對象并不是簡單的可達與不可達的關系,而是會有一個掃描過程中的狀态,是以就出現了三色标記法。
1. 三色标記法中的三色
白色
表示對象尚未被垃圾收集器通路過。顯然在可達性分析剛剛開始的階段,所有的對象都是 白色的,若在分析結束的階段,仍然是白色的對象,即代表不可達。
灰色
表示對象已經被垃圾收集器通路過,但這個對象上至少存在一個引用還沒有被掃描過,也就是整個引用鍊還未全部掃完。
黑色
表示對象已經被垃圾收集器通路過,且這個對象的所有引用都已經掃描過。黑色的對象代表已經掃描過,它是安全存活的,如果有其他對象引用指向了黑色對象,無須重新掃描一遍。黑色對象不可能直接(不經過灰色對象)指向某個白色對象。
初始階段:全部為白色
A對象掃描完成後變為黑色,B對象正在掃描則辨別為灰色,剩餘的白色對象辨別還未被掃描。
最終按照可達性分析算法一輪掃描下來結果如下
最終白色對象E即為垃圾對象。
主要就是利用三個集合,分别來存放三種顔色的對象,開始掃描時把被掃描的白色對象從白色集合中移動到灰色集合中,灰色對象掃描完成後,又被移動到黑色對象集合中,最終完成所有初始标記時識别到的GCRoot引用鍊路徑後,餘下的白色集合中的對象即為垃圾對象。
2. 三色标記的漏标問題
三色标記的思想非常簡單,但仔細分析一下就會發現其中的問題,如果把一個白色對象的引用設定到一個黑色的對象上,那麼這個白色對象就會被錯誤的認為是一個垃圾對象,因為黑色對象表示的是這個對象已經完成了掃描且這個對象的所有引用都已經掃描過。
第一次标記時,關系如下:
使用者線程修改了引用關系如下:
此時接着掃描E對象,發現E對象之後沒有引用關系了,把E對象設定為黑色,垃圾收集器認為兩條引用鍊上的對象全部掃描完畢,但是F對象卻被遺漏了。
Wilson于1994年在理論上證明了,當且僅當以下兩個條件同時滿足時,會産生對象消失的問題,即原本應該是黑色的對象被誤标為白色:
指派器插入了一條或多條從黑色對象到白色對象的新引用。 指派器删除了全部從灰色對象到該白色對象的直接或間接引用。
3. 如何解決漏标問題?
既然問題的産生需要同時滿足上述兩個條件,那麼要解決就隻需破壞其中一種即可,CMS和G1恰好分别利用其中一種條件來解決。
3.1. 增量更新(Incremental Update)
增量更新要破壞的是第一個條件,當黑色對象插入新的指向白色對象的引用關系時,就将這個新插入的引用記錄下來,等并發掃描結束之後,再将這些記錄過的引用關系中的黑色對象為根,重新掃描一次。這可以簡化了解為,黑色對象一旦新插入了指向白色對象的引用之後,它就變回灰色對象了。
C對象被修改為灰色,那麼就會沿着灰色對象繼續掃描,最終會掃描到F對象。
3.2. 原始快照(Snapshot At The Beginning, SATB)
原始快照要破壞的是第二個條件,當灰色對象要删除指向白色對象的引用關系時,就将這個要删除的引用記錄下來,在并發掃描結束之後,再将這些記錄過的引用關系中的灰色對象為根,重新掃描一次。這也可以簡化了解為,無論引用關系删除與否,都會按照剛剛開始掃描那一刻的對象圖快照來進行搜尋。
第一、二兩條鍊掃描完成後,多出了第三條引用鍊,從之前的灰對象E開始,指向F對象,這樣F對象就不會被清理掉了。
使用這種方式會有一個問題,假設引用關系如下:
之後引用關系被改變
E到F的引用沒有了,F也沒有再被其他對象引用,但是由于E對象為灰色對象,是以為了避免漏标,E對象最終還是會有一條到F的引用關系,這就是浮動垃圾問題,F對象會逃過本次的垃圾掃描,等待下次再被清理,但這總比漏标要好的多,但這種情況還是比較少的,因為隻有在改變灰色對象時才需要記錄。
三、垃圾回收器
一般大家認知比較高的垃圾回收器都在下圖中了,當然圖上的最新回收器是G1,而在JDK11時釋出的ZGC也是話題度很高的一款新型垃圾回收器,雖然有這麼多種垃圾回收器,不過就當下來看,目前用的最多的還以parallel、cms、g1這三種為代表。
接下來,我們就分别來談談這三種垃圾回收器。
1. Parallel
首先是Parallel,見名知意,這一款能夠并行執行的垃圾回收器,其主要的關注點在于保證系統的吞吐量,你可能會覺得這款垃圾回收器太老了,也不能做到并發回收,但它可是如今使用最多的JDK8中預設的垃圾回收器,而這一點我發現很多人都不知道(查查你們公司目前正在用的垃圾回收器是不是它),并且如果服務記憶體本身就比較小,那對于Parallel來說自身占用記憶體也是比較少的。
1.1. Parallel Scavenge、Parallel Old回收算法
Parallel Scavenge是針對新生代的垃圾回收器,而Parallel Old是針對老年代的垃圾回收器,對于新生代的回收算法,參考前面相關的理論知識,應該選擇标記-複制算法,而老年代,可以用标記-清除或者标記-整理,那追求吞吐量的情況下,Parallel Old肯定是選擇了标記-整理。
吞吐量
這裡有必要說明一下什麼是吞吐量?在垃圾回收中,吞吐量指的就是運作使用者線程時間占系統總運作時間的比值。
舉個例子:運作使用者代碼時間為99分鐘,垃圾收集器進行垃圾回收運作了1分鐘,那麼吞吐量就是:99 / (1+99) = 99%
追求高吞吐量可以最大程度的利用CPU資源完成運算的任務,這就比較适合關注背景運算,而與使用者互動較少的場景。
1.2. 兩個關鍵參數:
-XX:MaxGCPauseMillis
設定最大GC暫停時間的目标(以毫秒為機關)。這是一個軟目标,并且JVM将盡最大的努力來實作它。 預設情況下,沒有最大暫停時間值,這需要額外注意,他很有可能會造成較長時間的GC暫停。 下面的示例顯示如何将最大目标暫停時間設定為500ms: -XX:MaxGCPauseMillis = 500
當然你不能簡單的認為這個值設定的越小越好,你要知道Parallel Scavenge是如何做到控制停頓時間的?實際上就是簡單的增加垃圾回收頻率而已,也就是說你設定的停頓時間越短,垃圾回收的頻率就會越頻繁,比如:原來30秒一次垃圾回收,一次停頓2秒,現在由于設定的停頓時間為1秒,是以必須10秒執行一次垃圾回收,雖然停頓時間短了,但是吞吐量也低了。
-XX:GCTimeRatio
這個參數的值則應當是一個大于0小于100的整數,也就是垃圾收集時間占總時間的 比率,相當于吞吐量的倒數。譬如把此參數設定為19,那允許的最大垃圾收集時間就占總時間的5% (1/(1+19)),預設值為99,即允許最大1%(1/(1+99))的垃圾收集時間。
2. CMS
CMS(Concurrent Mark Sweep)是HotSpot虛拟機中第一款實作并發收集的垃圾回收器,是為那些希望使用較短的垃圾收集暫停時間并且可以在應用程式運作時與垃圾收集器共享處理器資源的應用程式而設計的,簡單來說,CMS就是追求最短停頓時間的垃圾收集器。
CMS的熱度一直都很高,也算是具有重要意義的一款垃圾回收器,不過遺憾的是,它并沒有成為任何一版JDK中的預設垃圾回收器,我想應該也是因為它缺點明顯,後面又有了更出色的G1的原因吧,盡管如此,CMS的設計理念還是很值得我們學習的,是以讓我們一起看看它到底是如何做到同時兼顧垃圾回收與對象産生的。
2.1 回收政策
CMS主要針對老年代進行垃圾回收,可以配合Serial或者ParNew新生代垃圾收集器進行回收,并且從名字上包含Mark Sweep就可以看出CMS收集器是基于标記-清除算法實作的,相對之前的垃圾收集器CMS整個回收過程要稍微複雜一些,大緻分為4步:
- 初始标記(CMS initial mark)
- 并發标記(CMS concurrent mark)
- 重新标記(CMS remark)
- 并發清除(CMS concurrent sweep)
2.1.1. 初始标記(CMS initial mark)
首先初始标記,需要暫停使用者線程,不過這一步僅僅标記GCRoots能直接關聯到的對象,是以暫停時間很短。
隻标記GCRoots直接可達對象
2.1.2. 并發标記
并發标記就是接着初始标記的根對象繼續往下标記,這個階段是最耗時的,但是好在是與使用者線程并發執行的。
考慮一種情況,老年代對象被新生代對象引用,如果此時隻掃描老年代的GCRoots對象,A對象就會被遺漏,是以并發标記時實際上也會掃描新生代對象。
2.1.3. 重新标記
重新标記階段是為了修正并發标記期間,因使用者程式繼續運作而導緻标記産生變動的那一部分對象的标記記錄,這個階段的停頓時間通常會比初始标記階段稍長一些,但也遠比并發标記階段的時間短。
2.1.4. 并發清除
清理删除掉标記階段判斷的已經死亡的對象,由于不需要移動存活對象,是以這個階段也是可以與使用者線程同時并發的。
并發預清理階段
實際上除了上述的主要流程之外,CMS還有一步并發預清理階段,這個階段主要是發生在重新标記之前,此階段工作與重新标記類似,目的主要是為了希望能夠在重新标記前觸發一次新生代的GC,這樣就可以減少重新标記的停頓時間,此階段主要标記新生代晉升到老年代的對象,直接配置設定到老年代的對象,并發過程中引用發生修改的對象,預設情況下當eden區達到了2M,則會開啟并發預清理階段,當eden區使用達到50%時停止預清理,或者預清理階段超過預設時間5秒時也會停止預清理,配置CMSScavengeBeforeRemark參數,也可強制使每次重新标記前都觸發一次YGC,但是這樣的做法,雖然減少了重新标記的任務,但如果剛好已經執行過一次YGC,重新标記又執行一次,也會造成STW時間變長。
如何解決并發标記時引用關系改變問題?
由于第二階段垃圾标記是與使用者線程并發執行的,那就有可能産生錯誤标記的問題,比如一個對象我們剛剛标記完,結果使用者線程又把其他對象引用到這個剛剛标記完的對象上。
如下圖,當垃圾線程标記時,A的這條引用鍊走到B就已經走完了,但是如果之後使用者線程讓B對象又引用了C對象,那麼C對象就會被漏标,最終會被當做垃圾對象被清理掉,顯然C對象是不能被回收的。
為了解決這樣的問題,CMS首先将老年代等份劃分成了好多小塊,這些小塊的集合可以叫做card table(byte數組,數組中每一進制素對應一個塊),當某一個對象的引用發生變化時(隻記錄黑色對象引用發生變化),就改變這個對象所在的塊的辨別,比如标記為:髒card,這樣我們在最終标記時隻要在周遊一次所有的髒card即可。
如何确定新生代對象是否存活?
- GC可達性分析
- 老年代引用新生代對象
GC可達性分析不用多說,主要分析一下老年代引用新生代對象的問題,剛才分析初始标記時就已經了解到,在分代收集中隻是掃描GCRoots肯定是不夠的,要确認老年代對象是否存活就必須掃描所有新生代對象,是以剛才介紹了CMS并發預清理階段就是為了來一次新生代的垃圾回收,這樣新生代中大多數對象就被回收了。
現在問題是新生代要判斷哪些對象被老年代引用了,老年代的對象的都是長期存活的,一次垃圾回收可沒用,那就隻能全量掃描老年代了?顯然CMS不會這樣做,這時候card table又派上用場了,當有新生代引用老年代對象時,隻需要把老年代所在的card标記新增一個辨別即可,就像上面标記為髒一樣,這樣新生代隻需要掃描所有有相關辨別的card即可。
card table是一個byte數組,一個byte有8個位,隻要約定好每一位的含義就可以區分辨別是對象在并發期間修改了,還是老年代引用新生代對象!
2.2 CMS缺點
- 因為是并發執行,是以會占用使用者線程,CPU核心數小于4的伺服器不推薦使用。
- 浮動垃圾問題,因為CMS是與使用者線程并發執行的,是以并不能等待記憶體占用達到100%了再回收,jdk6以後預設是92%,就會開啟CMS垃圾回收,如果過程中産生Concurrent Mode Failure,則會切換成serial old進行回收。
- 垃圾碎片:CMS采用标記-清除算法,是以會存在碎片問題,CMS預設情況下每一次FullGC都會進行一次壓縮整理,通過參數可以配置UseCMSCompactAtFullCollection預設為true, CMSFullGCsBeforeCompaction就是表示配置每多少次CMS的FullGC執行一次壓縮,但是如果使用者調用system.gc或者擔保失敗,那也會觸發壓縮的FullGC。
2.3 CMS常見問題解決思路
并發模式失敗和晉升失敗都會導緻長時間的停頓,常見解決思路如下:
- 降低觸發CMS GC的門檻值。即參數 -XX:CMSInitiatingOccupancyFraction 的值,讓CMS GC盡早執行,以保證有足夠的空間。
- 增加CMS線程數,即參數 -XX:ConcGCThreads。
- 增加老年代空間。
- 讓對象盡量在新生代回收,避免進入老年代。
通常CMS GC的過程基于标記清除算法,不帶壓縮動作,導緻越來越多的記憶體碎片需要壓縮。 常見以下場景會觸發記憶體碎片壓縮:
- 新生代Young GC出現新生代晉升擔保失敗(promotion failed))
- 程式主動執行System.gc()
可通過參數CMSFullGCsBeforeCompaction的值,設定多少次Full GC觸發一次壓縮。
預設值為:0,代表每次進入Full GC都會觸發壓縮,帶壓縮動作的算法為單線程Serial Old算法,暫停時間(STW)時間非常長,需要盡可能減少壓縮時間。
2.4 關鍵參數
-XX:CMSInitiatingOccupancyFraction
這個參數指的是一個百分比(0-100),表示當記憶體空間使用率達到百分之N時就開始執行垃圾回收,設定的過小,容易導緻記憶體使用率低,設定過高,如果并發回收時,記憶體無法滿足程式配置設定新對象的需要,就會出現一次并發失敗(Concurrent Mode Failure),當機使用者線程的執行,臨時啟用Serial Old收集器來重新進行老年代的垃圾收集, 但這樣停頓時間就很長了。
JDK5時這個值預設為68%,JDK6時,已經把預設值提升至92%,這個值要根據實際情況來設定。
-XX:ConcGCThreads
設定用于并發GC的線程數。預設值取決于JVM可用的CPU數量。
-XX:CMSFullGCsBeforeCompaction
這個參數的作用是要求CMS收集器在執行過若幹次(數量 由參數值決定)不整理空間的FullGC之後,下一次進入Full GC前會先進行碎片整理(預設值為0,表示每次進入Full GC時都進行碎片整理)。
3. Garbage-First (G1)
Garbage-First (G1)是一款非常具有特殊意義垃圾收集器的技術發展展現,因為相比G1之前的垃圾收集器,G1首次打破了基于老年代或者新生代一整塊記憶體進行收集的設計思想,G1設計上依然有分代的思想,但是在記憶體上不再進行分代上的實體劃分,也就是在一塊大的記憶體區域中,既有年輕代也有老年代,G1适用于具有大記憶體的多核伺服器,G1雖然與CMS一樣都是追求低停頓時間的垃圾收集器,但是由于G1在設計上的突破,使其能在更大的記憶體空間回收時,保持優秀的垃圾回收效率,這是G1之前的所有垃圾收集器所不能做到的。
3.1 G1中的分代設計
G1與其他的垃圾收集器相比不再有實體上的區域劃分,而是直接使用一整塊記憶體空間,并且劃分為多個大小相等的獨立區域(Region),每一個Region可以在邏輯上被劃分為Eden區、Suvivor區、Old區、Humongous區,并且每一個類型的Region也沒有固定的數量、大小與位址。
Humongous區是G1中新增的區域,專門用來存放大對象的,G1中定義一個對象如果超過Region大小的50%就屬于大對象。
每個Region的大小可以通過參數-XX:G1HeapRegionSize設定,取值範圍為1MB~32MB,且應為2的N次幂。
每一個Region表示的含義是不固定的,Eden區可能會變成Old區,G1可以根據優化政策自行調整它們之間的比例,是以一般使用G1時,不需要手動配置新生代與老年代大小。
3.2 可預測的停頓時間
在G1中使用參數-XX:MaxGCPauseMillis,可以控制最大的停頓時間,這依然是一個軟目标,但相比Parallel Scavenge設定而言,這要更加可控一些,因為現在的記憶體已經被劃分為許多小的Region區,G1通常可以在并發标記階段完成之後,就能計算出每個Region區回收時的大小,以此來評估出此次可優先回收的Region區域,當然如果你把這個值設定的太小,那麼G1最終也隻能犧牲每次回收的垃圾量而導緻垃圾回收變得更頻繁,這反而降低了總體性能。
3.3 回收過程
3.3.1. Initial marking phase
此階段為GCRoot根節點标記,需要暫停使用者線程,這個階段是在MinorGC(年輕代垃圾回收)階段完成的。
3.3.2. Root region scanning phase
此階段會對在初始标記階段标記的Suvivor區域進行掃描,看看是否有對老年代對象的引用并進行标記,此階段可以與服務同時運作,但一定會在下一個MinorGC(年輕代垃圾回收)開始之前完成。
3.3.3. Concurrent marking phase
此階段開始對整個堆區域進行掃描,此階段也是與服務同時運作,但可能會被MinorGC(年輕代垃圾回收)中斷。
3.3.4. Remark phase
此階段是STW階段,會暫停使用者線程,處理并發階段時引用産生變化的一些對象。
3.3.5. Copying/Cleanup phase
複制/清理階段完成之後
最後一個階段依然需要暫停使用者線程,統計Region中的資料以及對RSet進行清理,根據期望的停頓名額進行相應處理選擇。
3.4 常見問題
3.4.1 CSet集合
CSet集合即每次選出來的待回收的集合,Young GC和Mix GC都将會向CSet集合中添加内容。
3.4.2 RSet集合
之前在介紹CMS時提到了一個問題如何确定新生代對象是否存活?對于G1同樣存在這個問題,就是那些在老年代的對象引用了新生代的對象,與CMS一樣,G1也是把每一個Region劃分為一些Card Table塊,不同的是因為CMS的老年代隻有一個,是以隻需要維護一個對應的Card Table集合,而G1中的老年代會有很多個,這就需要維護很多個Card Table集合,是以G1在外面又加了一層集合,直接用來記錄目前新生代被哪些老年代引用了,這個集合就是RSet,RSet可以了解為是一個Map集合,Key就是Region分區的起始位址,Value又是一個集合,集合中的元素就是這個Region分區中Card Table的髒下标。
3.4.3 浮動垃圾
關于浮動垃圾的問題,前面在介紹三色标記法時已經提到過了,因為G1采用的是SATB的方式來解決漏标的問題,是以會産生浮動垃圾的問題(具體解釋看前面的三色标記法的介紹)。
3.4.4 Allocation (Evacuation) Failure
與CMS一樣,因為都是與使用者線程并行執行的,是以有可能會遇到使用者線程産生垃圾的速度比垃圾回收器回收的速度要快,一旦遇到這樣的情況,那就會産生一次Full GC。
3.4.5 Young GC
Young GC還是針對Eden區和Suvivor區的回收,一次YGC後,存活下來的Eden區和Suvivor區的對象将被複制到一塊新的區域,并會放入CSet集合中,同樣的經過多次YGC後仍然存活的對象将會被移動到old區。
3.4.6 Mix GC
當完成并發标記後,G1就會進入混合垃圾收集階段,在此階段G1會選擇将一些old區添加到将要收集的Eden區和Suvivor區中,當選擇了足夠多的old區域以後,G1就又會回到YGC的回收。
3.4.7 資源消耗
記憶體
相比G1之前的垃圾回收器,由于其特有的選擇回收方式,使得在大記憶體下G1依然能夠控制好回收時間,不過也因為G1中每個Region都需要維護一份RSet集合,這就導緻G1中的RSet可能會占整個堆容量的20%乃至更多的記憶體空間。
CPU
CMS和G1都有因為并發标記過程使用者線程改變對象引用關系的問題,二者都需要進行Card Table的維護,CMS和G1中都通過寫後屏障進行維護,不過G1中為了實作原始快照的算法還需要寫前屏障來跟蹤指針的變化情況,是以在使用者程式運作過程中會産生由跟蹤引用變化帶來的額外負擔。
3.4. 使用建議
- 一般情況使用G1時,不建議指定年輕代的大小或者調整其占比,因為這樣會使期望GC暫停的設定失效。
- G1雖然可以指定暫停時間的數值,但并不建議設定的太低,G1的吞吐量目标是90%的應用程式執行時間和10%的垃圾回收時間,設定過于激進的目标則會使垃圾回收變的非常頻繁,這将直接影響吞吐量。
- 關于混合垃圾回收的調整,請注意下面幾個參數值的設定:-XX:G1MixedGCLiveThresholdPercent、-XX:G1MixedGCLiveThresholdPercent、-XX:G1HeapWastePercent、-XX:G1MixedGCCountTarget、-XX:G1OldCSetRegionThresholdPercent
- 關于大對象,無論是配置設定還是回收都會帶來一定的危害,建議根據實際情況調整G1HeapRegionSize的值來避免過多的對象被定義為大對象。
3.5. 關鍵參數
标題 | 1 |
-XX:MaxGCPauseMillis | 期望的最大GC暫停時間,預設為:200ms,G1的預設政策是期望在吞吐量與延遲之間保持平衡,是以如果你希望獲得較高的吞吐量,那麼可以通過減少GC暫停的頻率來實作,而減少GC暫停頻率的主要方式就是增加最大GC暫停時間。 |
-XX:ParallelGCThreads | 垃圾收集暫停期間用于并行工作的最大線程數。預設根據運作JVM計算機的可用線程數決定,計算方式:當程序可用的CPU線程數小于等于8時,則直接使用該數,否則,将設定為:8 + (n - 8) * (5/8) 。 |
-XX:ConcGCThreads | 用于并發工作的最大線程數,預設情況下,此值為:-XX:ParallelGCThreads除以4。 |
-XX:G1HeapRegionSize | 預設會根據最大堆的大小,按照劃分出2048個region來計算出每個region的大小,最大值為32M,使用者可自定義的範圍是1~512M,且必須是2的幂。 |
-XX:G1NewSizePercent | 新生代最小堆的百分比占比,預設為Java堆的5%。 |
-XX:G1MaxNewSizePercent | 新生代最大堆的百分比占比,預設為Java堆的60%。 |
-XX:G1HeapWastePercent | 為了更有效的進行垃圾回收,G1會從CSet中選擇釋放一些對記憶體空間增益更大的region,其中有一項參考就是可回收空間要大于XX:G1HeapWastePercent設定的值,預設為:5%,表示占目前堆空間的5%。 |
-XX:G1MixedGCCountTarget | 在混合回收階段,G1期望能夠最大化的的進行回收,但同時還需要考慮XX:MaxGCPauseTimeMillis,是以通常會把一次大的混合回收,拆分為多次,這個次數就由XX:G1MixedGCCountTarget決定,預設為:8次,這樣就減少了每一次混合回收的暫停時間,以達到XX:MaxGCPauseTimeMillis的目标值。 |
-XX:G1MixedGCLiveThresholdPercent | 在混合回收階段,會避免回收那些需要大量時間來處理的region,那麼如何鑒定是否需要大量時間來處理呢?那麼在大多數情況下,占用率高的region就需要耗費更多的時間來處理,XX:G1MixedGCLiveThresholdPercent就是設定的存活對象占用率的門檻值,預設為:85%,也就是如果一個region中的存活對象占比達到此-XX:GCPauseTimeInterval= region的85%,那麼就不會回收這個region。 |
-XX:G1ReservePercent | 保留白閑區域的百分比,預設為10% |
-XX:G1OldCSetRegionThresholdPercent | 設定混合垃圾回收周期中要收集的old 區數量的上限。預設值為堆的10% |
-XX:InitiatingHeapOccupancyPercent | 設定觸發标記周期的堆占用門檻值,預設為占用整個堆的 45% |
3.6 G1日志分析
yaml複制代碼
G1 Evacuation Pause
young(年輕代回收):表示年輕代使用空間滿了。
mixed(年輕代+老年代一起回收):表示老年代使用占用到了堆空間的-XX:InitiatingHeapOccupancyPercent設定的值。
G1 Humongous Allocation
大對象申請都會觸發一次GC。
[GC pause (G1 Evacuation Pause) (young), 0.0264657 secs]秒
并行執行階段
GC啟動了10個線程并行回收,耗時20.7ms
[Parallel Time: 20.7 ms, GC Workers: 10]
記錄GC開始時間
[GC Worker Start (ms): Min: 99341.2, Avg: 99341.2, Max: 99341.3, Diff: 0.1]
根掃描
[Ext Root Scanning (ms): Min: 0.7, Avg: 1.3, Max: 5.1, Diff: 4.4, Sum: 13.1]
更新RSet集合
[Update RS (ms): Min: 0.0, Avg: 0.9, Max: 1.2, Diff: 1.2, Sum: 8.6]
[Processed Buffers: Min: 0, Avg: 5.1, Max: 18, Diff: 18, Sum: 51]
掃描RSet集合
[Scan RS (ms): Min: 0.0, Avg: 1.6, Max: 2.0, Diff: 2.0, Sum: 16.4]
Root對象對region引用的情況掃描
[Code Root Scanning (ms): Min: 0.0, Avg: 0.2, Max: 0.5, Diff: 0.5, Sum: 1.9]
存活對象複制并将存活對象從某個region區移動其他region區
[Object Copy (ms): Min: 15.5, Avg: 16.6, Max: 16.8, Diff: 1.4, Sum: 165.8]
GC終止
[Termination (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Termination Attempts: Min: 1, Avg: 1.0, Max: 1, Diff: 0, Sum: 10]
GC工作時,被其他JVM任務占用的時間,本身和GC無關
[GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.1, Diff: 0.1, Sum: 0.5]
GC過程總耗時
[GC Worker Total (ms): Min: 20.6, Avg: 20.6, Max: 20.7, Diff: 0.1, Sum: 206.3]
GC結束時間
[GC Worker End (ms): Min: 99361.8, Avg: 99361.8, Max: 99361.9, Diff: 0.0]
串行執行階段
Root對象修正,比如region内的對象被移動了,那則要更新一下引用位址
[Code Root Fixup: 0.1 ms]
Root對象清理
[Code Root Purge: 0.1 ms]
清理card table中已掃描的标志
[Clear CT: 0.2 ms]
其他耗時總計
[Other: 5.3 ms]
選擇要回收的CSet集合
[Choose CSet: 0.0 ms]
軟引用處理
[Ref Proc: 4.2 ms]
添加可以被回收的軟引用
[Ref Enq: 0.0 ms]
軟引用處理可能需要更新card table為髒
[Redirty Cards: 0.2 ms]
大對象統計(YGC階段也會帶着處理一點大對象)
[Humongous Register: 0.0 ms]
大對象回收耗時
[Humongous Reclaim: 0.0 ms]
CSet回收,并置位空閑
[Free CSet: 0.6 ms]
各個區域的回收前後對比記錄
[Eden: 492.0M(492.0M)->0.0B(500.0M) Survivors: 52.0M->39.0M Heap: 863.3M(1024.0M)->377.1M(1024.0M)]
[Times: user=0.31 sys=0.00, real=0.03 secs]
4、GC通用參數
标題 | |
-Xmn | 新生代大小,一般建議為整個堆大小的1/2~1/4之間,但如果使用G1垃圾收集器,則一般建議不要設定 |
-Xms | 該參數有兩個作用,分别為:堆的最小值以及初始值,預設為實體記憶體的1/64 |
-Xmx | 堆的最大值,預設為實體記憶體的1/4,對于大多數應用服務來說,-Xms,-Xmx應該設定為一樣的 |
-XX:SurvivorRatio | Eden區和Survivor區比例,預設是8,即表示eden區和兩個Survivor區的比例為,8:1:1 |
-XX:+UseTLAB | 使用TLAB配置設定,預設為開啟 |
XX:+DisableExplicitGC | 禁用System.gc(),預設為禁用 |
-XX:+PrintGCDetails | 列印GC詳情資訊,預設為不列印 |
-Xloggc:/opt/xxx/logs/xxx-xxx-gc-%t.log | 日志檔案的輸出路徑 |
-XX:+HeapDumpOnOutOfMemoryError | OOM時生成Dump檔案 |
-XX:HeapDumpPath=/memory.hprof | OOM檔案生成位址 |
問題排查篇
一、排查工具
1. JVM自帶工具
1.1 jmap
一般通過jmap可以生成堆的目前使用情況的快照,然後用它來分析或者調優JVM記憶體使用。
列印堆的直方圖。對于每個Java類,将列印對象數,以位元組為機關的記憶體大小以及完全限定的類名。JVM内部類名稱以*字首列印。如果指定了live子選項,則僅計算活動對象。
列印heap的使用情況,配置的參數資訊,使用的垃圾收集器等資訊。
MaxHeapSize:最大堆空間
NewSize:新生代配置設定大小
MaxNewSize:新生代最大配置設定大小
OldSize:老年代配置設定大小
NewRatio:新生代占整個堆空間的比例,2表示:新生代:老年代 = 1:2
SurvivorRatio:Survivor區占新生代空間的比例,8表示:Survivor:eden = 2:8
MetaspaceSize:元空間大小
後半部分是heap的使用情況
生成目前heap使用情況的快照,友善通過專業的記憶體分析工具進行分析。
1.2 jstack
jstack指令主要用于生成虛拟機目前時刻線程快照資訊,用于跟蹤并調試虛拟機堆棧資訊,通過這個指令可以檢測死鎖、死循環、線程長時間停頓等問題。
指令格式:jstack [ options ] pid
死鎖問題
死鎖代碼
java複制代碼public class DeadLock {
public static Object one = new Object();
public static Object two = new Object();
public static void main(String[] args) {
new Thread(() -> {
//擷取第一個鎖,并且不會被其他線程搶占
synchronized (one) {
try {
System.out.println(Thread.currentThread().getName() + "獲得one鎖,等待two鎖。");
//確定第二個線程此時先擷取到了第二個鎖
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
//請求擷取第二鎖,并且任然持有第一個鎖
synchronized (two) {
System.out.println(Thread.currentThread().getName() + "獲得two鎖。");
}
}
}).start();
new Thread(() -> {
synchronized (two) {
try {
System.out.println(Thread.currentThread().getName() + "獲得two鎖,等待one鎖。");
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (one) {
System.out.println(Thread.currentThread().getName() + "獲得one鎖。");
}
}
}).start();
}
}
死循環問題
死循環問題,一般我們在linux平台使用top指令,找到CPU占用率高的程序,然後再找程序裡面CPU占用率的線程,拿到線程ID,通過jstack指令列印後,搜尋相應的線程ID即可(jstack線程ID顯示的是16進制,top裡找到的是10進制,需要轉換一下)。
一段死循環代碼
java複制代碼public class EndlessLoop {
public static void main(String[] args) {
Thread t = new Thread(() -> {
while (true) {
}
});
t.setName("endless loop thread");
t.start();
}
}
先檢視top指令,找到占用CPU較多的程序(是我們的java程序),程序ID:7971
top -p 7971,再按H,找到最耗CPU的線程ID,7981
jstack 7971,找到7981這個線程,7981轉換成16進制就是0x1f2d,這樣就找到了具體的線程了,并且如果你按照規範給線程起了名稱,比如我們這裡叫:endless loop thread,這樣我們就能很快的定位到具體的代碼位置了。
1.3 jstat
jstat主要是用來監控虛拟機各種運作狀态資訊的一種工具,通過jstat指令主要可以用來觀察虛拟機在運作時垃圾收集狀況,以及類加載和編譯狀況。
指令格式為:jstat [ generalOption | outputOptions vmid [interval[s|ms] [count]] ]
下面我們通過幾個示範案例,看一下具體的使用方式,環境聲明:JDK1.8,使用-XX:+UseSerialGC垃圾回收器
常用的指令:jstat -gc pid interval[ms] count
每一列含義解釋
S0C:Survivor0區容量
S1C:Survivor1區容量
S0U:Survivor0區已使用容量
S1U:Survivor1區已使用容量
EC:Eden區容量
EU:Eden已使用容量
OC:老年代容量
OU:老年代已使用容量
MC:元空間容量
MU:元空間已使用容量
CCSC:壓縮類容量
CCSU:壓縮類已使用容量
YGC:新生代垃圾回收次數
YGCT:新生代垃圾回收耗時
FGC:FullGC發生次數
FGCT:FullGC耗時
GCT:總GC耗時
有時候還可以使用:jstat -gcutil pid interval[ms] count,檢視使用比例。
1.4 jvisualvm
如果你的伺服器已經開啟了遠端JMX,你可以通過jvisualvm工具查詢。
2. Arthas
阿裡開源的一款線上監控診斷産品,簡單好用,官網資料也很詳細,本文就不多贅述了。
3. Eclipse MAT(Memory Analyzer Tooling)
一款JAVA記憶體分析工具,可以對dump出來的hprof檔案進行分析,同樣詳細使用方式可以參見官網。
4. gceasy
gceasy是一款用于分析GC日志的工具,可以對gc日志進行分析,并分析出問題,給出推薦的解決方案,參見官網
二、常見問題
1. CPU過高
常見的CPU使用率較高問題一般有:大量循環嵌套處理邏輯、瘋狂開線程、頻繁FullGC、複雜算法等,遇到CPU過高的問題,可直接通過jstack抓取使用率高的線程進行檢視,具體方式前面介紹jstack時已經介紹過了,這裡就不再贅述了。
2. 記憶體過高
前面幾款工具介紹完之後,對于記憶體過高的原因也就好分析了,通過jmap或arthas都可以檢視記憶體使用情況,同時也都可以直接dump記憶體情況,然後通過Eclipse MAT進行分析。
小案例
一、TLAB配置設定、棧上配置設定性能測試
TLAB配置設定
TLAB:本地線程配置設定緩沖,記憶體配置設定實際上被按照不同的線程劃分在不同的記憶體之間進行,每個線程在Eden區中中有一塊獨享的小區域,這樣做的好處是可以減少同步處理帶來的性能消耗。
下面的小案例中啟動了100個線程,如果沒有TLAB優化,那麼啟動的線程越多,對象配置設定時的同步處理就越耗時
首先配置如下參數啟動,-XX:-UseTLAB、-XX:-DoEscapeAnalysis,表示關閉TLAB配置設定,關閉逃逸分析,確定對象隻能在堆上配置設定。
java複制代碼public class TestAlloc {
class User {
}
void alloc() {
new User();
}
public static void main(String[] args) throws InterruptedException {
TestAlloc t = new TestAlloc();
CountDownLatch countDownLatch = new CountDownLatch(100);
long start = System.currentTimeMillis();
for (int i = 0; i < 100; i++) {
new Thread(() -> {
for (int j = 0; j < 10_0000; j++) {
t.alloc();
}
countDownLatch.countDown();
}).start();
}
countDownLatch.await();
long end = System.currentTimeMillis();
System.out.println(end - start);
}
}
執行耗時大約1秒。
修改配置參數啟動,-XX:+UseTLAB、-XX:-DoEscapeAnalysis,表示開啟TLAB配置設定,關閉逃逸分析,確定對象隻能在堆上配置設定。
執行耗時大約100毫秒,性能差距大約10倍。
棧上配置設定
這是借助于逃逸分析來實作的,逃逸分析中指出如果對象的作用域不會逃出方法或者線程之外,也就是無法通過其他途徑通路到這個對象,那麼就可以對這個對象采取一定程度的優化,比如:将對象直接配置設定到棧上,對象可以随着棧的出棧過程被自然的銷毀,既省去了堆上配置設定的消耗,也節省了堆中垃圾回收所消耗的性能。
還是同樣的案例,alloc方法中new出來的User對象,作用域隻在該方法中,是以可以通過逃逸分析的結果,實作棧上配置設定。對象優先棧上配置設定,是以TLAB是否開啟不影響,使用預設配置就行。
首先配置如下參數啟動,-XX:-DoEscapeAnalysis,關閉逃逸分析。
java複制代碼public class TestAlloc {
class User {
}
void alloc() {
new User();
}
public static void main(String[] args) {
TestAlloc t = new TestAlloc();
long start = System.currentTimeMillis();
for (int j = 0; j < 1_0000_0000; j++) {
t.alloc();
}
long end = System.currentTimeMillis();
System.out.println(end - start);
}
}
執行耗時大約300毫秒。
當配置開啟逃逸分析時 -XX:+DoEscapeAnalysis,執行耗時隻有10毫秒左右。
二、使用ParallelGC頻繁出現FGC
使用的垃圾收集器:ParallelGC
服務參數:Non-default VM flags: -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=null -XX:InitialHeapSize=526385152 -XX:MaxHeapSize=8392802304 -XX:MaxNewSize=2797600768 -XX:MinHeapDeltaBytes=524288 -XX:NewSize=1572864 -XX:OldSize=524812288 -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC
初始化記憶體大小:500M,最大記憶體:8G。
伺服器運作一段時間後,可以看出FGC次數頻繁且耗時長,104次FGC,耗時達到258秒,平均每次FGC持續時間2秒多。
檢視一下實時記憶體使用情況,eden區2G多,老年代5G多,很明顯老年代正常狀态下有5G多的對象就很不正常,初步判斷就是大量的對象被過早的放入到了老年代。
問題分析
使用ParallelGC要特别注意AdaptiveSizePolicy參數的問題,還是上面那張圖,看看Eden和Survivor區的配置設定占比,明顯不是8:1:1了,這就是因為AdaptiveSizePolicy動态調整的原因。
AdaptiveSizePolicy 有三個目标:
Pause goal:應用達到預期的 GC 暫停時間。
Throughput goal:應用達到預期的吞吐量,即應用正常運作時間 / (正常運作時間 + GC 耗時)。
Minimum footprint:盡可能小的記憶體占用量。
是以為了達到期望的目标,Survivor區被調整的很小,導緻新生代的對象被大量的移到了老年代了。
又由于每次FGC後老年代空間被動态調整的問題,導緻老年代空間越來越大,同時也就意味着一次FGC的時間将會變得越來越長。
問題解決
-XX:SurvivorRatio,指定比例Eden區和Survivor的比例,不要讓AdaptiveSizePolicy動态調整。
合理控制老年代大小,對于ParallelGC這樣的垃圾收集器,老年代空間越大,一次FGC的停頓時間就越長。
控制新生代大小,新生代一般可以适當調大一些,讓那些朝生夕死的對象能夠全部在新生代被回收。
堆記憶體到底設定多大合适?這個一般要根據線上的實際使用情況來決定,其實如果不存在記憶體洩露問題,則隻需從每次gc的後存活對象的大小,就能大緻估算出實際所需要的記憶體空間(GC日志的重要性),為了用來應對系統峰值時的業務量激增導緻産生的對象也激增的場景,再做一些适當的備援即可。
垃圾收集器之是以要分代就是為了能夠快速的把一些朝生夕死的對象給處理掉,如果Survivor小到形容虛設,就失去了分代收集的意義,因為每次Eden區的對象隻要能熬過一次YGC就會被放到老年代(Survivor區太小不夠放),實際上可能在第二次YGC時就可以回收了,對于ParallelGC這樣的垃圾收集器,對象一旦進入老年代就隻能等待記憶體100%後觸發FGC才會被回收了。
三、記憶體使用率過高
背景
之前遇到過一次線上OOM問題,經排查分析發現是由于有一個接口使用JPA方式查詢資料庫,并且一次性傳回的資料量過多導緻(大約200W條資料),不過對于問題接口資料量雖然較多,但傳回的資料隻有一個未7個字元長度的String類型字段,是以200W條大約也就十幾M,理論上還不至于造成OOM。
問題分析
一般遇到這樣的情況,就需要用到一些記憶體分析工具來進行檢查了,于是我們dump出堆記憶體資訊,使用Eclipse MAT工具進行分析。
通過堆記憶體分析可以看出,使用JPA查詢時原對象會被進行各種包裝,并且被包裝後占用空間劇增,最終達到1G多。
四、線程過多導緻CPU使用率過高
按照前面介紹過的方式,線上遇到CPU使用率過高的問題,最直接的方式就是利用jstack進行分析。
線程數量異常
導出jstack檔案進一步分析,确認都是線程池中的線程
最終直接找到線程池建構的代碼,發現建構時最大線程數設定錯了,設定成了1000,是以改掉之後即可恢複正常。