你看懂 Elasticsearch Log 中的 GC 日志了嗎?[轉(zhuǎn)載]
如果你關注過 elasticsearch 的日志,可能會看到如下類似的內(nèi)容:
[2018-06-30T17:57:23,848][WARN ][o.e.m.j.JvmGcMonitorService] [qoo--eS] [gc][228384] overhead, spent [2.2s] collecting in the last [2.3s]
[2018-06-30T17:57:29,020][INFO ][o.e.m.j.JvmGcMonitorService] [qoo--eS] [gc][old][228385][160772] duration [5s], collections [1]/[5.1s], total [5s]/[4.4d], memory [945.4mb]->[958.5mb]/[1007.3mb], all_pools {[young] [87.8mb]->[100.9mb]/[133.1mb]}{[survivor] [0b]->[0b]/[16.6mb]}{[old] [857.6mb]->[857.6mb]/[857.6mb]}
看到其中的[gc]關鍵詞你也猜到了這是與 GC 相關的日志,那么你了解每一部分的含義嗎?如果不了解,你可以繼續(xù)往下看了。
我們先從最簡單的看起:
- 第一部分是
日志發(fā)生的時間 - 第二部分是
日志級別,這里分別是WARN和INFO - 第三部分是
輸出日志的類,我們后面也會講到這個類 - 第四部分是
當前 ES 節(jié)點名稱 - 第五部分是
gc關鍵詞,我們就從這個關鍵詞聊起。
友情提示:對 GC 已經(jīng)了如指掌的同學,可以直接翻到最后看答案。
1. 什么是 GC?
GC,全稱是 Garbage Collection (垃圾收集)或者 Garbage Collector(垃圾收集器)。
在使用 C語言編程的時候,我們要手動的通過 malloc 和 free來申請和釋放數(shù)據(jù)需要的內(nèi)存,如果忘記釋放內(nèi)存,就會發(fā)生內(nèi)存泄露的情況,即無用的數(shù)據(jù)占用了寶貴的內(nèi)存資源。而Java 語言編程不需要顯示的申請和釋放內(nèi)存,因為 JVM 可以自動管理內(nèi)存,這其中最重要的一部分就是 GC,即 JVM 可以自主地去釋放無用數(shù)據(jù)(垃圾)占用的內(nèi)存。
我們研究 GC 的主要原因是 GC 的過程會有 Stop The World(STW)的情況發(fā)生,即此時用戶線程會停止工作,如果 STW 的時間過長,則應用的可用性、實時性等就下降的很厲害。
GC主要解決如下3個問題:
- 如何找到垃圾?
- 如何回收垃圾?
- 何時回收垃圾?
我們一個個來看下。
1.1 如何找到垃圾?
所謂垃圾,指的是不再被使用(引用)的對象。Java 的對象都是在堆(Heap)上創(chuàng)建的,我們這里默認也只討論堆。那么現(xiàn)在問題就變?yōu)槿绾闻卸ㄒ粋€對象是否還有被引用,思路主要有如下兩種:
- 引用計數(shù)法,即在對象被引用時加1,去除引用時減1,如果引用值為0,即表明該對象可回收了。
- 可達性分析法,即通過遍歷已知的存活對象(GC Roots)的引用鏈來標記出所有存活對象
方法2是目前常用的方法,這里有一個關鍵是 GC Roots,它是判定的源頭,感興趣的同學可以自己去研究下,這里就不展開講了。

1.2 如何回收垃圾?
垃圾找到了,該怎么回收呢?看起來似乎是個很傻的問題。直接收起來扔掉不就好了?!對應到程序的操作,就是直接將這些對象占用的空間標記為空閑不就好了嗎?那我們就來看一下這個基礎的回收算法:標記-清除(Mark-Sweep)算法。
1.2.1 標記-清除 算法(Mark Sweep)
該算法很簡單,使用通過可達性分析分析方法標記出垃圾,然后直接回收掉垃圾區(qū)域。它的一個顯著問題是一段時間后,內(nèi)存會出現(xiàn)大量碎片,導致雖然碎片總和很大,但無法滿足一個大對象的內(nèi)存申請,從而導致 OOM,而過多的內(nèi)存碎片(需要類似鏈表的數(shù)據(jù)結(jié)構(gòu)維護),也會導致標記和清除的操作成本高,效率低下,如下圖所示:

1.2.2 復制算法(Copying)
為了解決上面算法的效率問題,有人提出了復制算法。它將可用內(nèi)存一分為二,每次只用一塊,當這一塊內(nèi)存不夠用時,便觸發(fā) GC,將當前存活對象復制(Copy)到另一塊上,以此往復。這種算法高效的原因在于分配內(nèi)存時只需要將指針后移,不需要維護鏈表等。但它最大的問題是對內(nèi)存的浪費,使用率只有 50%。
但這種算法在一種情況下會很高效:Java 對象的存活時間極短。據(jù) IBM 研究,Java 對象高達 98% 是朝生夕死的,這也意味著每次 GC 可以回收大部分的內(nèi)存,需要復制的數(shù)據(jù)量也很小,這樣它的執(zhí)行效率就會很高。

1.2.3 標記-整理算法(Mark Compact)
該算法解決了第1中算法的內(nèi)存碎片問題,它會在回收階段將所有內(nèi)存做整理,如下圖所示:

但它的問題也在于增加了整理階段,也就增加了 GC 的時間。1.2.4 分代收集算法(Generation Collection)
既然大部分 Java 對象是朝生夕死的,那么我們將內(nèi)存按照 Java 生存時間分為 新生代(Young) 和 老年代(Old),前者存放短命僧,后者存放長壽佛,當然長壽佛也是由短命僧升級上來的。然后針對兩者可以采用不同的回收算法,比如對于新生代采用復制算法會比較高效,而對老年代可以采用標記-清除或者標記-整理算法。這種算法也是最常用的。JVM Heap 分代后的劃分一般如下所示,新生代一般會分為 Eden、Survivor0、Survivor1區(qū),便于使用復制算法。

將內(nèi)存分代后的 GC 過程一般類似下圖所示:

- 對象一般都是先在
Eden區(qū)創(chuàng)建 - 當
Eden區(qū)滿,觸發(fā) Young GC,此時將Eden中還存活的對象復制到S0中,并清空Eden區(qū)后繼續(xù)為新的對象分配內(nèi)存 - 當
Eden區(qū)再次滿后,觸發(fā)又一次的 Young GC,此時會將Eden和S0中存活的對象復制到S1中,然后清空Eden和S0后繼續(xù)為新的對象分配內(nèi)存 - 每經(jīng)過一次 Young GC,存活下來的對象都會將自己存活次數(shù)加1,當達到一定次數(shù)后,會隨著一次 Young GC 晉升到
Old區(qū) -
Old區(qū)也會在合適的時機進行自己的 GC
1.2.5 常見的垃圾收集器
前面我們講了眾多的垃圾收集算法,那么其具體的實現(xiàn)就是垃圾收集器,也是我們實際使用中會具體用到的。現(xiàn)代的垃圾收集機制基本都是分代收集算法,而 Young與 Old區(qū)分別有不同的垃圾收集器,簡單總結(jié)如下圖:

從上圖我們可以看到 Young與 Old區(qū)有不同的垃圾收集器,實際使用時會搭配使用,也就是上圖中兩兩連線的收集器是可以搭配使用的。這些垃圾收集器按照運行原理大概可以分為如下幾類:
- Serial GC,串行,單線程的收集器,運行 GC 時需要停止所有的用戶線程,且只有一個 GC 線程
- Parallel GC,并行,多線程的收集器,是 Serial 的多線程版,運行時也需要停止所有用戶線程,但同時運行多個 GC 線程,所以效率高一些
- Concurrent GC,并發(fā),多線程收集器,GC 分多階段執(zhí)行,部分階段允許用戶線程與 GC 線程同時運行,這也就是并發(fā)的意思,大家要和并行做一個區(qū)分。
- 其他
我們下面簡單看一下他們的運行機制。
1.2.5.3 Concurrent Mark-Sweep GC
該類目前只是針對 Old 區(qū),最常見就是CMS GC,它的執(zhí)行分為多個階段,只有部分階段需要停止用戶進程,這里不詳細介紹了,感興趣可以去找相關文章來看,大體執(zhí)行如下:

1.2.5.4 其他
目前最新的 GC 有G1GC和ZGC,其運行機制與上述均不相同,雖然他們也是分代收集算法,但會把 Heap 分成多個 region 來做處理,這里不展開講,感興趣的可以參看最后參考資料的內(nèi)容。

1.2.6 Elasticsearch 的 GC 組合
Elasticsearch 默認的 GC 配置是CMS GC ,其 Young 區(qū)用 ParNew,Old 區(qū)用CMS,大家可以在 config/jvm.options中看到如下的配置:
## GC configuration
-XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=75
-XX:+UseCMSInitiatingOccupancyOnly
1.3 何時進行回收?
現(xiàn)在我們已經(jīng)知道如何找到和回收垃圾了,那么什么時候回收呢?簡單總結(jié)如下:
-
Young 區(qū)的GC 都是在Eden 區(qū)滿時觸發(fā) - Serial Old 和 Parallel Old 在
Old 區(qū)是在 Young GC 時預測Old 區(qū)是否可以為 young 區(qū) promote 到 old 區(qū) 的 object 分配空間,如果不可用則觸發(fā) Old GC。這個也可以理解為是Old區(qū)滿時。 - CMS GC 是在
Old 區(qū)大小超過一定比例后觸發(fā),而不是 Old 區(qū)滿。這個原因在于 CMS GC 是并發(fā)的算法,也就是說在 GC 線程收集垃圾的時候,用戶線程也在運行,因此需要預留一些 Heap 空間給用戶線程使用,防止由于無法分配空間而導致 Full GC 發(fā)生。
2. GC Log 如何閱讀?
前面講了這么多,終于可以回到開篇的問題了,我們直接來看答案
[2018-06-30T17:57:23,848][WARN ][o.e.m.j.JvmGcMonitorService] [qoo--eS] [gc][228384] overhead, spent [2.2s] collecting in the last [2.3s]
[gc][這是第228384次GC 檢查] 在最近 2.3 s 內(nèi)花了 2.2s 用來做垃圾收集,這占比似乎有些過了,請抓緊來關注下。
[2018-06-30T17:57:29,020][INFO ][o.e.m.j.JvmGcMonitorService] [qoo--eS] [gc][old][228385][160772] duration [5s], collections [1]/[5.1s], total [5s]/[4.4d], memory [945.4mb]->[958.5mb]/[1007.3mb], all_pools {[young] [87.8mb]->[100.9mb]/[133.1mb]}{[survivor] [0b]->[0b]/[16.6mb]}{[old] [857.6mb]->[857.6mb]/[857.6mb]}
我們直接來看具體的含義好了,相信有了前面的 GC 基礎知識,大家在看這里解釋的時候就非常清楚了。
- [gc][本次是 old GC][這是第228385次 GC 檢查][從 JVM 啟動至今發(fā)生的第 160772次 GC]
- duration [本次檢查到的 GC 總耗時 5 秒,可能是多次的加和],
- collections [從上次檢查至今總共發(fā)生1次 GC]/[從上次檢查至今已過去 5.1 秒],
- total [本次檢查到的 GC 總耗時為 5 秒]/[從 JVM 啟動至今發(fā)生的 GC 總耗時為 4.4 天],
- memory [ GC 前 Heap memory 空間]->[GC 后 Heap memory 空間]/[Heap memory 總空間],
- all_pools(分代部分的詳情) {[young 區(qū)][GC 前 Memory ]->[GC后 Memory]/[young區(qū) Memory 總大小] } {[survivor 區(qū)][GC 前 Memory ]->[GC后 Memory]/[survivor區(qū) Memory 總大小] }{[old 區(qū)][GC 前 Memory ]->[GC后 Memory]/[old區(qū) Memory 總大小] }
3. 看看源碼
從日志中我們可以看到輸出這些日志的類名叫做JvmGcMonitorService,我們?nèi)ピ创a中搜索很快會找到它/Users/rockybean/code/elasticsearch/core/src/main/java/org/elasticsearch/monitor/jvm/JvmGcMonitorService.java,這里就不詳細展開講解源碼了,它執(zhí)行的內(nèi)容大概如下圖所示:

關于打印日志的格式在源碼也有,如下所示:
private static final String SLOW_GC_LOG_MESSAGE =
"[gc][{}][{}][{}] duration [{}], collections [{}]/[{}], total [{}]/[{}], memory [{}]->[{}]/[{}], all_pools {}";
private static final String OVERHEAD_LOG_MESSAGE = "[gc][{}] overhead, spent [{}] collecting in the last [{}]";
另外細心的同學會發(fā)現(xiàn)輸出的日志中 gc 只分了 young 和 old ,原因在于 ES 對 GC Name 做了封裝,封裝的類為:org.elasticsearch.monitor.jvm.GCNames,相關代碼如下:
public static String getByMemoryPoolName(String poolName, String defaultName) {
if ("Eden Space".equals(poolName) || "PS Eden Space".equals(poolName) || "Par Eden Space".equals(poolName) || "G1 Eden Space".equals(poolName)) {
return YOUNG;
}
if ("Survivor Space".equals(poolName) || "PS Survivor Space".equals(poolName) || "Par Survivor Space".equals(poolName) || "G1 Survivor Space".equals(poolName)) {
return SURVIVOR;
}
if ("Tenured Gen".equals(poolName) || "PS Old Gen".equals(poolName) || "CMS Old Gen".equals(poolName) || "G1 Old Gen".equals(poolName)) {
return OLD;
}
return defaultName;
}
public static String getByGcName(String gcName, String defaultName) {
if ("Copy".equals(gcName) || "PS Scavenge".equals(gcName) || "ParNew".equals(gcName) || "G1 Young Generation".equals(gcName)) {
return YOUNG;
}
if ("MarkSweepCompact".equals(gcName) || "PS MarkSweep".equals(gcName) || "ConcurrentMarkSweep".equals(gcName) || "G1 Old Generation".equals(gcName)) {
return OLD;
}
return defaultName;
}
在上面的代碼中,你會看到很多我們在上一節(jié)中提到的 GC 算法的名稱。
至此,源碼相關部分也講解完畢,感興趣的大家可以自行去查閱。
4. 總結(jié)
講解 GC 的文章已經(jīng)很多,本文又嘮嘮叨叨地講一遍基礎知識,是希望對于第一次了解 GC 的同學有所幫助。因為只有了解了這些基礎知識,你才不至于被這些 GC 的輸出嚇懵。希望本文對你理解 ES 的 GC 日志 有所幫助。
5. 參考資料
- Java Hotspot G1 GC的一些關鍵技術(https://mp.weixin.qq.com/s/4ufdCXCwO56WAJnzng_-ow)
- Understanding Java Garbage Collection(https://www.cubrid.org/blog/understanding-java-garbage-collection)
- 《深入理解Java虛擬機:JVM高級特性與最佳實踐》