GC调优在Spark应用中的实践

【本文详细介绍了GC调优在Spark应用中的实践,欢迎读者朋友们阅读、转发和收藏!】

Spark立足内存计算,常常需要在内存中存放大量数据,因此也更依赖JVM的垃圾回收机制。与此同时,它也兼容批处理和流式处理,对于程序吞吐量和延迟都有较高要求,因此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分区。新产生的对象首先会被存放在Eden区,而每次minor GC发生时,JVM一方面将Eden分区内存活的对象拷贝到一个空的Survivor分区,另一方面将另一个正在被使用的Survivor分区中的存活对象也拷贝到空的Survivor分区内。在此过程中,JVM始终保持一个Survivor分区处于全空的状态。一个对象在两个Survivor之间的拷贝到一定次数后,如果还是存活的,就将其拷入Old分区。当Old分区没有足够空间时,GC会停下所有程序线程,进行Full GC,即对Old区中的对象进行整理。这个所有线程都暂停的阶段被称为Stop-The-World(STW),也是大多数GC算法中对性能影响最大的部分。

而G1 GC则完全改变了这一传统思路。它将整个Heap分为若干个预先设定的小区域块,每个区域块内部不再进行新旧分区, 而是将整个区域块标记为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,然后观察性能变化。

由于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大小趋于稳定,缓存空间得到有效控制,GC效率得以大大提高,程序总的运行时间缩短了10%~20%。

小结:当观察到GC频繁或者延时长的情况,也可能是Spark进程或者应用中内存空间没有有效利用。所以可以尝试检查是否存在RDD持久化后未得到及时释放等情况。

我知道你在看

  • 发表于:
  • 原文链接https://kuaibao.qq.com/s/20200523A005JG00?refer=cp_1026
  • 腾讯「云+社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 yunjia_community@tencent.com 删除。

扫码关注云+社区

领取腾讯云代金券