重讀 JVM - ParNew & CMS GC

2020-02-22     sandag

這次來複習一下常用的 ParNew 和 CMS GC 的概念和一些調優建議

GC

GC 全稱是 Garbage Collect ,譯為 「垃圾回收」,在代碼編寫過程中,我們new 一個對象後,在使用和結束階段,都可以不需要關注內存分配和內存回收,因為 jvm 會自動識別到哪些對象不再被使用,然後進行清理,同時在內存中釋放掉這些空間。

判斷對象不可用的方法

  • 引用計數 Reference Count

簡單理解就是,每個對象都有一個計數器,如果該對象被其它對象引用後,計數器加一,如果計數器不為 0,表示它還在使用,不能被清理。

這樣會有個弊端,例如 A <-> B ,如果存在互相引用,但沒有被第三方繼續引用,那麼這兩個對象其實沒有其他使用,但由於計數器不為 0,無法得到清理。

  • 可達性分析 Reachability Analysis

以 GC Roots 為起點,根據引用關係往下搜索,搜索過程中走過的路稱為」引用連「(Reference Chain),如果與 GC Roots 對象可連接,說明對象還在使用,反之表示不可達,說明這些對象不使用,可以被回收掉。

其中關於 GC Roots 這些對象,有我們熟悉的,各個線程中調用的方法堆棧中的參數、局部變量、臨時變量等,還有其它作為根路徑的對象,可以參考書籍 3.2 章節

畫圖,說明 ParNew 和 CMS 回收垃圾的流程

前面說了有哪些方法可以說明對象不可用,這裡來說下用什麼垃圾回收器去回收:recycle:

在看完第二版之後,jdk8 之前的常用 gc 算法基本掌握,大多基於「分代收集」 Generational Collection ,主要分為了新生代 Young 區,存儲一些朝生夕死的對象,另一個是老年代 Old 區,存儲一些使用時間比較長,熬過了多次垃圾回收的對象

回收核心的步驟:

  • 標記出可以回收的對象
  • 清理被標誌不可用的對象

在清理過程中,還會出現複製的操作,這是細化的操作,要看具體使用的哪個 GC 算法。

目前用的比較熟悉的是 [ParNew + CMS] 垃圾回收器,所以來簡單記錄這兩者使用到的 gc 算法和回收流程。

區別於初始版本的線性 Serial 垃圾回收器,Serial 只能單線程操作,目前常用的都是多線程操作,跟多一雙手多一份力一樣,多線程能夠提高垃圾回收的速度,常看到的 ParNew 和 Parallel Scanvenge,其中 Parallel 表示並行的意思,並行操作以降低用戶線程(應用)因垃圾收集而導致的停頓。

ParNew 收集器

ParNew 收集器用於回收新生代資源,是 Serial 收集器的並行版本。

為何要選擇 ParNew 作為新生代的回收器,答案是目前好像除了 Serial 收集器外,只有它能夠與老年代的 CMS 回收器搭配使用,在 jvm 啟動參數中可以通過 +XX:+/-UseParNewGC 來開啟或者關閉使用該收集器。

順便來介紹一下其它幾個 jvm 參數:

  1. -XX:SurvivorRatio

在新生代 Young Generation 中,分為了一個 Eden 區和兩個 Survivor(From & TO),這是一種更優的半區複製分代策略,每次分配使用 Eden 區 + 其中一個 Survivor 區,發生垃圾回收時,將 Eden 和 Survivor 中還存活的對象拷貝到另一個 Survivor 區( From —> TO),然後清理掉剛才的 Eden 和一塊剛才使用過的 Survivor 區中數據。

該參數的默認數值是 8,表示的是 Eden :Survivor 比值,因為有兩個 Survivor 區域,所以一塊 Survivor 占新生代 1/10,Eden 占有 8/10。

  1. –XX:NewRatio

該參數表示的是新生代與老年代的比值

例如如果設置 -XX:NewRatio=2 ,新生代(Eden + 2 * Survivor):老年代 = 1 :2,所以新生代占堆的 1/3,老年代占堆的 2/3。

  1. -XX:MetaspaceSize -XX:MaxMetaspaceSize

在 jdk8 之前,存在一個區域叫「永久代」(Permgen),與 jdk8 之後出現的「元空間」(Metaspace)作用一樣,主要功能是存儲類實例的具體信息(即類對象),這部分也叫做「類的元數據」,只對編譯器或者 JVM 的運行時有用。

不同於元空間,永久代里還存儲了一些與類數據無關的雜項對象(miscellaneous object),這些對象在 jdk8 的時候,被挪回了普通的堆空間。除此之外,jdk8 開始從根本上改變了保存在這個特殊區域的元數據的類型。

作為開發,可能不需要太關注裡面存儲了什麼信息,不過得知道為啥「元空間」取代了「永久代」,翻看資料,發現了之前默認情況下,永久代大多分配的大小最多只有 82MB,如果遇到特別複雜的應用,加載的類特別多,所以存儲的類信息也會很多。在開發中的應用伺服器(或者任何需要頻繁重新載入類的環境)上經常會碰到由於永久代空間空間耗盡觸發的 Full GC。

默認情況下,元空間是沒有大小限制的,不過還是建議分配一個初始和最大值,例如 -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m ,雖然還是有可能觸發 Full GC,這個時候就要排查定位出什麼類導致元空間這麼龐大,然後進行解決。

新生代 GC

可以看下新生代 GC 前後的內存分布情況:(藍色表示使用情況)

驗證了前面說的場景,拷貝 Eden 區和其中一塊使用的 Survivor 區 S1 中的還在使用對象到另一個 Survivor 區 S0,如果新生代放不下或者對象熬過多次垃圾回收,就會進入到老年代。

翻看 gc 日誌,回收新生代的日誌格式如下:

2020-02-20T11:02:44.255+0800: 3346835.272: [GC (Allocation Failure) 2020-02-20T11:02:44.255+0800: 3346835.273: [ParNew: 1123543K->11470K(1258304K), 0.0113857 secs] 2211645K->1099617K(4054528K), 0.0118211 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]

剛開始對於 Failure 有點敏感,以為是錯誤的,搜索資料後發現,原來是新生代空間不足,觸發了 Minor GC ,屬於正常現象,順便也來複習一下各個欄位的含義。

  • Allocation Failure:

表示新生代沒有足夠的空間分配新對象,於是需要進行新生代對象回收,準備下一次分配

  • 2020-02-20T11:02:44.255+0800: 3346835.273

表示完成的時間戳,後面的數字 3346835.273 表示程序開始多少秒

  • ParNew:

表示這次發生的是 Minor GC 是在新生代觸發的,使用的是 ParNew 收集器,使用的是 「標記-複製」算法,同時該期間將會停止用戶線程,也就是 Stop The World (常用 STW 表示)

  • 1123543K->11470K(1258304K), 0.0113857 secs

k 表示使用的單位為 KB , 前三個數字分別表示新生代當前使用的容量,回收後的容量,以及新生代分配的總大小 。

後面的時間表示新生代 GC 耗時,sec 表示 second(秒)

  • 2211645K->1099617K(4054528K), 0.0118211 secs

第二次出現的數字串, 前三個數字分別表示老年代當前使用的容量,回收後的容量,以及老年代分配的總大小

  • [Times: user=0.00 sys=0.00, real=0.01 secs]

分別表示用戶態耗時(user),內核態耗(sys)時和總耗時(real)

因為多線程的原因,通常來說總耗時 real 會比前兩個少,所以我們實際關注更多的地方在 real 欄位上,實際對用戶線程造成了多少中斷。

CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。

目前很大一部分的Java應用集中在網際網路網站或者基於瀏覽器的B/S系統的服務端上,這類應用通常都會較為關注服務的響應速度,希望系統停頓時間儘可能短,以給用戶帶來良好的交互體驗。出於穩定性,G1 垃圾回收器有點超前,出了問題的維護成本會比較大,所以希望儘可能短的停頓時間,目前我們在用的是 CMS 垃圾回收期。

老年代回收

-XX:CMSInitiatingOccupancyFraction=70 默認情況下,當老年代的使用空間達到 70% 時,將會觸發老年代回收

Concurrent Mark Sweep ,並發標記清理,CMS 收集器是用於老年代GC,從上圖可以看出,使用 CMS 收集器後,老年代回收對象之後,不會進行壓縮整理,所以老年代出現了不連續的內存空間。

上面是 CMS 收集時的日誌格式,同時從時間上可以看出,1~2 天才出現一次老年代的 GC,表示老年代的 GC 頻率不高,驗證了大多數對象都是朝生夕死的,在 Minor GC 就被回收掉了,下面來記錄每個欄位的含義。

  • 初始標記 Initial Mark
[GC (CMS Initial Mark) [1 CMS-initial-mark: 2058107K(2796224K)] 2189383K(4054528K), 0.0167010 secs] [Times: user=0.00 sys=0.00, real=0.02 secs]

並發回收會由「初始標記」開始,這個階段會暫停所有的應用程式線程(也就是 Stop The World),該階段的任務時找到堆中所有的垃圾回收根節點對象(GC Roots)

第一組數字 2058107K(2796224K):表示老年代使用了 2058MB,整個老年代大小為 2796MB(簡單計算,除以 1000)。

第二組數字 2189383K(4054528K):表示整個堆的大小為括號中的 4054MB,被使用了 2180MB

0.0167010 secs:表示用戶線程被暫停了 0.0167010 秒

  • 標記階段 concurrent-mark-start
[CMS-concurrent-mark-start][CMS-concurrent-mark: 0.436/0.441 secs] [Times: user=0.95 sys=0.03, real=0.44 secs]

標記階段耗時 0.44 秒(以及 0.95 秒的 CPU 時間)。該階段進行的工作僅僅是標記,不會對堆的使用情況造成實質性的影響。

同時該階段,應用程式還在持續運行著,所以如果有其它日誌輸出,有可能是在這 0.44s 內新生代對象進行了分配。

  • 預清理 preclean
[CMS-concurrent-preclean-start][CMS-concurrent-preclean: 0.008/0.009 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]

預清理階段,應用程式也是在持續運行著

在預清理階段,在書籍裡面沒有找到具體解釋,所以查詢後,覺得資料二中說的比較好,引用一下

此階段標記從新生代晉升的對象、新分配到老年代的對象以及在並發階段被修改了的對象。

介紹起來有點太複雜,涉及到 jvm 底層保存對象時使用到的數據結構,感興趣的請好好看下第二條資料~

  • 重新標記 rescan
[CMS-concurrent-abortable-preclean-start] CMS: abort preclean due to time 2020-02-18T17:32:02.230+0800: 3197393.247: [CMS-concurrent-abortable-preclean: 5.638/5.968 secs] [Times: user=9.84 sys=0.15, real=5.96 secs][GC (CMS Final Remark) [YG occupancy: 586303 K (1258304 K)]2020-02-18T17:32:02.234+0800: 3197393.251: [Rescan (parallel) , 0.0904047 secs]2020-02-18T17:32:02.325+0800: 3197393.342: [weak refs processing, 0.0029463 secs]2020-02-18T17:32:02.328+0800: 3197393.345: [class unloading, 0.0942777 secs]2020-02-18T17:32:02.422+0800: 3197393.439: [scrub symbol table, 0.0275075 secs]2020-02-18T17:32:02.450+0800: 3197393.467: [scrub string table, 0.0036712 secs][1 CMS-remark: 2199444K(2796224K)] 2785747K(4054528K), 0.2585849 secs] [Times: user=0.46 sys=0.00, real=0.26 secs]

該階段不是並發的,將會阻塞用戶線程,也就是 STW

其中出現的 abortable clean 表示「可中斷清理」,使用它的原因是 希望儘量縮短停頓的時間,避免連續的停頓

前面已經出現了「初始標記」,當時只是簡單的標記一下 GC Roots 能直接關聯到的對象,速度比較快。

「並發標記」階段就是從 GC Roots 的直接關聯對象開始遍歷整個對象圖的過程,這個過程耗時較長但是不需要停頓用戶線程,可以與垃圾收集線程一起並發運行

「重新標記」階段,使用它是為了 修正在並發標記期間,因為用戶線程繼續運行而導致的新生代對象分配或者對象修改引用,會造成原有對象的標記記錄變動

  • 並發清除 sweep
[CMS-concurrent-sweep-start][CMS-concurrent-sweep: 1.613/1.618 secs] [Times: user=2.30 sys=0.01, real=1.62 secs]

清理 sweep 階段與用戶線程是並發運行的,不會 STW

也有可能出現這種場景:

在 con-sweep 階段中,發生了新生代 GC,說明新生代的 GC 和老年代的 GC 可以並發進行

  • 並發重置 concurrent reset
[CMS-concurrent-reset-start][CMS-concurrent-reset: 0.008/0.008 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]

該階段也是並發的,不會中斷用戶線程

該階段的日誌出現,表示 CMS GC 的周期到此結束,老年代沒有被引用的對象將會被回收。

總結 和 調優建議

新生代的垃圾回收比較簡單,回收過程中,會短暫的 STW,而老年代的 GC 比較複雜,經歷了下面的階段:

  • 初步標記(有 STW)
  • 並發標記(並發)
  • 再次標記(有 STW)
  • 並發清理(並發)

由於 CMS 算法不會對老年代進行壓縮整理,碎片空間越來越多,如果出現老年代空間不足以讓新生代的對象晉升,CMS 收集器將無法回收,那麼老年代將會退化到 Full GC(由於手上暫時沒有例子,所以不展示了)

目前來說,默認參數配置已經夠用了,例如下面這個:

MEM_OPTS="-server                   # 以服務端形式運行-Xms4096m                 # 起始堆大小-Xmx4096m                 # 最大堆大小-XX:MetaspaceSize=256m    # 元空間大小 -XX:MaxMetaspaceSize=256m # 最大元空間大小-XX:NewRatio=2            # 新生代 : 老年代 = 1 : 2,該數值要注意,2 是老年代占的比例-XX:SurvivorRatio=8       # Eden : Survivor = 8 : 1,表示一個 Survivor 占新生代的 1/10"      GC_OPTS="-XX:+UseConcMarkSweepGC                 # 使用 CMS-XX:+UseCMSCompactAtFullCollection      # 在 Full GC 時進行壓縮整理-XX:CMSInitiatingOccupancyFraction=70   # 老年代觸發 GC 的百分比-XX:MaxTenuringThreshold=15             # 最大老年代回收線程數量,回收線程不是越多越好,要結合伺服器性能一起評估,具體算法請查相關文章-XX:+DisableExplicitGC                  # 禁止在代碼中顯式調用 System.gc()-XX:+CMSParallelRemarkEnabled           # 開啟並發重標記-verbose:gc                             # 設置 gc 輸出的日誌參數...-XX:+PrintGCDateStamps -XX:+PrintGCDetails -Xloggc:${LOGS_DIR}/gc.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=20M"

下面是幾個調優建議

  1. 升級配置

如果你的應用之前運行在 2C4G 的伺服器上,發現相應速度越來越慢,那這個時候可以升級到 4C8G,配置越高,伺服器的性能當然也會更好,這時你就可以可以調整 MEM_OPTS ,將內存參數放大

先別噴這個建議,有時候業務量上來了,原有的機器性能的確扛不住,這樣的話升級硬體配置也是理所當然的

  1. 調整 CMSInitiatingOccupancyFraction 參數

CMSInitiatingOccupancyFraction 默認情況下是 70,老年代占用達到 70%後將會觸發 CMS GC,但這個時候有可能出現新生代在不斷分配對象,然後有對象能夠晉升到老年代,將會出現老年代空間不足而觸發的 Full GC。

所以可以適當減小這個值,讓並發後台線程儘早運行,去回收老年代不再使用的對象。

  1. 優化代碼邏輯

除去伺服器配置問題,業務代碼上如果出現大量耗時操作,例如頻繁的資料庫交互,大數據計算,這樣 GC 將會更加頻繁,並且時間可能越來越長,導致用戶線程被占用,系統中斷時間增加,會造成用戶不好的使用體驗。

所以根本上,需要從應用代碼著手,例如做以下幾個方面的優化

  • 將頻繁的資料庫操作改成批處理,一次性獲取數據或修改數據
  • 簡化代碼計算邏輯,去掉無用計算量
  • 減少嵌套循環,優化數據結構

還有更多 GC 的內容沒有記錄,所以強烈建議大家去看下周志明寫的《深入理解 JVM》第三個章節,繼續深入學習這些經典的 GC 算法。同時,我們在調優過程中,都是在吞吐量和應用停頓時間,這兩者之間在做平衡,所以具體調整方案需要在我們了解 GC 細節後,選擇合適的算法和配置參數,來達到預期的效果。

文章來源: https://twgreatdaily.com/9-otb3ABgx9BqZZIHVFS.html