GC调优在Spark应用中的实践

Spark是时下很是热门的大数据计算框架,以其卓越的性能优点、独特的架构、易用的用户接口和丰富的分析计算库,正在工业界得到愈来愈普遍的应用。与Hadoop、HBase生态圈的众多项目同样,Spark的运行离不开JVM的支持。因为Spark立足于内存计算,经常须要在内存中存放大量数据,所以也更依赖JVM的垃圾回收机制(GC)。而且同时,它也支持兼容批处理和流式处理,对于程序吞吐量和延迟都有较高要求,所以GC参数的调优在Spark应用实践中显得尤其重要。本文主要讲述如何针对Spark应用程序配置JVM的垃圾回收器,并从实际案例出发,剖析如何进行GC调优,进一步提高Spark应用的性能。算法

问题介绍

随着Spark在工业界获得普遍使用,Spark应用稳定性以及性能调优问题不可避免地引发了用户的关注。因为Spark的特点在于内存计算,咱们在部署Spark集群时,动辄使用超过100GB的内存做为Heap空间,这在传统的Java应用中是比较少见的。在普遍的合做过程当中,确实有不少用户向咱们抱怨运行Spark应用时GC所带来的各类问题。例如垃圾回收时间久、程序长时间无响应,甚至形成程序崩溃或者做业失败。对此,咱们该怎样调试Spark应用的垃圾收集器呢?在本文中,咱们从应用实例出发,结合具体问题场景,探讨了Spark应用的GC调优方法。缓存

按照经验来讲,当咱们配置垃圾收集器时,主要有两种策略——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算法中对性能影响最大的部分。app

图 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,而后观察性能变化。oop

图 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()的大小调整,以免大量的线程通讯所致使的性能降低。大数据

关于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%。

小结:当观察到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* Screen Shot 2015-05-26 at 1.42.13 PM
Task* Screen Shot 2015-05-26 at 1.56.05 PM
CPU* Screen Shot 2015-05-26 at 1.57.56 PM
Mem* Screen Shot 2015-05-26 at 2.00.31 PM

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* Screen Shot 2015-05-26 at 3.02.59 PM
Task* Screen Shot 2015-05-26 at 3.03.42 PM
CPU* Screen Shot 2015-05-26 at 3.04.24 PM
Mem* Screen Shot 2015-05-26 at 3.05.59 PM

表 3 G1 GC运行状况(未调优)

表 4 三种垃圾收集器对应的程序运行时间比较(88GB heap未调优)

根据日志进一步调优

在让G1 GC跑起来以后,咱们下一步就是须要根据GC log,来进一步进行性能调优。首先,咱们要让JVM记录比较详细的GC日志. 对于Spark而言,咱们须要在SPARK_JAVA_OPTS中设置参数使得Spark保留下咱们须要用到的日志. 通常而言,咱们须要设置这样一串参数:

-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运行日志中,咱们明显发现这样一段特殊的日志:

(to-space exhausted), 1.0552680 secs]

[Parallel Time: 958.8 ms, GC Workers: 23]

[GC Worker Start (ms): Min: 759925.0, Avg: 759925.1, Max: 759925.3, Diff: 0.3]

[Ext Root Scanning (ms): Min: 1.1, Avg: 1.4, Max: 1.8, Diff: 0.6, Sum: 33.0]

[SATB Filtering (ms): Min: 0.0, Avg: 0.0, Max: 0.3, Diff: 0.3, Sum: 0.3]

[Update RS (ms): Min: 0.0, Avg: 1.2, Max: 2.1, Diff: 2.1, Sum: 26.9]

[Processed Buffers: Min: 0, Avg: 2.8, Max: 11, Diff: 11, Sum: 65]

[Scan RS (ms): Min: 1.6, Avg: 2.5, Max: 3.0, Diff: 1.4, Sum: 58.0]

[Object Copy (ms): Min: 952.5, Avg: 953.0, Max: 954.3, Diff: 1.7, Sum: 21919.4]

[Termination (ms): Min: 0.0, Avg: 0.1, Max: 0.2, Diff: 0.2, Sum: 2.2]

[GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.6]

[GC Worker Total (ms): Min: 958.1, Avg: 958.3, Max: 958.4, Diff: 0.3, Sum: 22040.4]

[GC Worker End (ms): Min: 760883.4, Avg: 760883.4, Max: 760883.4, Diff: 0.0]

[Code Root Fixup: 0.0 ms]

[Clear CT: 0.4 ms]

[Other: 96.0 ms]

[Choose CSet: 0.0 ms]

[Ref Proc: 0.4 ms]

[Ref Enq: 0.0 ms]

[Free CSet: 0.1 ms]

[Eden: 160.0M(3904.0M)->0.0B(4480.0M) Survivors: 576.0M->0.0B Heap: 87.7G(88.0G)->87.7G(88.0G)]

[Times: user=1.69 sys=0.24, real=1.05 secs]

760.981: [G1Ergonomics (Heap Sizing) attempt heap expansion, reason: allocation request failed, allocation request: 90128 bytes]

760.981: [G1Ergonomics (Heap Sizing) expand the heap, requested expansion amount: 33554432 bytes, attempted expansion amount: 33554432 bytes]

760.981: [G1Ergonomics (Heap Sizing) did not expand the heap, reason: heap expansion operation failed]

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产生了大量的暂停时间。好比,咱们在日志中读到不少相似这样的片段:

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* Screen Shot 2015-05-26 at 3.20.31 PM
Task* Screen Shot 2015-05-26 at 3.21.36 PM
CPU* Screen Shot 2015-05-26 at 3.22.13 PM
Mem* Screen Shot 2015-05-26 at 3.22.45 PM

表 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中等等。

相关文章
相关标签/搜索