三 實戰:第09講:案例實戰:面對突如其來的 GC 問題如何下手解決

本課時我們主要從一個實戰案例入手分析面對突如其來的 GC 問題該如何下手解決。

想要下手解決 GC 問題,我們首先需要掌握下面這三種問題。

  • 如何使用 jstat 命令查看 JVM 的 GC 情況?
  • 面對海量 GC 日誌參數,如何快速抓住問題根源?
  • 你不得不掌握的日誌分析工具。

工欲善其事,必先利其器。我們前面課時講到的優化手段,包括代碼優化、擴容、參數優化,甚至我們的估算,都需要一些支撐信息加以判斷。

對於 JVM 來說,一種情況是 GC 時間過長,會影響用戶的體驗,這個時候就需要調整某些 JVM 參數、觀察日誌。

另外一種情況就比較嚴重了,發生了 OOM,或者操作系統的內存溢出。服務直接宕機,我們要尋找背後的原因。

這時,GC 日誌能夠幫我們找到問題的根源。本課時,我們就簡要介紹一下如何輸出這些日誌,以及如何使用這些日誌的支撐工具解決問題。

GC 日誌輸出

你可能感受到,最近幾年 Java 的版本更新速度是很快的,JVM 的參數配置其實變化也很大。就拿 GC 日誌這一塊來說,Java 9 幾乎是推翻重來。網絡上的一些文章,把這些參數寫的亂七八糟,根本不能投入生產。如果你碰到不能被識別的參數,先確認一下自己的 Java 版本。

在事故出現的時候,通常並不是那麼溫柔。你可能在半夜裏就能接到報警電話,這是因爲很多定時任務都設定在夜深人靜的時候執行。

這個時候,再去看 jstat 已經來不及了,我們需要保留現場。這個便是看門狗的工作,看門狗可以通過設置一些 JVM 參數進行配置。

那在實踐中,要怎麼用呢?請看下面命令行。

Java 8

我們先看一下 JDK8 中的使用。

#!/bin/sh
LOG_DIR="/tmp/logs"
JAVA_OPT_LOG=" -verbose:gc"
JAVA_OPT_LOG="${JAVA_OPT_LOG} -XX:+PrintGCDetails"
JAVA_OPT_LOG="${JAVA_OPT_LOG} -XX:+PrintGCDateStamps"
JAVA_OPT_LOG="${JAVA_OPT_LOG} -XX:+PrintGCApplicationStoppedTime"
JAVA_OPT_LOG="${JAVA_OPT_LOG} -XX:+PrintTenuringDistribution"
JAVA_OPT_LOG="${JAVA_OPT_LOG} -Xloggc:${LOG_DIR}/gc_%p.log"
JAVA_OPT_OOM=" -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=${LOG_DIR} -XX:ErrorFile=${LOG_DIR}/hs_error_pid%p.log "
JAVA_OPT="${JAVA_OPT_LOG} ${JAVA_OPT_OOM}"
JAVA_OPT="${JAVA_OPT} -XX:-OmitStackTraceInFastThrow"

合成一行。

-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps 
-XX:+PrintGCApplicationStoppedTime -XX:+PrintTenuringDistribution 
-Xloggc:/tmp/logs/gc_%p.log -XX:+HeapDumpOnOutOfMemoryError 
-XX:HeapDumpPath=/tmp/logs -XX:ErrorFile=/tmp/logs/hs_error_pid%p.log 
-XX:-OmitStackTraceInFastThrow

然後我們來解釋一下這些參數:

再來看下 OOM 時的參數:

注意到我們還設置了一個參數 OmitStackTraceInFastThrow,這是 JVM 用來縮簡日誌輸出的。

開啓這個參數之後,如果你多次發生了空指針異常,將會打印以下信息。

java.lang.NullPointerException
java.lang.NullPointerException
java.lang.NullPointerException
java.lang.NullPointerException

在實際生產中,這個參數是默認開啓的,這樣就導致有時候排查問題非常不方便(很多研發對此無能爲力),我們這裏把它關閉,但這樣它會輸出所有的異常堆棧,日誌會多很多。

Java 13

再看下 JDK 13 中的使用。

從 Java 9 開始,移除了 40 多個 GC 日誌相關的參數。具體參見 JEP 158。所以這部分的日誌配置有很大的變化。

我們同樣看一下它的生成腳本。

#!/bin/sh
LOG_DIR="/tmp/logs"
JAVA_OPT_LOG=" -verbose:gc"
JAVA_OPT_LOG="${JAVA_OPT_LOG} -Xlog:gc,gc+ref=debug,gc+heap=debug,gc+age=trace:file=${LOG_DIR}/gc_%p.log:tags,uptime,time,level"
JAVA_OPT_LOG="${JAVA_OPT_LOG} -Xlog:safepoint:file=${LOG_DIR}/safepoint_%p.log:tags,uptime,time,level"
JAVA_OPT_OOM=" -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=${LOG_DIR} -XX:ErrorFile=${LOG_DIR}/hs_error_pid%p.log "
JAVA_OPT="${JAVA_OPT_LOG} ${JAVA_OPT_OOM}"
JAVA_OPT="${JAVA_OPT} -XX:-OmitStackTraceInFastThrow"
echo $JAVA_OPT

合成一行展示。

-verbose:gc -Xlog:gc,gc+ref=debug,gc+heap=debug,gc+age=trace:file
=/tmp/logs/gc_%p.log:tags,uptime,time,level -Xlog:safepoint:file=/tmp
/logs/safepoint_%p.log:tags,uptime,time,level -XX:+HeapDumpOnOutOfMemoryError 
-XX:HeapDumpPath=/tmp/logs -XX:ErrorFile=/tmp/logs/hs_error_pid%p.log 
-XX:-OmitStackTraceInFastThrow

可以看到 GC 日誌的打印方式,已經完全不一樣,但是比以前的日誌參數規整了許多。

我們除了輸出 GC 日誌,還輸出了 safepoint 的日誌。這個日誌對我們分析問題也很重要,那什麼叫 safepoint 呢?

safepoint 是 JVM 中非常重要的一個概念,指的是可以安全地暫停線程的點。

當發生 GC 時,用戶線程必須全部停下來,纔可以進行垃圾回收,這個狀態我們可以認爲 JVM 是安全的(safe),整個堆的狀態是穩定的。

如果在 GC 前,有線程遲遲進入不了 safepoint,那麼整個 JVM 都在等待這個阻塞的線程,會造成了整體 GC 的時間變長。

所以呢,並不是只有 GC 會掛起 JVM,進入 safepoint 的過程也會。這個概念,如果你有興趣可以自行深挖一下,一般是不會出問題的。

如果面試官問起你在項目中都使用了哪些打印 GC 日誌的參數,上面這些信息肯定是不很好記憶。你需要進行以下總結。比如:

“我一般在項目中輸出詳細的 GC 日誌,並加上可讀性強的 GC 日誌的時間戳。特別情況下我還會追加一些反映對象晉升情況和堆詳細信息的日誌,用來排查問題。另外,OOM 時自動 Dump 堆棧,我一般也會進行配置”。

GC 日誌的意義

我們首先看一段日誌,然後簡要看一下各個階段的意義。

  • 1 表示 GC 發生的時間,一般使用可讀的方式打印;
  • 2 表示日誌表明是 G1 的“轉移暫停: 混合模式”,停頓了約 223ms;
  • 3 表明由 8 個 Worker 線程並行執行,消耗了 214ms;
  • 4 表示 Diff 越小越好,說明每個工作線程的速度都很均勻;
  • 5 表示外部根區掃描,外部根是堆外區。JNI 引用,JVM 系統目錄,Classloaders 等;
  • 6 表示更新 RSet 的時間信息;
  • 7 表示該任務主要是對 CSet 中存活對象進行轉移(複製);
  • 8 表示花在 GC 之外的工作線程的時間;
  • 9 表示並行階段的 GC 總時間;
  • 10 表示其他清理活動;
  • 11表示收集結果統計;
  • 12 表示時間花費統計。

可以看到 GC 日誌描述了垃圾回收器過程中的幾乎每一個階段。但即使你瞭解了這些數值的意義,在分析問題時,也會感到喫力,我們一般使用圖形化的分析工具進行分析。

尤其注意的是最後一行日誌,需要詳細描述。可以看到 G C花費的時間,竟然有 3 個數值。這個數值你可能在多個地方見過。如果你手頭有 Linux 機器,可以執行以下命令:
time ls /

可以看到一段命令的執行,同樣有三種緯度的時間統計。接下來解釋一下這三個字段的意思。

  • real 實際花費的時間,指的是從開始到結束所花費的時間。比如進程在等待 I/O 完成,這個阻塞時間也會被計算在內;
  • user 指的是進程在用戶態(User Mode)所花費的時間,只統計本進程所使用的時間,注意是指多核;
  • sys 指的是進程在覈心態(Kernel Mode)花費的 CPU 時間量,指的是內核中的系統調用所花費的時間,只統計本進程所使用的時間。

在上面的 GC 日誌中,real < user + sys,因爲我們使用了多核進行垃圾收集,所以實際發生的時間比 (user + sys) 少很多。在多核機器上,這很常見。
[Times: user=1.64 sys=0.00, real=0.23 secs]

下面是一個串行垃圾收集器收集的 GC 時間的示例。由於串行垃圾收集器始終僅使用一個線程,因此實際使用的時間等於用戶和系統時間的總和:
[Times: user=0.29 sys=0.00, real=0.29 secs]

那我們統計 GC 以哪個時間爲準呢?一般來說,用戶只關心繫統停頓了多少秒,對實際的影響時間非常感興趣。至於背後是怎麼實現的,是多核還是單核,是用戶態還是內核態,它們都不關心。所以我們直接使用 real 字段。

GC日誌可視化

肉眼可見的這些日誌信息,讓人非常頭暈,尤其是日誌文件特別大的時候。所幸現在有一些在線分析平臺,可以幫助我們分析這個過程。下面我們拿常用的 gceasy 來看一下。

以下是一個使用了 G1 垃圾回收器,堆內存爲 6GB 的服務,運行 5 天的 GC 日誌。

(1)堆信息

我們可以從圖中看到堆的使用情況。

(2)關鍵信息

從圖中我們可以看到一些性能的關鍵信息。

吞吐量:98.6%(一般超過 95% 就 ok 了);

最大延遲:230ms,平均延遲:42.8ms;

延遲要看服務的接受程度,比如 SLA 定義 50ms 返回數據,上面的最大延遲就會有一點問題。本服務接近 99% 的停頓在 100ms 以下,可以說算是非常優秀了。

你在看這些信息的時候,一定要結合宿主服務器的監控去看。比如 GC 發生期間,CPU 會突然出現尖鋒,就證明 GC 對 CPU 資源使用的有點多。但多數情況下,如果吞吐量和延遲在可接受的範圍內,這些對 CPU 的超額使用是可以忍受的。

(3)交互式圖表

可以對有問題的區域進行放大查看,圖中表示垃圾回收後的空間釋放,可以看到效果是比較好的。

(4)G1 的時間耗時

如圖展示了 GC 的每個階段花費的時間。可以看到平均耗時最長的階段,就是 Concurrent Mark 階段,但由於是併發的,影響並不大。隨着時間的推移,YoungGC 竟然達到了 136485 次。運行 5 天,光花在 GC 上的時間就有 2 個多小時,還是比較可觀的。

(5)其他

如圖所示,整個 JVM 創建了 100 多 T 的數據,其中有 2.4TB 被 promoted 到老年代。
另外,還有一些 safepoint 的信息等,你可以自行探索。

那到底什麼樣的數據纔是有問題的呢?gceasy 提供了幾個案例。比如下面這個就是停頓時間明顯超長的 GC 問題。

下面這個是典型的內存泄漏。

上面這些問題都是非常明顯的。但大多數情況下,問題是偶發的。從基本的衡量指標,就能考量到整體的服務水準。如果這些都沒有問題,就要看曲線的尖峯。

一般來說,任何不平滑的曲線,都是值得懷疑的,那就需要看一下當時的業務情況具體是什麼樣子的。是用戶請求突增引起的,還是執行了一個批量的定時任務,再或者查詢了大批量的數據,這要和一些服務的監控一起看才能定位出根本問題。

只靠 GC 來定位問題是比較困難的,我們只需要知道它有問題就可以了。後面,會介紹更多的支持工具進行問題的排解。

爲了方便你調試使用,我在 GitHub 上上傳了兩個 GC 日誌。其中 gc01.tar.gz 就是我們現在正在看的,解壓後有 200 多兆;另外一個 gc02.tar.gz 是一個堆空間爲 1GB 的日誌文件,你也可以下載下來體驗一下。

GitHub 地址:

https://gitee.com/xjjdog/jvm-lagou-res

另外,GCViewer 這個工具也是常用的,可以下載到本地,以 jar 包的方式運行。

在一些極端情況下,也可以使用腳本簡單過濾一下。比如下面行命令,就是篩選停頓超過 100ms 的 GC 日誌和它的行數(G1)。

# grep -n real gc.log | awk -F"=| " '{ if($8>0.1){ print }}'
1975: [Times: user=2.03 sys=0.93, real=0.75 secs]
2915: [Times: user=1.82 sys=0.65, real=0.64 secs]
16492: [Times: user=0.47 sys=0.89, real=0.35 secs]
16627: [Times: user=0.71 sys=0.76, real=0.39 secs]
16801: [Times: user=1.41 sys=0.48, real=0.49 secs]
17045: [Times: user=0.35 sys=1.25, real=0.41 secs]

jstat

上面的可視化工具,必須經歷導出、上傳、分析三個階段,這種速度太慢了。有沒有可以實時看堆內存的工具?

你可能會第一時間想到 jstat 命令。第一次接觸這個命令,我也是很迷惑的,主要是輸出的字段太多,不瞭解什麼意義。

但其實瞭解我們在前幾節課時所講到內存區域劃分和堆劃分之後,再看這些名詞就非常簡單了。

我們拿 -gcutil 參數來說明一下。

jstat -gcutil $pid 1000

只需要提供一個 Java 進程的 ID,然後指定間隔時間(毫秒)就 OK 了。

S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 0.00 72.03 0.35 54.12 55.72 11122 16.019 0 0.000 16.019
0.00 0.00 95.39 0.35 54.12 55.72 11123 16.024 0 0.000 16.024
0.00 0.00 25.32 0.35 54.12 55.72 11125 16.025 0 0.000 16.025
0.00 0.00 37.00 0.35 54.12 55.72 11126 16.028 0 0.000 16.028
0.00 0.00 60.35 0.35 54.12 55.72 11127 16.028 0 0.000 16.028

可以看到,E 其實是 Eden 的縮寫,S0 對應的是 Surivor0,S1 對應的是 Surivor1,O 代表的是 Old,而 M 代表的是 Metaspace。

YGC 代表的是年輕代的回收次數,YGC T對應的是年輕代的回收耗時。那麼 FGC 肯定代表的是 Full GC 的次數。

你在看日誌的時候,一定要注意其中的規律。-gcutil 位置的參數可以有很多種。我們最常用的有 gc、gcutil、gccause、gcnew 等,其他的瞭解一下即可。

  • gc: 顯示和 GC 相關的 堆信息
  • gcutil: 顯示 垃圾回收信息
  • gccause: 顯示垃圾回收 的相關信息(同 -gcutil),同時顯示 最後一次 或 當前 正在發生的垃圾回收的 誘因
  • gcnew: 顯示 新生代 信息;
  • gccapacity: 顯示 各個代 的 容量 以及 使用情況
  • gcmetacapacity: 顯示 元空間 metaspace 的大小;
  • gcnewcapacity: 顯示 新生代大小 和 使用情況
  • gcold: 顯示 老年代 和 永久代 的信息;
  • gcoldcapacity: 顯示 老年代 的大小;
  • printcompilation: 輸出 JIT 編譯 的方法信息;
  • class: 顯示 類加載 ClassLoader 的相關信息;
  • compiler: 顯示 JIT 編譯 的相關信息;

如果 GC 問題特別明顯,通過 jstat 可以快速發現。我們在啓動命令行中加上參數 -t,可以輸出從程序啓動到現在的時間。如果 FGC 和啓動時間的比值太大,就證明系統的吞吐量比較小,GC 花費的時間太多了。另外,如果老年代在 Full GC 之後,沒有明顯的下降,那可能內存已經達到了瓶頸,或者有內存泄漏問題。

下面這行命令,就追加了 GC 時間的增量和 GC 時間比率兩列。

jstat -gcutil -t 90542 1000 | awk 'BEGIN{pre=0}{if(NR>1) {print $0 "\t" ($12-pre) "\t" $12*100/$1 ; pre=$12 } else { print $0 "\tGCT_INC\tRate"} }'
 
Timestamp         S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT     GCT    GCT_INC Rate
           18.7   0.00 100.00   6.02   1.45  84.81  76.09      1    0.002     0    0.000    0.002 0.002 0.0106952
           19.7   0.00 100.00   6.02   1.45  84.81  76.09      1    0.002     0    0.000    0.002 0 0.0101523

GC 日誌也會搞鬼

順便給你介紹一個實際發生的故障。

你知道 ElasticSearch 的速度是非常快的,我們爲了壓榨它的性能,對磁盤的讀寫幾乎是全速的。它在後臺做了很多 Merge 動作,將小塊的索引合併成大塊的索引。還有 TransLog 等預寫動作,都是 I/O 大戶。

使用 iostat -x 1 可以看到具體的 I/O 使用狀況。

問題是,我們有一套 ES 集羣,在訪問高峯時,有多個 ES 節點發生了嚴重的 STW 問題。有的節點竟停頓了足足有 7~8 秒。
[Times: user=0.42 sys=0.03, real=7.62 secs] 

從日誌可以看到在 GC 時用戶態只停頓了 420ms,但真實的停頓時間卻有 7.62 秒。

盤點一下資源,唯一超額利用的可能就是 I/O 資源了(%util 保持在 90 以上),GC 可能在等待 I/O。

通過搜索,發現已經有人出現過這個問題,這裏直接說原因和結果。

原因就在於,寫 GC 日誌的 write 動作,是統計在 STW 的時間裏的。在我們的場景中,由於 ES 的索引數據,和 GC 日誌放在了一個磁盤,GC 時寫日誌的動作,就和寫數據文件的動作產生了資源爭用。

解決方式也是比較容易的,把 ES 的日誌文件,單獨放在一塊普通 HDD 磁盤上就可以了。

小結

本課時,我們主要介紹了比較重要的 GC 日誌,以及怎麼輸出它,並簡要的介紹了一段 G1 日誌的意義。對於這些日誌的信息,能夠幫助我們理解整個 GC 的過程,專門去記憶它投入和產出並不成正比,可以多看下 G1 垃圾回收器原理方面的東西。

接下來我們介紹了幾個圖形化分析 GC 的工具,這也是現在主流的使用方式,因爲動輒幾百 MB 的 GC 日誌,是無法肉眼分辨的。如果機器的 I/O 問題很突出,就要考慮把 GC 日誌移動到單獨的磁盤。

我們尤其介紹了在線分析工具 gceasy,你也可以下載 gcviewer 的 jar 包本地體驗一下。

最後我們看了一個命令行的 GC 回收工具 jstat,它的格式比較規整,可以重定向到一個日誌文件裏,後續使用 sed、awk 等工具進行分析。關於相關的兩個命令,可以參考我以前寫的兩篇文章。

《Linux生產環境上,最常用的一套“Sed“技巧》

《Linux生產環境上,最常用的一套“AWK“技巧》

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