为你的spark程序调优JAVA垃圾回收

文章翻译自数砖 原文链接

Apache Spark 由于其优秀的性能,简单的 API 以及丰富的分析与计算库,被各大企业所广泛的使用。 就像很多其他的大数据系统一样,spark 也是运行在 Java 虚拟机之上( JVM )。由于 spark 需要存储大量的数据在内存中, 所以其十分依赖 Java 的内存管理和垃圾回收机制(GC)。新版本的 spark 会使用 Tungsten 来简化和优化内存的管理。在现阶段,了解 Java GC 选项和参数的用户可以来自主的调优他们的 spark 参数。本文主要讲解如果通过配置 JVM 的垃圾回收器来管理 spark, 并且用真实的使用案例来讲解如何通过 GC调优来提高 spark 程序的性能。我们主要关注于在调优 GC 时的关键因素, 例如回收的吞吐量和延迟。

Spark 和 垃圾回收器的介绍

随着 spark 在企业中的广泛使用,spark 应用的稳定和性能调优问题日益受到更多的关注。由于 spark 是基于内存的运算策略,通常会使用 100 G 甚至是更多的内存, 这在一般的 java 程序还是很少见的。在大量使用 spark 的公司中,我们会碰到各种调整是关于 spark 在执行时候的 GC 问题的。比如垃圾回收花了很长的时间,造成我们的程序运行了非常长的时间,甚至是发生程序崩溃。在本文中,我们使用真实的案例,结合具体的问题,来讨论能够缓解这些问题,优化 spark 程序的 GC 调优方法。

Java 应用程序一般采用两种经典的垃圾收集策略之一:CMS 垃圾回收器和 ParallelOld 垃圾回收器。其性能表示在低延迟和高吞吐量上。这两种策略都有性能瓶颈:CMS GC 没有做压缩, 而 Parallel GC 仅仅能做全局的压缩,这也会导致相当长的暂停时间。一般来说实时程序推荐使用 CMS GC 而离线任务推荐使用 Parallel GC。

现如今,像 spark 这样的程序同时有实时计算和传统的离线计算,我们能否找到更好的收集器?Hotspot JVM 从1.6版本开始支持第三种垃圾回收器 : Garbage-First GC ( G1GC )。 G1回收器被 Oracle 计划在未来代替 CMS GC。G1回收器的目标是为了实现 高吞吐量和低延迟。在我们讨论在spark中使用 G1 收集器时,我们先来了解一些 Java GC的背景。

Java 如何如何进行垃圾回收?

在传统的 JVM 管理中,堆空间被分为 年轻代老年代 两种。而年轻代又分为 Eden 区和两个更小的servivor区。结构如下图所示。新创建的对象会被分配到 Eden 区。每一次进行 minor GC 的时候,JVM 都会复制还在 Eden 中存活的对象到一个空的 survivor 区中。 这个方法让一个 survivor 区来控制对象,而让另外一个 survivor 区域空置来为下一次垃圾回收做准备。在经过数次 minor GG后,仍然存活的对象将会被复制到老年代。当老年代填满之后,一次 major GC 会暂停所有的线程来进行 full GC,然后来移除老年代中的对象。这个暂停所有线程的操作叫做 Stop-The-World (STW)。大多数 GC 算法的性能损失都是发生在这个时候。

img

Java 新的 G1 回收器 完全改变了传统的 GC 方法。堆空间被划分为许多相同大小的堆区域。每一个区域都被分配相同的职责(Eden,survivor,old)在下一次的收集中,但是空间大小会发生改变。这带来了更灵活的内存使用。当一个对象被创建之后,他被分到到一个有空闲区间的区域中。当这个区域被填满后,JVM会创建一个新的区域来创建对象。当发生 minor GC 时, G1 复制活着的对象到一个或更多的区域。并且选择一些新的区域作为 Eden 区域。Full GC 仅仅在所有的区域都存放对象,并且没有其他空区域的的时候才会发生。G1在标记活动对象时使用“Remembered Sets(RSets)”概念。RSets使用外部的区域来监控每一个区域中对象的应用。在整个堆空间中只有一个 RSet 区域。RSet 提供完整的堆烧面,并且能够对每一个区域进行并行且独立的 GC。G1 GC 不仅能够提高在发生 full GC 时候的空间利用率,还能使 minor GC 的暂停时间得到控制,这一点对于大型内存情况比较友好。那么这些改变是如何影响 GC 性能的呢?下面我们通过简单的方法来观察性能的变化。例如通过对比老的GC 方法和 G1GC 的效果。

img

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

如果想获得 JVM 的完整 GC 参数,可以使用参数 -XX: +PrintFlagsFinal 来控制打印。或者参照 Orcal 的官方文档的解释。

理解 spark 的内存管理

RDD 是在 spark 中最核心的概念。RDD的创建和缓存于内存消耗有着直接的关系。spark 允许用户缓存 RDD 来重新使用。从而避免了重复计算带来的开销。 保留RDD的一种形式是将所有或部分数据缓存在JVM堆中。 Spark的执行程序将JVM堆空间分为两个部分:一个部分用于存储Spark应用程序持久性缓存到内存中的数据; 其余部分用作JVM堆空间,负责RDD转换期间的内存消耗。 我们可以使用spark.storage.memoryFraction参数调整这两个分数的比率,以使Spark通过确保所缓存的RDD的总大小不超过RDD堆空间量乘以该参数的值来控制其总大小。 RDD缓存部分的未使用部分也可以由JVM使用。 因此,针对Spark应用程序的GC分析应涵盖两个内存部分的内存使用情况。

当观察到由GC延迟导致的效率下降时,我们应该首先检查并确保Spark应用程序有效地使用了有限的内存空间。 RDD占用的内存空间越少,则留给程序执行的堆空间就越大,从而提高了GC效率; 相反,由于旧版本中大量的缓存对象,RDD占用的过多内存导致严重的性能损失。 在这里,我们拿一个例子对此进行解释:

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

Iteration Number Cache Size of each Iteration Total Cache Size (Before Optimization) Total Cache Size (After Optimization)
Initialization 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

选择垃圾收集器

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

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

CMS GC无法采取任何措施消除此Spark应用程序中的 full GC。 此外,CMS GC的 full GC 暂停时间比 Parallel GC长得多,这大大降低了应用程序的吞吐量。

接下来,我们使用默认的G1 GC配置运行我们的应用程序。 令我们惊讶的是,G1 GC还给出了不可接受的full GC(请参阅下表中的“ CPU利用率”,显然作业3暂停了将近100秒),并且长时间的暂停大大拖累了整个应用程序的运行。 如表所示,尽管总运行时间比 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* img
Task* img
CPU* img
Mem* img
Garbage Collector Running Time for 88GB Heap
Parallel GC 6.5min
CMS GC 9min
G1 GC 7.6min

基于 Log 来调优 G1 回收器

设置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选项。 接下来,我们可以根据GC日志分析问题的根本原因,并学习如何提高程序性能。

让我们看一下G1 GC日志的结构,例如,以G1 GC中的混合GC为例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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日志具有非常清晰的层次结构。 该日志列出了发生暂停的时间和原因,并对各个线程的时间消耗以及平均和最大CPU时间进行了分级。 最后,G1 GC会列出此暂停后的清理结果以及总的时间消耗。

在当前的G1 GC运行日志中,我们找到一个类似以下的特殊块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
(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收集器尝试为某些区域收集垃圾时,它无法找到可以将活动对象复制到的空闲区域。 这种情况称为疏散失败,通常会导致GC满载。 显然,G1 GC中的 full GC比 Parallel GC 还要差,因此我们必须避免使用 Parallel GC才能获得更好的性能。 为了避免在G1 GC中使用完全GC,有两种常用方法:

  1. 减小InitiatingHeapOccupancyPercent选项的值(默认值为45),以使G1 GC在较早的时间开始初始并发标记,以便我们更有可能避免使用完整的GC。
  2. 增加ConcGCThreads选项的值,以有更多线程用于并行标记,这样我们可以加快并行标记阶段。 请注意,根据您的工作负载CPU利用率,此选项还会占用一些有效的工作线程资源。

调整这两个选项可以最大程度地减少发生 full GC的可能性。 消除了完全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]

在这里,我们看到了巨大的对象(对象的大小为标准区域的50%或更大)。 G1 GC会将这些对象中的每一个放置在连续的区域集中。而且由于复制这些对象会消耗大量资源,因此大型对象被直接分配到老年代区域中(绕过所有年轻的GC),然后被分类为大型区域。在1.8.0_u40之前,需要完整的堆活动性分析以回收大型区域。如果有很多这样的对象,堆将很快被填满,而回收它们太昂贵了。即使进行了修复(它们确实大大提高了回收大型对象的效率),但连续区域的分配仍然更加昂贵(尤其是在遇到严重的堆碎片时),因此我们要避免创建这种大小的对象。我们可以增加G1HeapRegionSize的值来减少创建巨型区域的可能性,但是如果我们使用相对较大的堆,则默认值已经是其最大大小32M。这意味着我们只能分析程序才能找到这些对象并最小化它们的创建。否则,可能会导致更多的并发标记阶段,此后,您需要仔细调整与混合GC相关的配置(例如,-XX:G1HeapWastePercent -XX:G1MixedGCLiveThresholdPercent),以避免长时间的混合GC暂停(由许多巨大的对象引起) 。

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

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

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

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

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* img
Task* img
CPU* img
Mem* img

总结

对于严重依赖内存计算的Spark应用程序,GC调整尤为重要。当GC出现问题时,请勿着急调试GC本身。首先要考虑Spark程序的内存管理效率低下,例如持久性和释放RDD在缓存中。在调整垃圾收集器时,我们首先建议使用G1 GC运行Spark应用程序。 G1收集器已做好充分准备,可以处理Spark经常看到的不断增长的堆大小。使用G1,将需要更少的选项来提供更高的吞吐量和更低的延迟。当然,GC调整没有固定的模式。各种应用程序具有不同的特性,并且为了应对不可预测的情况,必须掌握根据日志和其他取证方法进行GC调整的技术。最后,我们不能忘记通过程序的逻辑和代码进行优化,例如减少中间对象的创建或复制,控制大型对象的创建,将长期存在的对象存储在堆外等等。

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

0%