一.对象存储概述
在调整内存使用中有三个方面需要考虑:对象存储所使用的内存【可能希望在内存中存储整个数据集,以方便使用】、访问这些对象的成本及垃圾回收的开销【对象更新频繁】。
默认情况下,Java对象的访问速度很快,但是与其字段内的原始数据相比,它们很容易消耗2-5倍的空间。原因如下:
- 每个不同的Java对象都有一个对象头,大约16个字节,其中包含诸如指向其类的指针之类的信息。对于其中数据很少的对象【例如一个Int字段】,该对象头大小可能大于数据本身。
- Java String类型相对于原始字符串数据有大约40个字节的开销【因为它们将其存储在Chars 数组中并保留诸如长度之类的额外数据】,并且由于UTF-16的内部用法,因此每个字符都存储为两个字节String编码。因此,一个10个字符的字符串可以轻松消耗60个字节。
- 诸如HashMap和通用集合类LinkedList使用链接的数据结构,其中每个条目【例如Map.Entry】都是一个包装对象。该对象不仅具有标题,而且具有指向列表中下一个对象的指针【通常每个指针8个字节】。
- 基本类型的集合通常将它们存储为封装对象,例如java.lang.Integer。
二.内存管理概述
Spark中的内存使用情况大体上属于以下两种类别之一:执行和存储。执行内存是指用于shuffles, joins, sorts和aggregations等操作使用的内存,而存储内存是指用于在集群中缓存和分发内部数据的内存。在Spark中,执行和存储共享一个统一的区域(M)。当不使用执行内存时,存储可以获取所有可用内存,反之亦然。如果有必要,执行可能会释放存储内存,但只有在存储总内存使用量下降到某个阈值(R)以下时,才可以执行该操作。换句话说,R描述了一个子区域,在M中该子区域永远不会移出缓存的块。由于实现的复杂性,存储可能无法退出执行。
这种设计确保了几种理想的性能。首先,不使用缓存的应用程序可以将整个空间用于执行,从而避免了不必要的磁盘溢写。其次,确实使用缓存的应用程序可以保留最小的存储空间(R),以免其数据块被清除缓存。最后,这种方法可为各种工作负载提供合理的即用即分性能,而无需用户了解如何在内部划分内存。
尽管有两种相关的配置,但典型用户无需调整它们,因为默认值适用于大多数工作负载:
- spark.memory.fraction表示大小为M【JVM堆空间-300MB】的一部分【默认值为0.6】。其余的空间【0.4】用于存储用户数据结构、Spark中的内部元数据以及在稀疏和异常大的数据情况下防止OOM错误。
- spark.memory.storageFraction阈值大小R【默认值为M的0.5】。 R是M中的缓存块不受执行影响而退出的存储空间。
三.确定内存消耗
确定数据集所需的内存消耗量的最佳方法是创建一个RDD,将其放入缓存中,然后查看Web UI中的“ Storage”页面。该页面将告诉RDD占用了多少内存。
要估算特定对象的内存消耗,请使用SizeEstimator的estimate方法。这对于尝试使用不同的数据布局以减少内存使用量以及确定广播变量将在每个执行程序堆上占用的空间量很有用。
四.调整数据结构
减少内存消耗的第一种方法是避免使用Java功能,这些功能会增加开销,例如基于指针的数据结构和包装对象。做这件事有很多种方法:
- 设计数据结构,使其更适合对象数组和原始类型,而不是标准的Java或Scala集合类。fastutil 库提供方便的集合类基本类型是与Java标准库兼容。
- 尽可能避免使用带有许多小对象和指针的嵌套结构。
- 考虑使用数字ID或枚举对象代替键的字符串。
- 如果RAM少于32 GiB,则设置JVM标志-XX:+UseCompressedOops以使指针为四个字节而不是八个字节。可以在spark-env.sh中添加这些选项。
五.序列化RDD存储
当对象仍然太大而无法进行优化存储时,减少内存使用的一种更简单的方法是使用RDD持久性API中的序列化StorageLevels 以序列化形式存储它们。然后,Spark将每个RDD分区存储为一个大字节数组。由于必须动态地反序列化每个对象,因此以串行形式存储数据的唯一缺点是访问时间较慢。如果要缓存序列化形式的数据,我们强烈建议使用Kryo,因为它的大小比Java序列化【当然也比原始Java对象】小。
六.垃圾收集优化
当在程序存储的RDD方面有较大的“搅动”时,JVM垃圾回收可能会成为问题。【在只读取一次RDD然后对其执行许多操作的程序中,这通常不是问题。】当Java需要替换旧对象以为新对象腾出空间时,它将需要遍历所有Java对象并查找未使用的。这里要记住的要点是,垃圾回收的成本与Java对象的数量成正比,因此使用具有较少对象的数据结构【例如Int类型的数组而不是一个Int类型的LinkedList】可以大大降低此成本。更好的方法是如上所述以序列化形式持久化对象。
由于任务的工作内存【运行任务所需的空间量】与节点上缓存的RDD之间的干扰,GC也会成为问题。
衡量GC的影响
GC调整的第一步是收集有关垃圾收集发生频率和GC使用时间的统计信息。这可以通过添加-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStampsJava选项来完成。下次运行Spark作业时,每次发生垃圾收集时,都会在工作日志中看到打印的消息。请注意,这些日志将位于群集的工作节点上【stdout位于其工作目录中的文件中】,而不位于驱动程序上。
高级GC调整
为了进一步调整垃圾回收,我们首先需要了解有关JVM中内存管理的一些基本信息:
- Java Heap空间分为Young【年轻代】和Old【年老代】两个区域。年轻代用于保存寿命短的对象,而年老代则用于寿命更长的对象。
- 年轻代又分为三个区域[Eden,Survivor1,Survivor2]。
- 垃圾收集过程的简化描述:当Eden已满时,将在Eden上运行次要GC,并将来自Eden和Survivor1的活动对象复制到Survivor2。Survivor区域被交换。如果对象足够旧或Survivor2已满,则将其移到年老代。最后,当Old接近满时,将调用完整的GC。
在Spark中进行GC调整的目的是确保仅将长寿命的RDD存储在年老代中,并确保年轻代的大小足以存储短寿命的对象。这将有助于避免完整的GC收集任务执行期间创建的临时对象。可能有用的一些步骤是:
- 通过收集GC统计信息检查是否有太多垃圾回收。如果在任务完成之前多次调用一个完整的GC,则意味着没有足够的内存来执行任务。
- 如果次要集合太多,但主要GC却没有很多,那么为Eden分配更多的内存将有所帮助。可以将Eden的大小设置为高于每个任务所需的内存量。如果确定Eden的大小为E,则可以使用选项设置年轻代的大小-Xmn=4/3*E。按4/3比例放大也是为了考虑Survivor区域使用的空间。
- 在打印的GC统计信息中,如果年老代即将满,请通过降低spark.memory.fraction来减少用于缓存的内存量;最好缓存较少的对象,而不是减慢任务的执行。或者,考虑减小年轻代的大小。-Xmn如果如上所述进行设置,则意味着降低。如果不是,请尝试更改JVM NewRatio参数的值。许多JVM将此默认值设置为2,这意味着年老代占据了堆的2/3。它应该足够大以使超过spark.memory.fraction。
- 使用尝试G1GC垃圾收集器-XX:+UseG1GC。在垃圾收集成为瓶颈的某些情况下,它可以提高性能。需要注意的是大executor导致的堆大小,可能重要的是增加-XX:G1HeapRegionSize以增加G1区域大小。
- 如果任务是从HDFS读取数据,则可以使用从HDFS读取的数据块的大小来估算任务使用的内存量。注意,解压缩块的大小通常是块大小的2~3倍。因此,如果我们希望拥有3到4个任务的工作空间,并且HDFS块大小为128 MB,则我们可以将Eden的大小估计为43128MB。
- 使用新设置监视垃圾回收所花费的频率和时间如何变化。
经验表明,GC调整的效果取决于应用程序和可用内存量。有更多的微调选项,但在较高的水平,管理GC如何充分频繁发生可以帮助减少开销。
可以通过设置spark.executor.defaultJavaOptions或spark.executor.extraJavaOptions在作业的配置中指定执行程序的GC调整标志。
七.提高并行度
有时,会出现OutOfMemoryError的原因不是因为RDD不能容纳在内存中,而是因为reduceByKey等之类的操作导致工作集太大。Spark的聚合操作(sortByKey,groupByKey,reduceByKey,join,等)建立每个任务中的哈希表来进行分组,而这往往是大的。这里最简单的解决方法是提高并行度,以使每个任务的输入集更小。Spark可以高效地支持短至200 ms的任务,因为它可以在多个任务中重用一个执行器JVM,并且任务启动成本较低,因此可以安全地将并行度提高到集群中内核数量的2~3倍。
八.广播大变量
利用SparkContext提供的广播功能可大大降低每个序列化任务的大小,并降低集群启动作业的成本。如果任务使用驱动程序中的任何大对象【例如,静态查找表】,请考虑将其转换为广播变量。Spark在主服务器上打印每个任务的序列化大小,因此可以查看它来确定任务是否太大;通常,大于20 KB的任务可能值得优化。
九.数据局部性原理
数据局部性可能会对Spark作业的性能产生重大影响。如果数据和对其进行操作的代码在一起,则计算速度往往会很快。但是,如果代码和数据是分开的,那么一个必须移到另一个所在的节点。通常,从一个节点到另一个节点传送序列化代码要比块数据更快,因为代码大小比数据小得多。Spark围绕此数据本地性原则构建调度。
数据局部性是数据与处理它的代码之间的接近程度。根据数据的当前位置,可分为多个级别。从最远到最远的顺序:
- PROCESS_LOCAL数据与正在运行的代码位于同一JVM中。这是最好的情况。
- NODE_LOCAL代码和数据在同一节点上。数据可能在同一节点上的HDFS中,或者在同一节点上的另一执行程序中。由于数据必须在进程之间传输因而要比这比PROCESS_LOCAL慢一些。
- NO_PREF 可以从任何地方快速访问数据,并且不受位置限制。
- RACK_LOCAL数据在同一服务器机架上。数据位于同一机架上的另一台服务器上,因此通常需要通过网络进行代码发送。
- ANY 数据在网络上的其他位置,而且不在同一机架中。
Spark倾向于在最佳位置级别安排所有任务,但这并不总是可能的。在任何空闲执行器上没有未处理的数据的情况下,Spark会切换到较低的本地级别。有两种选择:
- 等待忙碌的CPU释放以在同一服务器上的数据上启动任务。
- 立即将数据移动到更远的地方启动新任务。
Spark通常要做的是稍等一下,以期释放繁忙的CPU。一旦超时到期,它将开始将数据移至空闲的CPU。每个级别之间的回退等待超时可以单独配置,也可以一起配置在一个参数中。如果任务很长并且位置不佳,则应该增加这些设置,但是默认设置通常效果很好。