调整Apache Spark应用程序的Java垃圾收集

调整Apache Spark应用程序的Java垃圾收集
王道远黄洁 由王道远和黄杰 发表于公司博客 2015年5月28日
这是来自英特尔SSG STO大数据技术小组的朋友的客座文章。
来源地址:
https://databricks.com/blog/2015/05/28/tuning-java-garbage-collection-for-spark-applications.html

加入我们的Spark峰会,听取英特尔和其他公司在生产中部署Apache Spark的消息。使用代码Databricks20可获得20%的折扣!

Apache Spark凭借其卓越的性能,简单的界面以及丰富的分析和计算库,正在获得广泛的行业采用。与大数据生态系统中的许多项目一样,Spark运行在Java虚拟机(JVM)上。由于Spark可以在内存中存储大量数据,因此它主要依赖于Java的内存管理和垃圾收集(GC)。Project Tungsten等新计划将在未来的Spark版本中简化和优化内存管理。但是今天,了解Java的GC选项和参数的用户可以调整它们以获得最佳的Spark应用程序性能。本文介绍如何为Spark配置JVM的垃圾收集器,并提供实际用例,说明如何调整GC以提高Spark的性能。我们研究了调整GC时的关键考虑因素,例如收集吞吐量和延迟。

Spark和Garbage Collection简介
随着Spark在工业中的广泛应用,Spark应用程序的稳定性和性能调优问题日益成为人们关注的话题。由于Spark以内存为中心的方法,通常使用100GB或更多内存作为堆空间,这在传统Java应用程序中很少见。在与使用Spark的大公司合作时,我们对Spark应用程序执行期间围绕GC的各种挑战表示了很多担忧。例如,垃圾收集需要很长时间,导致程序经历长时间延迟,甚至在严重情况下崩溃。在本文中,我们使用实际示例,结合具体问题,讨论可以缓解这些问题的Spark应用程序的GC调优方法。

Java应用程序通常使用两种垃圾收集策略之一:并发标记扫描(CMS)垃圾收集和ParallelOld垃圾收集。前者旨在降低延迟,而后者则旨在提高吞吐量。这两种策略都存在性能瓶颈:CMS GC不执行压缩[1],而Parallel GC仅执行整堆压缩,这会导致相当长的暂停时间。在英特尔,我们建议客户选择最适合特定应用要求的策略。对于具有实时响应的应用程序,我们通常建议使用CMS GC; 对于离线分析程序,我们使用Parallel GC。

因此,对于支持流式计算和传统批处理的Spark等计算框架,我们能找到最佳的收集器吗?Hotspot JVM 1.6引入了垃圾收集的第三个选项:Garbage-First GC(G1 GC)。甲骨文计划将G1收集器作为CMS GC的长期替代品。最重要的是,G1收集器旨在实现高吞吐量和低延迟。在我们详细介绍如何将Spark收集器与Spark一起使用之前,让我们先了解一下Java GC基础知识的背景知识。

Java的垃圾收集器如何工作
在传统的JVM内存管理中,堆空间分为Young和Old代。年轻一代由一个叫做Eden的区域和两个较小的幸存者空间组成,如图1所示。新创建的对象最初在Eden中分配。每次发生次要GC时,JVM都会将Eden中的活动对象复制到空的幸存者空间,并将活动对象复制到正用于该空幸存者空间的另一个幸存者空间中。这种方法使幸存者空间中的一个保持对象,而另一个空白用于下一个集合。在一些次要集合中存活的对象将被复制到旧代。当老一代填满时,一个主要的GC将暂停所有线程以执行完整的GC,即组织或删除旧一代中的对象。所有线程挂起时的执行暂停称为Stop-The-World(STW),这会牺牲大多数GC算法的性能。[2]

图1分代热点堆结构[2] **

图1一般热点堆结构[2] **

Java的新版G1 GC彻底改变了传统方法。堆被分成一组大小相等的堆区域,每个堆区域都是一个连续的虚拟内存范围(图2)。某些区域集被赋予与旧收集器中相同的角色(Eden,幸存者,旧),但它们没有固定的大小。这为内存使用提供了更大的灵活性。创建对象时,它最初分配在可用区域中。当区域填满时,JVM会创建新区域来存储对象。当发生次要GC时,G1将活动对象从堆的一个或多个区域复制到堆上的单个区域,并选择一些自由新区域作为Eden区域。仅当所有区域都包含活动对象且未找到完全空白区域时,才会发生完整GC。标记活动对象时,G1使用记忆集(RSets)概念。RSets通过外部区域跟踪对象引用到给定区域。堆中每个区域有一个RSet。RSet避免了整堆扫描,并支持区域的并行和独立收集。在这种情况下,我们可以看到G1 GC不仅可以在触发完整GC时大大提高堆占用率,还可以使次要GC暂停时间更加可控,从而对大内存环境非常友好。这些破坏性改进如何改变GC性能?在这里,我们使用最简单的方法来观察性能变化,即从旧的GC设置迁移到G1 GC设置。[3] 我们可以看到,G1 GC不仅可以在触发完整GC时大大提高堆占用率,还可以使次GC暂停时间更加可控,从而对大内存环境非常友好。这些破坏性改进如何改变GC性能?在这里,我们使用最简单的方法来观察性能变化,即从旧的GC设置迁移到G1 GC设置。[3] 我们可以看到,G1 GC不仅可以在触发完整GC时大大提高堆占用率,还可以使次GC暂停时间更加可控,从而对大内存环境非常友好。这些破坏性改进如何改变GC性能?在这里,我们使用最简单的方法来观察性能变化,即从旧的GC设置迁移到G1 GC设置。[3]

图2 G1堆结构图[3] **

图2 G1堆结构的示意图[3] **

由于G1放弃了对年轻/老化对象使用固定堆分区的方法,因此我们必须相应地调整GC配置选项,以保证G1收集器的应用程序的平稳运行。与旧的垃圾收集器不同,我们通常发现G1收集器的良好起点不是进行任何调整。因此,我们建议仅从默认设置开始,只需通过-XX:+UseG1GC 选项启用G1 。我们发现有时有用的一个调整是,当应用程序使用多个线程时,最好使用-XX: -ResizePLAB关闭PLAB()调整大小并避免由大量线程通信导致的性能下降。

有关Hotspot JVM支持的GC参数的完整列表,可以使用该参数-XX: +PrintFlagsFinal打印列表,或参阅Oracle官方文档以获取有关部分参数的说明。

了解Spark中的内存管理
甲弹性分布式数据集(RDD)是在火花核心抽象。RDD的创建和缓存与内存消耗密切相关。Spark允许用户持久缓存数据以便在应用程序中重用,从而避免重复计算造成的开销。持久化RDD的一种形式是将全部或部分数据缓存在JVM堆中。Spark的执行程序将JVM堆空间分为两部分:一部分用于存储Spark应用程序持久缓存到内存中的数据; 剩下的部分用作JVM堆空间,负责RDD转换期间的内存消耗。我们可以用这个来调整这两个分数的比例spark.storage.memoryFraction让Spark通过确保它不超过RDD堆空间体积乘以此参数的值来控制缓存RDD的总大小的参数。JVM也可以使用RDD高速缓存分数的未使用部分。因此,Spark应用程序的GC分析应涵盖两个内存分数的内存使用情况。

当观察到GC延迟导致效率下降时,我们应首先检查并确保Spark应用程序以有效的方式使用有限的内存空间。RDD占用的内存空间越少,程序执行剩余的堆空间就越多,从而提高了GC的效率; 相反,由于旧代中存在大量缓冲对象,RDD过多的内存消耗会导致显着的性能损失。在这里,我们用一个用例扩展这一点:

例如,用户具有基于Spark的Bagel组件的应用程序,其执行简单的迭代计算。一个超级步(迭代)的结果取决于前一个超级步的结果,因此每个超级步的结果将保留在内存空间中。在程序执行期间,我们观察到当迭代次数增加时,进度使用的内存空间会迅速增长,从而导致GC变得更糟。当我们仔细观察Bagel时,我们发现它将每个超级步的RDD缓存在内存中而不会随着时间的推移释放它们,即使它们在一次迭代后没有被使用。这会导致内存消耗增长,从而触发更多的GC尝试。我们在SPARK-2661中删除了这种不必要的缓存。在此修改缓存之后,RDD大小在三次迭代后稳定,并且缓存空间现在得到有效控制(如表1所示)。结果,GC效率得到了很大提高,程序的总运行时间缩短了10%~20%。

表1:Bagel应用程序在优化之前和之后的RDD缓存大小的比较

迭代次数 每次迭代的缓存大小 总缓存大小(优化前) 总缓存大小(优化后)
初始化 4.3GB 4.3GB 4.3GB
1 8.2GB 12.5GB 8.2GB
2 98.8GB 111.3 GB 98.8GB
3 90.8GB 202.1 GB 90.8GB

结论:

当观察到GC太频繁或持久时,可能表明Spark进程或应用程序未有效使用内存空间。您可以通过在不再需要缓存的RDD后明确清理它们来提高性能。

选择垃圾收集器
如果我们的应用程序尽可能高效地使用内存,那么下一步就是调整我们对垃圾收集器的选择。实施SPARK-2661后,我们建立了一个四节点集群,为每个执行器分配了一个88GB的堆,并在独立模式下启动Spark来进行我们的实验。我们从默认的Spark Parallel GC开始,发现由于Spark应用程序的内存开销相对较大且大多数对象无法在相当短的生命周期内回收,因此并行GC通常被困在完整的GC中,这导致了下降每次发生时的表现。更糟糕的是,Parallel GC提供了非常有限的性能调整选项,因此我们只能使用一些基本参数来调整性能,例如每一代的大小比例,以及将对象提升到旧一代之前的副本数量。由于这些调优策略只推迟了完整的GC,因此并行GC调优对于长期运行的应用程序几乎没有帮助。因此,在本文中,我们不进行并行GC调整。表2显示了并行GC的操作,显然,当执行完整的GC时,会出现最低的CPU利用率。

表2:并行GC运行状态(调整前)

配置选项 -XX:+UseParallelGC -XX:+UseParallelOldGC -XX:+PrintFlagsFinal -XX:+PrintReferenceGC -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintAdaptiveSizePolicy -Xms88g -Xmx88g
阶段* 屏幕截图2015-05-26 at 1.42.13 PM
任务* 屏幕截图2015-05-26在1.56.05 PM
中央处理器* 屏幕截图2015-05-26在1.57.56 PM
纪念品* 屏幕截图2015-05-26在2.00.31 PM
CMS GC无法做任何事情来消除此Spark应用程序中的完整GC。此外,CMS GC具有比并行GC更长的完整GC暂停时间,从而大大降低了应用程序的吞吐量。

接下来,我们使用默认的G1 GC配置运行我们的应用程序 令我们惊讶的是,G1 GC还提供了令人无法接受的完整GC(参见表3中的“CPU利用率”,显然作业3暂停了将近100秒),并且长暂停时间显着拖累了整个应用程序操作。如表4所示,尽管总运行时间略长于Parallel GC,但G1 GC的性能略好于CMS GC。

表3:G1 GC运行状态(调整前)

配置选项 -XX:+ UseG1GC -XX:+ PrintFlagsFinal -XX:+ PrintReferenceGC -verbose:GC -XX:+ PrintGCDetails -XX:+ PrintGCTimeStamps -XX:+ PrintAdaptiveSizePolicy -XX:+ UnlockDiagnosticVMOptions -XX:+ G1SummarizeConcMark -Xms88g -Xmx88g
阶段* 屏幕截图2015-05-26于3.02.59 PM
任务* 屏幕截图2015-05-26在3.03.42 PM
中央处理器* 屏幕截图2015-05-26于3.04.24 PM
纪念品* 屏幕截图2015-05-26于3.05.59 PM

表4三个垃圾收集器程序运行时间比较(调优前88GB堆)

垃圾收集器 运行时间为88GB堆
并行GC 6.5min
CMS GC 9分钟
G1 GC 7.6min
基于日志调整G1收集器[4] [5]
设置G1 GC后,下一步是根据GC日志进一步调整收集器性能。

首先,我们希望JVM在GC日志中记录更多细节。因此对于Spark,我们设置“spark.executor.extraJavaOptions”以包含其他标志。一般来说,我们需要设置这样的选项:

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

定义了这些选项后,我们会在Spark的执行程序日志中跟踪详细的GC日志和有效的GC选项(输出到 S P A R K H O M E / w o r k / SPARK_HOME/work/ app_id/$executor_id/stdout每个工作节点)。接下来,我们可以根据GC日志分析问题的根本原因,并学习如何提高程序性能。

让我们看一下G1 GC日志的结构,如下所示,例如在G1 GC中使用混合GC。

251.354:[G1人体工程学(混合GC)继续混合GC,原因:候选旧区域可用,候选旧区域:363个区域,可回收:9830652576字节(10.40%),阈值:10.00%]

[平行时间:145.1毫秒,GC工人: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]

[更新RS(ms):最小值:0.0,平均值:0.3,最大值:0.6,差值:0.6,总和:5.8]

[已处理缓冲区:最小值:0,平均值:1.6,最大值:9,差值:9,总和:37]

[Scan RS(ms):Min:6.0,Avg:6.2,Max:6.3,Diff:0.3,Sum:143.0]

[对象复制(毫秒):最小值:136.2,平均值:136.3,最大值:136.4,差值:0.3,总和:3133.9]

[终止(ms):最小值:0.0,平均值:0.0,最大值:0.0,差值:0.0,总和: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]

[代码根修正:0.0毫秒]

[清除CT:6.6 ms]

[其他:26.8毫秒]

[选择CSet:0.2毫秒]

[Ref Proc:16.6 ms]

[Ref Enq:0.9 ms]

[免费CSet:2.0毫秒]

[伊甸园:3904.0M(3904.0M) - > 0.0B(4448.0M)幸存者:576.0M-> 32.0M堆:63.7G(88.0G) - > 58.3G(88.0G)]

[时间:用户= 3.43 sys = 0.01,实际= 0.18秒]
从这个日志中,我们可以看到G1 GC日志具有非常清晰的层次结构。日志列出暂停发生的时间和原因,并对各种线程的时间消耗以及平均和最大CPU时间进行评级。最后,G1 GC列出了此暂停后的清理结果以及总耗时。

在我们当前的G1 GC运行日志中,我们找到了一个特殊的块,如下所示:

(空间耗尽),1.0552680秒]

[平行时间:958.8毫秒,GC工人: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过滤(ms):最小值:0.0,平均值:0.0,最大值:0.3,差值:0.3,总和:0.3]

[更新RS(ms):最小值:0.0,平均值:1.2,最大值:2.1,差值:2.1,总和:26.9]

[已处理缓冲区:最小值:0,平均值:2.8,最大值:11,差值:11,总和:65]

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

[对象复制(毫秒):最小值:952.5,平均值:953.0,最大值:954.3,差值:1.7,总和:21919.4]

[终止(ms):最小值:0.0,平均值:0.1,最大值:0.2,差值:0.2,总和: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]

[代码根修正:0.0毫秒]

[清除CT:0.4 ms]

[其他:96.0毫秒]

[选择CSet:0.0 ms]

[Ref Proc:0.4 ms]

[Ref Enq:0.0 ms]

[免费CSet:0.1毫秒]

[伊甸园:160.0M(3904.0M) - > 0.0B(4480.0M)幸存者:576.0M-> 0.0B堆:87.7G(88.0G) - > 87.7G(88.0G)]

[时间:用户= 1.69 sys = 0.24,真实= 1.05秒]

760.981:[G1Ergonomics(Heap Sizing)尝试堆扩展,原因:分配请求失败,分配请求:90128字节]

760.981:[G1Ergonomics(堆大小)扩展堆,请求扩展量:33554432字节,尝试扩展量:33554432字节]

760.981:[G1Ergonomics(堆大小)没有扩展堆,原因:堆扩展操作失败]

760.981:[全GC 87G-> 36G(88G),67.4381220秒]
正如我们所看到的,最大的性能下降是由这样一个完整的GC引起的,并且在日志中输出为To-space Exhausted,To-space Overflow或类似的(对于各种JVM版本,输出可能看起来略有不同)。原因是当G1 GC收集器尝试为某些区域收集垃圾时,它无法找到可以将活动对象复制到的空闲区域。这种情况称为疏散失败,通常会导致完整的GC。显然,G1 GC中的完整GC甚至比并行GC更差,因此我们必须尽量避免使用完整的GC以获得更好的性能。为避免G1 GC中的完整GC,有两种常用方法:

减少InitiatingHeapOccupancyPercent选项的值(默认值为45),让G1 GC在较早的时间开始初始并发标记,这样我们就更有可能避免使用完整的GC。
增加ConcGCThreads选项的值,以便为并发标记提供更多线程,从而我们可以加快并发标记阶段。请注意,此选项还可能占用一些有效的工作线程资源,具体取决于您的工作负载CPU利用率。
调整这两个选项可以最大限度地减少完整GC出现的可能性。消除完整的GC后,性能显着提高。但是,我们仍然发现GC期间有很长时间的暂停。在进一步调查中,我们在日志中发现以下情况:

280.008:[G1Ergonomics(Concurrent Cycles)请求并发周期启动,原因:占用率高于阈值,占用率:62344134656字节,分配请求:46137368字节,阈值:42520176225字节(45.00%),来源:并发大量分配]
在这里,我们看到了巨大的物体(物体的大小是标准区域的50%或更大)。G1 GC会将每个对象放在连续的区域集中。而且由于复制这些对象会占用大量资源,因此直接从旧一代中分配出大量的物体(绕过所有年轻的GC),然后将其分类为巨大的区域[4]。在1.8.0_u40之前,需要一个完整的堆活动分析来回收巨大的区域[ JDK-8027959]。如果有很多此类对象,堆将很快填满,并且回收它们太昂贵了。即使有了修复(它们确实提高了回收大量对象的效率),连续区域的分配仍然更加昂贵(特别是在遇到严重的堆碎片时),因此我们希望避免创建这种大小的对象。我们可以增加值G1HeapRegionSize以减少创建大区域的可能性,但是如果我们使用相对较大的堆,则默认值已经达到其最大大小32M。这意味着我们只能分析程序以找到这些对象并最小化它们的创建。否则,它可能会导致更多并发标记阶段,之后,您需要仔细调整混合GC相关旋钮(例如,-XX:G1HeapWastePercent -XX:G1MixedGCLiveThresholdPercent)避免长时间混合GC暂停(由大量大量物体引起)。

接下来,我们可以分析从循环开始到混合GC结束的单个GC循环的间隔。如果时间太长,您可以考虑增加其值ConcGCThreads,但请注意,这将占用更多的CPU资源。

G1 GC还有一些方法可以减少STW暂停长度,以换取在垃圾收集的并发阶段做更多的工作。如上所述,G1 GC为每个区域维护一个记忆集(RSet),以跟踪外部区域对给定区域的对象引用,G1收集器在STW阶段和并发阶段更新RSets。如果您正在寻求使用G1 GC减少STW暂停的长度,则可以G1RSetUpdatingPauseTimePercent在增加值的同时减小值G1ConcRefinementThreads。该选项G1RSetUpdatingPauseTimePercent用于在总STW时间内指定所需的RSets更新时间比率,默认为10%G1ConcRefinementThreads用于定义程序运行期间维护RSets的线程数。通过调整这两个选项,我们可以将更多的RSets更新工作负载从STW阶段转移到并发阶段。

此外,对于长时间运行的应用程序,我们使用该AlwaysPreTouch选项,因此JVM在启动时应用操作系统所需的所有内存,并避免动态应用程序。这以延长开始时间为代价提高了运行时性能。

最后,经过几轮GC参数调整后,我们得出了表5中的结果。与之前的结果相比,我们最终获得了更满意的运行效率。

表5 G1 GC运行状态(调整后)

配置选项 -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
阶段* 屏幕截图2015-05-26于3.20.31 PM
任务* 屏幕截图2015-05-26在3.21.36 PM
中央处理器* 屏幕截图2015-05-26在3.22.13 PM
纪念品* 屏幕截图2015-05-26在3.22.45 PM
结论:

我们建议尝试使用G1 GC与Spark应用程序的替代品。通过GC日志分析可以获得更细粒度的优化。调整后,我们成功地将应用程序的运行时间缩短为4.3分钟。与调优前的运行时间相比,我们实现了1.7倍的性能提升; 与Parallel GC相比,增加了1.5倍或更少。

摘要
对于严重依赖内存计算的Spark应用程序,GC调优尤为重要。当GC出现问题时,不要急于调试GC本身。首先考虑Spark程序的内存管理效率低下,例如在缓存中保留和释放RDD。在调整垃圾收集器时,我们首先建议使用G1 GC来运行Spark应用程序。G1收集器可以很好地处理Spark常见的堆积大小。使用G1,需要更少的选项来提供更高的吞吐量和更低的延迟。当然,GC调整没有固定的模式。各种应用程序具有不同的特性,为了应对不可预测的情况,必须根据日志和其他取证来掌握GC调整的艺术。最后,我们不能忘记通过程序的逻辑和代码进行优化,

通过使用G1 GC,我们在Spark应用程序中实现了主要的性能改进。Spark的未来工作将把内存管理责任从Java的垃圾收集器转移到Spark本身。这将减轻Spark应用程序的大部分调整要求。尽管如此,今天的垃圾收集器选择可以提高关键任务Spark应用程序的性能。

承认
在本文的调整练习和写作过程中,我们得到了英特尔Java Runtime团队高级工程师王燕平女士的指导和帮助。

*表示使用英特尔大数据团队开发的内部性能分析工具生成的图表。

**表示Oracle文档中的图像。有关详细信息,请参阅参考文献[2] [3]

参考
[1] https://docs.oracle.com/cd/E13150_01/jrockit_jvm/jrockit/geninfo/diagnos/garbage_collect.html#wp1086917

[2] http://www.oracle.com/webfolder/technetwork/tutorials/obe/java/gc01/index.html

[3] http://www.oracle.com/webfolder/technetwork/tutorials/obe/java/G1GettingStarted/index.html

[4] http://www.infoq.com/articles/tuning-tips-G1-GC

[5] https://blogs.oracle.com/poonam/entry/understanding_g1_gc_logs

关于作者:
Daoyuan Wang,英特尔亚太研发有限公司SSG STO大数据技术软件工程师,也是Apache社区的活跃Spark贡献者。

黄杰,英特尔亚太研发有限公司SSG STO大数据技术工程经理

Databricks博客

猜你喜欢

转载自blog.csdn.net/hsg77/article/details/86720576