Spark-内存管理调优
这篇文章主要是对官网内容学习过程的总结,大部分是原文,加上自己的学习笔记!!!
- spark 2.0+内存模型
调优内存使用时需要考虑三个因素:
- 对象使用的内存数量(您可能希望您的整个数据集都能装入内存);
- 访问这些对象的成本
- 垃圾收集的开销(如果对象的周转率很高)。
默认情况下,Java对象访问速度很快,但是很容易比字段中的“原始”数据多消耗2-5倍的空间。这是由于几个原因:
- 每个不同的Java对象都有一个
object header
,它大约是16个字节,包含指向其类的指针等信息。对于一个只有很少数据的对象(比如一个Int字段),它可以比数据更大。- Java字符串在原始字符串数据上大约有40个字节的开销(因为它们存储在字符数组中,并保存额外的数据,比如长度),由于字符串内部使用UTF-16编码,所以每个字符都存储为两个字节。因此,一个10个字符的字符串可以轻松地消耗60个字节。
- 公共集合类,如
HashMap
和LinkedList
,使用链接数据结构,其中每个条目都有一个“包装器”对象(例如Map.Entry
)。这个对象不仅有一个头,而且还有指向列表中下一个对象的指针(通常每个指针8个字节)。
4.基本类型的集合通常将它们存储为“装箱的”对象,如java.lang.Integer
。
Spark中的内存使用主要分为两类:执行和存储。
执行内存:指的是在shuffles, joins, sorts , aggregations
中用于计算的内存;
存储内存:指的是在集群中缓存和传播内部数据的内存;
在Spark中,执行和存储共享一个统一的区域(M),当没有执行内存时,存储可以获得所有可用的内存,反之亦然;
如果有必要,执行可能会删除存储,但仅在总存储内存使用量低于某个阈值(R)时才会删除存储。
这种设计确保了几个理想的性能:
- 首先,不使用缓存的应用程序可以使用整个空间执行,从而避免不必要的磁盘溢出;
- 其次,确实使用缓存的应用程序可以保留一个最小的存储空间(R),在这里它们的数据块不会被驱逐;
- 最后,这种方法为各种工作负载提供了合理的开箱即用性能,而不需要用户了解如何在内部划分内存。
虽然有两种相关配置,但由于默认值适用于大多数工作负载,一般用户不需要调整它们:
spark.memory.fraction
:用 M 表示 (JVM heap space - 300MB) (default 0.6)的一部分,其余的空间(40%)预留给用户数据结构、Spark中的内部元数据,以及在稀疏且异常大的记录情况下防止OOM错误。
spark.memory.storageFraction
:用R表示M的一部分(默认值为0.5)。R是M中的存储空间,其中缓存的块不会被执行驱逐。
- 优化数据结构:
减少内存消耗的第一种方法是避免增加开销的Java特性,比如基于指针的数据结构和包装器对象。有几种方法可以做到这一点:
- 设计数据结构选择对象数组和基本类型,而不是标准的Java或Scala集合类(例如HashMap)。fastutil库为与Java标准库兼容的基本类型提供了方便的集合类。
- 在可能的情况下,避免使用包含大量小对象和指针的嵌套结构。
- 考虑使用
numeric IDs
或enumeration objects
对象代替键的字符串。- 如果内存不足32 GB,可以设置JVM标志-XX:+UseCompressedOops,使指针由8个字节变为4个字节。您可以在spark-env.sh中添加这些选项。
- 序列化RDD存储:
当对象仍然太大而无法有效地存储(尽管进行之前调优)时,减少内存使用的一个更简单的方法是使用RDD持久化API中的序列化存储级别(如
MEMORY_ONLY_SER
)以序列化的形式存储它们。
Spark然后将每个RDD分区存储为一个大字节数组。以序列化形式存储数据的惟一缺点是访问时间较慢,因为必须动态地反序列化每个对象。
如果希望以序列化的形式缓存数据,强烈建议使用Kryo,因为它比Java序列化(当然也比原始Java对象)的大小小得多。
- 垃圾收集调优:
当您的程序存储了大量的RDDs时,JVM垃圾收集可能是一个问题。(在程序中,只读取RDD一次,然后在上面运行许多操作,这通常不是问题。)当Java需要驱逐旧对象以为新对象腾出空间时,它将需要跟踪所有Java对象并找到未使用的对象。
这里要记住的要点是,垃圾收集的成本与Java对象的数量成正比,因此使用对象更少的数据结构(例如,一个int数组而不是一个LinkedList)可以大大降低此成本。一个更好的方法是以序列化的形式持久化对象,如上所述:现在每个RDD分区只有一个对象(一个字节数组)。在尝试其他技术之前,如果GC存在问题,首先要尝试的是使用序列化缓存。
由于任务的工作内存(运行任务所需的空间量)和缓存在节点上的RDDs之间存在干扰,GC也可能成为一个问题。我们将讨论如何控制分配给RDD缓存的空间来缓解这种情况。
- 测量GC的影响(Measuring the Impact of GC ):
GC调优的第一步是收集关于垃圾收集发生频率和花费GC的时间的统计信息。这可以通过向Java选项添加
-verbose:gc -XX:+PrintGCDetails -XX:+ printgctimestamp
来实现。下一次运行Spark作业时,每次发生垃圾收集时,您将看到打印在worker’s logs
的消息。
注意,这些日志将在集群的工作节点上(在工作节点中的stdout文件中),而不是在驱动程序上。
有关传递Java选项来触发作业的信息,请参阅配置指南
- 高级GC调优(Advanced GC Tuning)
为了进一步优化垃圾收集,我们首先需要了解JVM中内存管理的一些基本信息:
- Java堆空间分为两个区域,年轻的和年老的。年轻代的目的是保存短期对象,而老代的目的是保存寿命更长的对象。
- 年轻一代被进一步划分为三个区域[Eden, Survivor1, Survivor2]。
- 垃圾收集过程的简化描述:当Eden已满时,在Eden上运行一个小型GC,将Eden和Survivor1中的活动对象复制到Survivor2。将交换Survivor区域。如果一个对象足够老或Survivor2已满,则将其移动到旧的。最后,当Old接近full时,将调用一个full GC。
Minor GC——复制算法具体过程:
- 将Eden和S0中还存活着的对象一次性的复制到S1中,并且清理掉Eden与S0的空间。如果S1放不下还存活着的对象,那这些对象将通过分配担保机制进入老年代。【原理上随时保持S0和S1有一个是空的,用来存下一次的对象】
- Eden区快满的时候,会进行上一步类似操作,将Eden和S1区的年纪大的对象放到S0区【此时S1区就是空的】
- 直到Eden区快满,S0或者S1也快满的时候,这时候就把这两个区的年纪大的对象放到Old区。
- 依次循环,直到Old区也快满的时候,Eden区也快满的时候,会对整个这一块内存区域进行一次大清洗(FullGC),腾出内存,为之后的对象创建,程序运行腾地方。
来源:jdk1.8——jvm分析与调优
在Spark中进行GC调优的目标是确保只有长生命周期的RDDs存储在老一代中,并且年轻一代的大小足以存储短生命周期的对象。这将有助于避免
Major GC
在任务执行期间创建的临时对象。以下是一些可能有用的步骤:
通过收集GC统计信息来检查是否有太多的垃圾收集。如果在任务完成之前多次调用
Major GC
,这意味着没有足够的内存可用来执行任务。如果有太多的
Minor GC
而没有太多的Major GC
,那么为Eden分配更多的内存将会有所帮助。您可以将Eden的大小设置为高估于每个任务需要内存的值。如果Eden的大小被确定为E,那么可以使用选项-Xmn=4/3*E
E设置年轻一代的大小。(按比例增加4/3是考虑到survivor regions
使用的空间)在打印的GC统计数据中,如果老年代本接近满,则通过降低
spark.memory.fraction
来减少用于缓存的内存大小;缓存更少的对象比降低任务执行速度要好。
或者,考虑减少年轻一代的规模。如果你把它设为上面的值意味着降低-Xmn
。
如果没有,尝试更改JVM’s NewRatio
参数的值。许多jvm将这个值默认为2,这意味着老年代占用了堆的2/3。它应该足够大,使这个分数超过spark.memory.fraction
。尝试使用
-XX:+UseG1GC
指定G1GC垃圾收集器。在垃圾收集成为瓶颈的某些情况下,它可以提高性能。
注意,对于较大的执行器堆大小,使用-XX:G1HeapRegionSize
增加G1区域大小可能很重要。如果任务正在从HDFS读取数据,则可以使用从HDFS读取的数据块大小来估计任务所使用的内存量。
注意,解压缩块的大小通常是块大小的2或3倍。因此,如果我们希望有3或4个任务的工作空间,并且HDFS块大小为128MB,我们可以估计Eden的大小为4*3*128MB
。监视垃圾收集的频率和时间如何随着新设置而变化。
excutor的调优标志可以由spark.executor.extraJavaOptions
指定。
管理完全GC发生的频率有助于减少开销。
关于GC调优更多的参考:Java Platform, Standard Edition HotSpot Virtual Machine Garbage Collection Tuning Guide
- 级别的并行性 (Level of Parallelism)
除非为每个操作设置足够高的并行度,否则集群不会得到充分利用。
Spark根据每个文件的大小自动设置要在每个文件上运行的“map”任务的数量(尽管您可以通过SparkContext.textFile
的可选参数来控制它)。
对于分布式的reduce
操作,例如groupByKey
和reduceByKey
,它使用最大的父RDD分区数。您可以将并行度级别作为参数传递(如:def reduceByKey(func: (V, V) ⇒ V, numPartitions: Int): RDD[(K, V)]
)。,或设置配置属性spark.default.parallelism来更改默认值。通常,建议在集群中每个CPU内核执行2-3个任务。
- 减少任务的内存使用 (Memory Usage of Reduce Tasks)
有时候,会报
OutOfMemoryError
错误,不是因为RDDs的内存设置不适合,而是因为一个任务的工作集(例如groupByKey
中的一个reduce
任务)太大。
Spark的shuffle
操作(sortByKey
、groupByKey
、reduceByKey
、join
等)在每个任务中构建一个哈希表来执行分组,分组常常很大。这里最简单的修复方法是增加并行度,这样每个任务的输入集就会更小。
Spark可以有效地支持短至200 ms的任务,因为它跨多个任务重用一个executor JVM
,并且任务启动成本很低,所以可以安全地将并行性级别提高到集群中的核心数量以上。
- 广播大变量(Broadcasting Large Variables)
使用
SparkContext
中可用的广播功能可以极大地减少每个序列化任务的大小,以及通过集群启动作业的成本。
如果任务使用驱动程序中的任何大对象(例如静态查找表),考虑将其转换为广播变量。
Spark在主控器上打印每个任务的序列化大小,因此可以查看它来确定任务是否太大;一般来说,大于20 KB的任务可能值得优化。
参考:Broadcast Variables
- 数据本地性(Data Locality)
数据位置对Spark作业的性能有很大的影响。如果数据和对其进行操作的代码在一起,那么计算往往会很快。但是,如果代码和数据是分开的,那么其中一个必须转移到另一个。
通常,将序列化的代码从一个地方传送到另一个地方要比传送数据块快得多,因为代码的大小比数据小得多。
Spark根据数据局部性的一般原则构建其调度。
数据局部性是指数据与处理数据的代码之间的距离。根据数据的当前位置,有几个级别的局部性。
从最近到最远:
PROCESS_LOCAL
:数据与运行代码位于同一个JVM中。这是最好的地点;
NODE_LOCAL
:数据在同一个节点上。可能在同一节点上的HDFS中,或者在同一节点上的另一个执行器中。这比PROCESS_LOCAL稍微慢一些,因为数据必须在进程之间传递;
NO_PREF
:从任何地方访问数据的速度都一样快,而且没有区域性首选项;
RACK_LOCAL
:数据位于相同的服务器机架上。数据位于同一机架上的不同服务器上,因此需要通过网络发送数据,通常通过一个交换机;
ANY
:数据在网络的其他地方,不在同一个机架上。Spark倾向于将所有任务安排在最佳位置级别,但这并不总是可能的。
在任何空闲执行器上都没有未处理的数据的情况下,Spark切换到较低的局部性级别。
有两种选择:
- 等待繁忙的CPU释放空闲来启动同一服务器上数据上的任务;
- 立即在需要移动数据的较远的地方启动新任务。
Spark通常做的是等待一段时间,希望繁忙的CPU释放。一旦超时过期,它就开始将数据从很远的地方移动到空闲的CPU。每个级别之间回退的等待超时可以单独配置,也可以全部配置在一个spark.locality
中。
本文为本人学习总结文章,转载请注明出处!!!!