GC調優在Spark應用中的實踐(轉載)

Spark是時下很是熱門的大數據計算框架,以其卓越的性能優點、獨特的架構、易用的用戶接口和豐富的分析計算庫,正在工業界得到愈來愈普遍的應用。與Hadoop、HBase生態圈的衆多項目同樣,Spark的運行離不開JVM的支持。因爲Spark立足於內存計算,經常須要在內存中存放大量數據,所以也更依賴JVM的垃圾回收機制(GC)。而且同時,它也支持兼容批處理和流式處理,對於程序吞吐量和延遲都有較高要求,所以GC參數的調優在Spark應用實踐中顯得尤其重要。本文主要講述如何針對Spark應用程序配置JVM的垃圾回收器,並從實際案例出發,剖析如何進行GC調優,進一步提高Spark應用的性能。html

問題介紹

隨着Spark在工業界獲得普遍使用,Spark應用穩定性以及性能調優問題不可避免地引發了用戶的關注。因爲Spark的特點在於內存計算,咱們在部署Spark集羣時,動輒使用超過100GB的內存做爲Heap空間,這在傳統的Java應用中是比較少見的。在普遍的合做過程當中,確實有不少用戶向咱們抱怨運行Spark應用時GC所帶來的各類問題。例如垃圾回收時間久、程序長時間無響應,甚至形成程序崩潰或者做業失敗。對此,咱們該怎樣調試Spark應用的垃圾收集器呢?在本文中,咱們從應用實例出發,結合具體問題場景,探討了Spark應用的GC調優方法。java

按照經驗來講,當咱們配置垃圾收集器時,主要有兩種策略——Parallel GC和CMS GC。前者注重更高的吞吐量,然後者則注重更低的延遲。二者彷佛是魚和熊掌,不能兼得。在實際應用中,咱們只能根據應用對性能瓶頸的側重性,來選取合適的垃圾收集器。例如,當咱們運行須要有實時響應的場景的應用時,咱們通常選用CMS GC,而運行一些離線分析程序時,則選用Parallel GC。那麼對於Spark這種既支持流式計算,又支持傳統的批處理運算的計算框架來講,是否存在一組通用的配置選項呢?算法

一般CMS GC是企業比較經常使用的GC配置方案,並在長期實踐中取得了比較好的效果。例如對於進程中若存在大量壽命較長的對象,Parallel GC常常帶來較大的性能降低。所以,即便是批處理的程序也能從CMS GC中獲益。不過,在從1.6開始的HOTSPOT JVM中,咱們發現了一個新的GC設置項:Garbage-First GC(G1 GC)。Oracle將其定位爲CMS GC的長期演進,這讓咱們重燃了魚與熊掌兼得的但願!那麼,咱們首先了解一下GC的一些相關原理吧。緩存

GC算法原理

在傳統JVM內存管理中,咱們把Heap空間分爲Young/Old兩個分區,Young分區又包括一個Eden和兩個Survivor分區,如圖1所示。新產生的對象首先會被存放在Eden區,而每次minor GC發生時,JVM一方面將Eden分區內存活的對象拷貝到一個空的Survivor分區,另外一方面將另外一個正在被使用的Survivor分區中的存活對象也拷貝到空的Survivor分區內。在此過程當中,JVM始終保持一個Survivor分區處於全空的狀態。一個對象在兩個Survivor之間的拷貝到必定次數後,若是仍是存活的,就將其拷入Old分區。當Old分區沒有足夠空間時,GC會停下全部程序線程,進行Full GC,即對Old區中的對象進行整理。這個全部線程都暫停的階段被稱爲Stop-The-World(STW),也是大多數GC算法中對性能影響最大的部分。架構

 

圖 1 分年代的Heap結構 併發

而G1 GC則徹底改變了這一傳統思路。它將整個Heap分爲若干個預先設定的小區域塊(如圖2),每一個區域塊內部再也不進行新舊分區, 而是將整個區域塊標記爲Eden/Survivor/Old。當建立新對象時,它首先被存放到某一個可用區塊(Region)中。當該區塊滿了,JVM就會建立新的區塊存放對象。當發生minor GC時,JVM將一個或幾個區塊中存活的對象拷貝到一個新的區塊中,並在空餘的空間中選擇幾個全新區塊做爲新的Eden分區。當全部區域中都有存活對象,找不到全空區塊時,才發生Full GC。而在標記存活對象時,G1使用RememberSet的概念,將每一個分區外指向分區內的引用記錄在該分區的RememberSet中,避免了對整個Heap的掃描,使得各個分區的GC更加獨立。在這樣的背景下,咱們能夠看出G1 GC大大提升了觸發Full GC時的Heap佔用率,同時也使得Minor GC的暫停時間更加可控,對於內存較大的環境很是友好。這些顛覆性的改變,將給GC性能帶來怎樣的變化呢?最簡單的方式,咱們能夠將老的GC設置直接遷移爲G1 GC,而後觀察性能變化。app

 

圖 2 G1 Heap結構示意 框架

因爲G1取消了對於heap空間不一樣新舊對象固定分區的概念,因此咱們須要在GC配置選項上做相應的調整,使得應用可以合理地運行在G1 GC收集器上。通常來講,對於原運行在Parallel GC上的應用,須要去除的參數包括-Xmn, -XX:-UseAdaptiveSizePolicy, -XX:SurvivorRatio=n等;而對於原來使用CMS GC的應用,咱們須要去掉-Xmn -XX:InitialSurvivorRatio -XX:SurvivorRatio -XX:InitialTenuringThreshold -XX:MaxTenuringThreshold等參數。另外在CMS中已經調優過的-XX:ParallelGCThreads -XX:ConcGCThreads參數最好也移除掉,由於對於CMS來講性能最好的不必定是對於G1性能最好的選擇。咱們先統一置爲默認值,方便後期調優。此外,當應用開啓的線程較多時,最好使用-XX:-ResizePLAB來關閉PLAB()的大小調整,以免大量的線程通訊所致使的性能降低。oop

關於Hotspot JVM所支持的完整的GC參數列表,可使用參數-XX:+PrintFlagsFinal打印出來,也能夠參見Oracle官方的文檔中對部分參數的解釋。性能

Spark的內存管理

Spark的核心概念是RDD,實際運行中內存消耗都與RDD密切相關。Spark容許用戶將應用中重複使用的RDD數據持久化緩存起來,從而避免反覆計算的開銷,而RDD的持久化形態之一就是將所有或者部分數據緩存在JVM的Heap中。Spark Executor會將JVM的heap空間大體分爲兩個部分,一部分用來存放Spark應用中持久化到內存中的RDD數據,剩下的部分則用來做爲JVM運行時的堆空間,負責RDD轉化等過程當中的內存消耗。咱們能夠經過spark.storage.memoryFraction參數調節這兩塊內存的比例,Spark會控制緩存RDD總大小不超過heap空間體積乘以這個參數所設置的值,而這塊緩存RDD的空間中沒有使用的部分也能夠爲JVM運行時所用。所以,分析Spark應用GC問題時應當分別分析兩部份內存的使用狀況。

而當咱們觀察到GC延遲影響效率時,應當先檢查Spark應用自己是否有效利用有限的內存空間。RDD佔用的內存空間比較少的話,程序運行的heap空間也會比較寬鬆,GC效率也會相應提升;而RDD若是佔用大量空間的話,則會帶來巨大的性能損失。下面咱們從一個用戶案例展開:

該應用是利用Spark的組件Bagel來實現的,其本質就是一個簡單的迭代計算。而每次迭代計算依賴於上一次的迭代結果,所以每次迭代結果都會被主動持續化到內存空間中。當運行用戶程序時,咱們觀察到隨着迭代次數的增長,進程佔用的內存空間不斷快速增加,GC問題愈來愈突出。可是,仔細分析Bagel實現機制,咱們很快發現Bagel將每次迭代產生的RDD都持久化下來了,而沒有及時釋放掉再也不使用的RDD,從而形成了內存空間不斷增加,觸發了更多GC執行。通過簡單的修改,咱們修復了這個問題(SPARK-2661)。應用的內存空間獲得了有效的控制後,迭代次數三次之後RDD大小趨於穩定,緩存空間獲得有效控制(如表1所示),GC效率得以大大提升,程序總的運行時間縮短了10%~20%。

表1 三次迭代對比

小結:當觀察到GC頻繁或者延時長的狀況,也多是Spark進程或者應用中內存空間沒有有效利用。因此能夠嘗試檢查是否存在RDD持久化後未獲得及時釋放等狀況。

選擇垃圾收集器

在解決了應用自己的問題以後,咱們就要開始針對Spark應用的GC調優了。基於修復了SPARK-2661的Spark版本,咱們搭建了一個4個節點的集羣,給每一個Executor分配88G的Heap,在Spark的Standalone模式下來進行咱們的實驗。在使用默認的Parallel GC運行咱們的Spark應用時,咱們發現,因爲Spark應用對於內存的開銷比較大,並且大部分對象並不能在一個較短的生命週期中被回收,Parallel GC也經常受困於Full GC,而每次Full GC都給性能帶來了較大的降低。而Parallel GC能夠進行參數調優的空間也很是有限,咱們只能經過調節一些基本參數來提升性能,如各年代分區大小比例、進入老年代前的拷貝次數等。並且這些調優策略只能推遲Full GC的到來,若是是長期運行的應用,Parallel GC調優的意義就很是有限了。所以,本文中不會再對Parallel GC進行調優。表2列出了Parallel GC的運行狀況,其中CPU利用率較低的部分正是發生Full GC的時候。

Configuration Options -XX:+UseParallelGC -XX:+UseParallelOldGC -XX:+PrintFlagsFinal -XX:+PrintReferenceGC -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintAdaptiveSizePolicy -Xms88g -Xmx88g
Stage*
Task*
CPU*
Mem*

表 2 Parallel GC運行狀況(未調優)

至於CMS GC,也沒有辦法消除這個Spark應用中的Full GC,並且CMS的Full GC的暫停時間遠遠超過了Parallel GC,大大拖累了該應用的吞吐量。

接下來,咱們就使用最基本的G1 GC配置來運行咱們的應用。實驗結果發現,G1 GC居然也出現了不可忍受的Full GC(表3的CPU利用率圖中,能夠明顯發現Job 3中出現了將近100秒的暫停),超長的暫停時間大大拖累了整個應用的運行。如表4所示,雖然總的運行時間比Parallel GC略長,不過G1 GC表現略好於CMS GC。

Configuration Options -XX:+UseG1GC -XX:+PrintFlagsFinal -XX:+PrintReferenceGC -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintAdaptiveSizePolicy -XX:+UnlockDiagnosticVMOptions -XX:+G1SummarizeConcMark -Xms88g -Xmx88g
Stage*
Task*
CPU*
Mem*

表 3 G1 GC運行狀況(未調優) 

表 4 三種垃圾收集器對應的程序運行時間比較(88GB heap未調優) 

根據日誌進一步調優

在讓G1 GC跑起來以後,咱們下一步就是須要根據GC log,來進一步進行性能調優。首先,咱們要讓JVM記錄比較詳細的GC日誌. 對於Spark而言,咱們須要在SPARK_JAVA_OPTS中設置參數使得Spark保留下咱們須要用到的日誌. 通常而言,咱們須要設置這樣一串參數:

1 -XX:+PrintFlagsFinal -XX:+PrintReferenceGC -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintAdaptiveSizePolicy -XX:+UnlockDiagnosticVMOptions -XX:+G1SummarizeConcMark

有了這些參數,咱們就能夠在SPARK的EXECUTOR日誌中(默認輸出到各worker節點的$SPARK_HOME/work/$app_id/$executor_id/stdout中)讀到詳盡的GC日誌以及生效的GC 參數了。接下來,咱們就能夠根據GC日誌來分析問題,使程序得到更優性能。咱們先來了解一下G1中一次GC的日誌結構。

251.354: [G1Ergonomics (Mixed GCs) continue mixed GCs, reason: candidate old regions available, candidate old regions: 363 regions, reclaimable: 9830652576 bytes (10.40 %), threshold: 10.00 %]
[Parallel Time: 145.1 ms, GC Workers: 23]
[GC Worker Start (ms): Min: 251176.0, Avg: 251176.4, Max: 251176.7, Diff: 0.7]
[Ext Root Scanning (ms): Min: 0.8, Avg: 1.2, Max: 1.7, Diff: 0.9, Sum: 28.1]
[Update RS (ms): Min: 0.0, Avg: 0.3, Max: 0.6, Diff: 0.6, Sum: 5.8]
[Processed Buffers: Min: 0, Avg: 1.6, Max: 9, Diff: 9, Sum: 37]
[Scan RS (ms): Min: 6.0, Avg: 6.2, Max: 6.3, Diff: 0.3, Sum: 143.0]
[Object Copy (ms): Min: 136.2, Avg: 136.3, Max: 136.4, Diff: 0.3, Sum: 3133.9]
[Termination (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.3]
[GC Worker Other (ms): Min: 0.0, Avg: 0.1, Max: 0.2, Diff: 0.2, Sum: 1.9]
[GC Worker Total (ms): Min: 143.7, Avg: 144.0, Max: 144.5, Diff: 0.8, Sum: 3313.0]
[GC Worker End (ms): Min: 251320.4, Avg: 251320.5, Max: 251320.6, Diff: 0.2]
[Code Root Fixup: 0.0 ms]
[Clear CT: 6.6 ms]
[Other: 26.8 ms]
[Choose CSet: 0.2 ms]
[Ref Proc: 16.6 ms]
[Ref Enq: 0.9 ms]
[Free CSet: 2.0 ms]
[Eden: 3904.0M(3904.0M)->0.0B(4448.0M) Survivors: 576.0M->32.0M Heap: 63.7G(88.0G)->58.3G(88.0G)]
[Times: user=3.43 sys=0.01, real=0.18 secs]

以G1 GC的一次mixed GC爲例,從這段日誌中,咱們能夠看到G1 GC日誌的層次是很是清晰的。日誌列出了此次暫停發生的時間、緣由,並分級各類線程所消耗的時長以及CPU時間的均值和最值。最後,G1 GC列出了本次暫停的清理結果,以及總共消耗的時間。

而在咱們如今的G1 GC運行日誌中,咱們明顯發現這樣一段特殊的日誌:

 1 (to-space exhausted), 1.0552680 secs]
 2 [Parallel Time: 958.8 ms, GC Workers: 23]
 3 [GC Worker Start (ms): Min: 759925.0, Avg: 759925.1, Max: 759925.3, Diff: 0.3]
 4 [Ext Root Scanning (ms): Min: 1.1, Avg: 1.4, Max: 1.8, Diff: 0.6, Sum: 33.0]
 5 SATB Filtering (ms): Min: 0.0, Avg: 0.0, Max: 0.3, Diff: 0.3, Sum: 0.3]
 6 [Update RS (ms): Min: 0.0, Avg: 1.2, Max: 2.1, Diff: 2.1, Sum: 26.9]
 7 [Processed Buffers: Min: 0, Avg: 2.8, Max: 11, Diff: 11, Sum: 65]
 8 [Scan RS (ms): Min: 1.6, Avg: 2.5, Max: 3.0, Diff: 1.4, Sum: 58.0]
 9 [Object Copy (ms): Min: 952.5, Avg: 953.0, Max: 954.3, Diff: 1.7, Sum: 21919.4]
10 [Termination (ms): Min: 0.0, Avg: 0.1, Max: 0.2, Diff: 0.2, Sum: 2.2]
11 [GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.6]
12 [GC Worker Total (ms): Min: 958.1, Avg: 958.3, Max: 958.4, Diff: 0.3, Sum: 22040.4]
13 [GC Worker End (ms): Min: 760883.4, Avg: 760883.4, Max: 760883.4, Diff: 0.0
14 [Code Root Fixup: 0.0 ms]
15 [Clear CT: 0.4 ms]
16 [Other: 96.0 ms]
17 [Choose CSet: 0.0 ms]
18 [Ref Proc: 0.4 ms]
19 [Ref Enq: 0.0 ms]
20 [Free CSet: 0.1 ms]
21 [Eden: 160.0M(3904.0M)->0.0B(4480.0M) Survivors: 576.0M->0.0B Heap: 87.7G(88.0G)->87.7G(88.0G)]
22 [Times: user=1.69 sys=0.24, real=1.05 secs]
23 760.981: [G1Ergonomics (Heap Sizing) attempt heap expansion, reason: allocation request failed, allocation request: 90128 bytes]
24 760.981: [G1Ergonomics (Heap Sizing) expand the heap, requested expansion amount: 33554432 bytes, attempted expansion amount: 33554432 bytes]
25 760.981: [G1Ergonomics (Heap Sizing) did not expand the heap, reason: heap expansion operation failed]
26 760.981: [Full GC 87G->36G(88G), 67.4381220 secs]

顯然最大的性能降低是這樣的Full GC致使的,咱們能夠在日誌中看到相似To-space Exhausted或者To-space Overflow這樣的輸出(取決於不一樣版本的JVM,輸出略有不一樣)。這是G1 GC收集器在將某個須要垃圾回收的分區進行回收時,沒法找到一個能將其中存活對象拷貝過去的空閒分區。這種狀況被稱爲Evacuation Failure,經常會引起Full GC。並且很顯然,G1 GC的Full GC效率相對於Parallel GC實在是相差太遠,咱們想要得到比Parallel GC更好的表現,必定要盡力規避Full GC的出現。對於這種狀況,咱們常見的處理辦法有兩種:

  1. 將InitiatingHeapOccupancyPercent參數調低(默認值是45),可使G1 GC收集器更早開始Mixed GC;但另外一方面,會增長GC發生頻率。
  2. 提升ConcGCThreads的值,在Mixed GC階段投入更多的併發線程,爭取提升每次暫停的效率。可是此參數會佔用必定的有效工做線程資源。

調試這兩個參數能夠有效下降Full GC出現的機率。Full GC被消除以後,最終的性能得到了大幅提高。可是咱們發現,仍然有一些地方GC產生了大量的暫停時間。好比,咱們在日誌中讀到不少相似這樣的片段:

1 280.008: [G1Ergonomics (Concurrent Cycles) request concurrent cycle initiation, reason: occupancy higher than threshold, occupancy: 62344134656 bytes, allocation request: 46137368 bytes, threshold: 42520176225 bytes (45.00 %), source: concurrent humongous allocation]

這裏就是Humongous object,一些比G1的一個分區的一半更大的對象。對於這些對象,G1會專門在Heap上開出一個個Humongous Area來存放,每一個分區只放一個對象。可是申請這麼大的空間是比較耗時的,並且這些區域也僅當Full GC時才進行處理,因此咱們要儘可能減小這樣的對象產生。或者提升G1HeapRegionSize的值減小HumongousArea的建立。不過在內存比較大的時,JVM默認把這個值設到了最大(32M),此時咱們只能經過分析程序自己找到這些對象而且儘可能減小這樣的對象產生。固然,相信隨着G1 GC的發展,在後期的版本中相信這個最大值也會愈來愈大,畢竟G1號稱是在1024~2048個Region時可以得到最佳性能。

接下來,咱們能夠分析一下單次cycle start到Mixed GC爲止的時間間隔。若是這一時間過長,能夠考慮進一步提高ConcGCThreads,須要注意的是,這會進一步佔用必定CPU資源。

對於追求更短暫停時間的在線應用,若是觀測到較長的Mixed GC pause,咱們還要把G1RSetUpdatingPauseTimePercent調低,把G1ConcRefinementThreads調高。前文提到G1 GC經過爲每一個分區維護RememberSet來記錄分區外對分區內的引用,G1RSetUpdatingPauseTimePercent則正是在STW階段爲G1收集器指定更新RememberSet的時間佔總STW時間的指望比例,默認爲10。而G1ConcRefinementThreads則是在程序運行時維護RememberSet的線程數目。經過對這兩個值的對應調整,咱們能夠把STW階段的RememberSet更新工做壓力更多地移到Concurrent階段。

另外,對於須要長時間運行的應用,咱們不妨加上AlwaysPreTouch參數,這樣JVM會在啓動時就向OS申請全部須要使用的內存,避免動態申請,也能夠提升運行時性能。可是該參數也會大大延長啓動時間。

最終,通過幾輪GC參數調試,其結果以下表5所示。較之先前的結果,咱們最終仍是得到了較滿意的運行效率。

Configuration Options -XX:+UseG1GC -XX:+PrintFlagsFinal -XX:+PrintReferenceGC -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintAdaptiveSizePolicy -XX:+UnlockDiagnosticVMOptions -XX:+G1SummarizeConcMark -Xms88g -Xmx88g -XX:InitiatingHeapOccupancyPercent=35 -XX:ConcGCThread=20
Stage*
Task*
CPU*
Mem*

表 5 使用G1 GC調優完成後的表現 

小結:綜合考慮G1 GC是較爲推崇的默認Spark GC機制。進一步的GC日誌分析,能夠收穫更多的GC優化。通過上面的調優過程,咱們將該應用的運行時間縮短到了4.3分鐘,相比調優以前,咱們得到了1.7倍左右的性能提高,而相比Parallel GC也得到了1.5倍左右的性能提高。 

總結

對於大量依賴於內存計算的Spark應用,GC調優顯得尤其重要。在發現GC問題的時候,不要着急調試GC。而是先考慮是否存在Spark進程內存管理的效率問題,例如RDD緩存的持久化和釋放。至於GC參數的調試,首先咱們比較推薦使用G1 GC來運行Spark應用。相較於傳統的垃圾收集器,隨着G1的不斷成熟,須要配置的選項會更少,能同時知足高吞吐量和低延遲的尋求。固然,GC的調優不是絕對的,不一樣的應用會有不一樣應用的特性,掌握根據GC日誌進行調優的方法,才能以不變應萬變。最後,也不能忘了先對程序自己的邏輯和代碼編寫進行考量,例如減小中間變量的建立或者複製,控制大對象的建立,將長期存活對象放在Off-heap中等等。 

 

轉載自 https://www.csdn.net/article/2015-06-01/2824823

英文原文參見 https://databricks.com/blog/2015/05/28/tuning-java-garbage-collection-for-spark-applications.html

相關文章
相關標籤/搜索