細説jvm(五)、垃圾回收器入門

語言: CN / TW / HK

之前的文章

1、細説jvm(一)、jvm運行時的數據區域

2、細説jvm(二)、java對象創建過程

3、細説jvm(三)、對象創建的內存分配

4、細説jvm(四)、垃圾回收算法

接下來會用幾篇的功夫來講講垃圾回收器,這塊是個比較重要的地方,我也會在垃圾回收器這部分內容講關於GC的優化,在涉及到CMS以及G1的時候篇幅會比較大,因為這是現在最常用的垃圾回收器,我得多講點才能對你有所幫助。

我的文章裏總共會講到Serial,Serial Old,Parallel Scavenge,Parallel Old,ParNew,CMS,G1,以及ZGC這些垃圾回收器,用的多的我會細講,用的少的我就只會説説工作過程。所以本篇先只説Serial,Serial Old,Parallel Scavenge,Parallel Old,ParNew這五款回收器,CMS和G1和ZGC我會單獨用文章來説。

Serial 和 Serial old

從Serial這個單詞我們可以理解出,這個垃圾回收器在運行的時候,它必須要暫停其他的工作線程直到它收集結束,另外這個玩意是單線程的,是針對新生代的回收器,老年代則是使用Serial old,老年代和新生代採取的算法也不相同,新生代使用的是複製算法,而老年代使用的是標記整理算法。這個收集器在單cpu的工作條件下表現會比較優秀,因為避免了線程上下文切換帶來的開銷。

ParNew

這個垃圾回收器可以説是Serial 的多線程版本,也是一款並行收集器。

並行和併發的區別是:併發是指的是和用户線程一起運行,即併發過程不會暫停用户線程,但是並行是需要暫停用户線程的,也就
是説,ParNew在GC的時候是需要STW的。
複製代碼

除過多線程之外,這個收集器其他行為和Serial可以説是完全一樣的(我自己就一直是這麼理解的)。我們可以使用-XX:ParallelGCThreads來調整進行垃圾回收的線程數量,值得注意的是,垃圾回收的線程的數量絕對不是越多越好,越多的線程只會增大線程上下文切換的開銷。另外,ParNew是針對新生代的收集器,使用ParNew的時候,老年代需要另外選擇其他的收集器,可以是Serial或者是CMS,一般會選擇CMS。

Parallel 和 Parallel Old

Parallel的全稱是Parallel Scavenge,這玩意是一個針對新生代的回收器,與之對應的Parallel Old則是針對老年代的回收器。在jdk1.8中,這兩個回收器是一套的,什麼意思呢?就是當我們用了-XX:+UseParallelGC之後就會自動的在老年代給我們搭配上Parallel Old。事實上即使在jdk1.8之前,能和Parallel Scavenge搭配的老年代回收器也只有Serial Old和Parallel Old。Parallel是不能和CMS搭配使用的,這個原因是因為Parallel和CMS使用了不同的jvm分代框架。

為什麼不能和CMS搭配使用?這裏看R大在這個鏈接裏的回答 http://hllvm-group.iteye.com/group/topic/27629#post-199089
複製代碼

Parallel的這兩個回收器是屬於並行收集器,它們設計的目標是為了儘可能的提高吞吐量。所謂吞吐量,指的是運行用户代碼和總的運行時間的比值,總的運行時間包含垃圾回收時間和運行用户代碼的時間。算法的使用上,新生代的Parallel Scavenge使用的是複製算法,老年代的Parallel Old則使用的是標記整理算法。Parallel提供了兩個參數-XX:MaxGCPauseMillis和-XX:GCTimeRatio以便於我們可以更精準的控制吞吐量,我們一個一個的來看一下它們的用法。

-XX:MaxGCPauseMillis

這個參數的值是一個毫秒的值,意思是所允許每次的最大停頓時間,如果設置了這個值的話,jvm將盡力滿足我們的這個要求。但是千萬別以為這個值是越小越好的,因為太小的停頓往往意味着犧牲了吞吐量,具體原因我們在本文的最後一部分説。

-XX:GCTimeRatio

這個參數的值一個0到100的整數,意思是所允許的垃圾回收時間佔總的程序運行時間的比例,默認是99,意思就是最大允許百分之一的垃圾回收時間。

除過這兩個參數之外,我們還可以設置自適應的策略,-XX:+UseAdaptiveSizePolicy,這個參數設置之後,我們就不需要再設置新生代大小(-Xmn),以及eden和survivor的比例(-XX:SurvivorRatio)以及晉升到老年代對象大小(-XX:PretenureSizeThreshold)等細節的參數了。

關於Parallel收集器,我們再多説説它的內存分配策略和悲觀策略,因為這點也是它很不一樣的地方。

內存分配策略

常規的收集器在當年輕代放不下的時候,往往出觸發一次young gc,但是到了Parallel這裏,有一些特殊,具體分為兩點,一是當整個新生代放不下某個對象的時候,這個對象會直接進入老年代,另一方面是當整個新生代都可以放下但只是eden的空間不夠時,則嘗試young一次。我們使用代碼來證明剛才説的這兩個點是對的,來看看代碼

jvm參數,證明第一點用
//-Xms100m
//-Xmx100m
//-Xmn50m
//-XX:SurvivorRatio=8
//-verbose:gc              // 和printGCDetails這個參數的作用是一樣的
//-XX:+PrintGCDetails
//-XX:+PrintGCDateStamps
//-XX:+PrintHeapAtGC       //每次gc前後打印堆內存空間使用的情況
//-XX:+UseParallelOldGC
public static void main(String[] args) {
      byte[][] use = new byte[7][];
      use[0] = allocM(10);
      use[1] = allocM(10);
      use[2] = allocM(10);
      use[3] = allocM(20);
}

private static byte[] allocM(int n) {
      return new byte[1024 * 1024 * n];
}
複製代碼

輸出結果如下: 我一點一點解釋下,在main方法的最後一行代碼這裏,分配了20M空間給新的對象,此時其實新生代eden的空間已經不夠了,但是並沒有發生GC,而是直接將對象分配在了老年代。輸出結果的圖這個只是程序運行結束時候的內存使用情況,我們可以清楚的看出來根本就沒有發生GC,另外我們可以看到,老年代被使用了20480K,這正好是我們最後分配的對象的大小,可能會有同學奇怪為什麼新生代被使用的大小大於30M,這個是因為在堆內存中還有着一些常量,它們佔據了額外的內存,因此新生代大小是大於30M的,注意哈,這些常量也是受GC約束的。到這裏第一點就證明了。我們再來看看第二點,這時候,我們保持參數不變,把上面的代碼改成下面這個樣子:

public static void main(String[] args) throws InterruptedException {
    allocM(10);
    allocM(10);
    allocM(10);
    allocM(10);
}

private static byte[] allocM(int n) {
    return new byte[1024 * 1024 * n];
}
複製代碼

可以看到輸出如下: 在main方法的最後一行代碼中,我們分配了10M的空間給新的對象,此時eden已經被使用了30M,剩下eden的空間明顯不足以分配新對象,但是此時Survivor的兩個區域(共10M,注意eden和兩個Survivor區域加起來的和是新生代)還沒有被使用,滿足第二個點的條件,於是觸發了一次young gc,也有人叫minor gc。

minor gc == young gc ,都是針對新生代的垃圾回收
複製代碼
悲觀策略

這個策略具體指的是:在執行Young GC之後,如果預計下次晉升老年代的平均大小,比當前老年代的剩餘空間要大的話,則會觸發一次Full GC。

其他收集器沒有這個策略,其他收集器僅僅是在執行young gc之前,如果估計之前晉升老年代的平均大小,比當前老年代的剩餘空間要大的話,則會放棄young gc,轉而觸發full gc,Parallel不僅僅有這個,還多了上面的悲觀策略
複製代碼

我們依然使用代碼證明下,保持上面的參數,僅僅把代碼改成如下樣子:

public static void main(String[] args) throws InterruptedException {
    byte[][] use = new byte[7][];
    use[0] = allocM(10);
    use[1] = allocM(10);
    use[2] = allocM(10);
    System.out.println("要發生GC了。。。。。。。");
    use[3] = allocM(10);
}

private static byte[] allocM(int n) {
    return new byte[1024 * 1024 * n];
}
複製代碼

輸出結果如下 大家不要被這麼長的日誌嚇到,這都是紙老虎而已(後邊我還會教大家用可視化工具分析GC,所以別怕),其實就是因為我們在每次gc的前後打印了堆內存空間的使用情況而已,所以顯得日誌很長。我們可以看到,最後一次分配對象之前發生了兩次GC,其中第一次是young gc,第二次是full gc,young gc這個還順便證明了上面説過的第二點,但是在young gc之後,發現之前晉升到老年代的大小已經大於當前老年代剩下的空間了,所以觸發了一次full gc,注意藍色方框內的字Ergonomics而不是Allocation Failure,這個意味這這次full gc並不是由於分配失敗引起的,而是由於jvm自身的機制引起的。

畫外音:用Parallel 這兩個收集器的時候full gc可能比你想象中的會多一些

jvm不可能三角之間的矛盾

jvm的內存大容量,以及低停頓和吞吐量之間這三者是個不可能三角,這個的意思就是很難同時把三點全部都做的非常好,內存的大容量,意味着垃圾回收器就必須回收更多的空間,這就必須造成更大的停頓時間,而為了縮小停頓時間,就必須併發執行,併發執行,增加了線程上下文切換帶來的開銷,於是吞吐量就會被降低。我們一般來説更關注的是延遲,因為很難去忍受應用有個一兩秒的停頓(這個在2G以上堆內存的時候發生full gc尤其容易有這樣時間的停頓)。你可以想象一下,我們的服務是一個很長鏈路中的一部分,然後我們的應用由於full gc停了一秒多,如果是高併發的情況下,上游很快就會堆積很多請求。另外就是這和用户體驗是息息相關的,用户是很難以忍受我們的應用頻繁卡頓的,你想如果雙十一搶東西的時候頁面卡頓那用户的心情簡直可以美如畫,然後把你噴的體無完膚最後還會附上一句看看人家系統都不卡的致命嘲諷。