Spark 作为一个基于内存的分布式计算引擎,其内存管理模块在整个系统中扮演着非常重要的角色。理解 Spark 内存管理的基本原理,有助于更好地开发 Spark 应用程序和进行性能调优。




spark 运行内存 spark内存优化_内存管理


如果提交的时候内存分配过大则占用资源,内存分配过小就容易出现内存溢出和fullGC的问题,报如下异常:



java heap out of memory FetchFailedException

FileNotFoundException

Executor heartbeat timed out

executor lost

GC overhead limit exceeded


而spark在submit的时候都是设定连个内存分别如图所示:



spark 运行内存 spark内存优化_spark 运行内存_02


Driver的内存管理相对来说较为简单,Spark不做具体规划。下面主要对Executor的内存管理进行分析


目录:



1   堆内堆外内存规划




2 内存空间分配


存储内存管理




执行内存管理




个人优化建议




一,堆内堆外内存规划:


的内存管理建立在 JVM 的内存管理之上, Spark 对 JVM 的堆内( On-heap )空间进行了更为详细的分配,以充分利用内存。同时, Spark 引入了堆外( Off-heap )内存,使之可以直接在工作节点的系统内存中开辟空间,进一步优化了内存的使用


堆内内存:受JVM管理

堆外内存:不受jvm管理


spark 运行内存 spark内存优化_spark 运行内存_03



堆内内存:


由 Spark 应用程序启动时的 –executor-memory 或 spark.executor.memory 参数配置


Executor 内运行的并发任务共享 JVM 堆内内存 , 主要用于 缓存和 shuffle


Spark 对堆内内存的管理是一种 逻辑上的 " 规划式 " 的管理,因为对象实例占用内存的申请和释放都由 JVM 完成, Spark 只能在申请后和释放前 记录 这些内存


对于非序列化的对象,其占用的内存是通过周期性地采样近似估算而得,不能准确记录实际可用的堆内内存,从而也就无法完全避免内存溢出的异常


虽然不能精准控制堆内内存的申请和释放,但 Spark 通过对存储内存和执行内存各自独立的规划管理,在一定程度上可以提升内存的利用率,减少异常的出现






对外内存:



在默认情况下堆外内存并不启用,可通过配置 spark.memory.offHeap.enabled 参数启用,并由 spark.memory.offHeap.size 参数设定堆外空间的大小。


存储经过序列化的二进制数据。


Spark 可以直接操作系统堆外内存,减少了不必要的内存开销,以及频繁的 GC 扫描和回收,提升了处理性能。堆外内存可以被精确地申请和释放,而且序列化的数据占用的空间可以被精确计算,所以相比堆内内存来说降低了管理的难度,也降低了误差。


除了没有 other 空间,堆外内存与堆内内存的划分方式相同,所有运行中的并发任务共享存储内存和执行内存。



内存空间的分配:


Spark 为存储内存和执行内存的管理提供了统一的接口 —— MemoryManager ,同一个 Executor 内的任务都调用这个接口的方法来申请或释放内存。


MemoryManager 有两种具体实现, Spark1.6 之后默认为统一管理( UnifiedMemoryManager )方式, 1.6 之前采用的静态管理( StaticMemoryManager )方式仍被保留,可通过配置 spark.memory.useLegacyMode 参数启用。两种方式的区别在于对空间分配的方式。




静态管理:


存储内存、执行内存和其他内存的大小在 Spark 应用程序运行期间均为固定的,但用户可以应用程序启动前进行配置


spark 运行内存 spark内存优化_序列化_04


spark 运行内存 spark内存优化_spark_05



缺点:如果用户不熟悉 Spark 的存储机制,或没有根据具体的数据规模和计算任务或做相应的配置,容易出现存储内存和执行内存中的一方剩余大量的空间,而另一方却早早被占满,不得不淘汰或移出旧的内容以存储新的内容,造成程序执行缓慢甚至失败


统一管理


Spark1.6 之后引入的统一内存管理机制,与静态内存管理的区别在于存储内存和执行内存共享同一块空间,可以动态占用对方的空闲区域


spark 运行内存 spark内存优化_内存管理_06




spark 运行内存 spark内存优化_spark 运行内存_07


Spark1.6 之后引入的统一内存管理机制,与静态内存管理的区别在于存储内存和执行内存共享同一块空间,可以动态占用对方的空闲区域




动态占用机制



spark 运行内存 spark内存优化_内存管理_08


优点:在一定程度上提高了堆内和堆外内存资源的利用率,降低了开发者维护 Spark 内存的难度


存储内存管理


RDD 的持久化机制 :


如果一个 RDD 上要执行多次 Action ,可以在第一次 Action 中使用 persist 或 cache 方法,在内存或磁盘中持久化或缓存这个 RDD ,从而在后面的行动时提升计算速度。


堆内和堆外存储内存的设计,便可以对缓存  RDD  时使用的内存做统一的规划和管理


spark 运行内存 spark内存优化_spark 运行内存_09




spark 运行内存 spark内存优化_内存管理_10



RDD 缓存的过程


RDD 在缓存到存储内存之前, Partition 中的数据一般以迭代器( Iterator )的数据结构来访问。通过 Iterator 可以获取分区中每一条序列化或者非序列化的数据项 (Record) ,这些 Record 的对象实例在逻辑上占用了 JVM 堆内内存的 other 部分的空间,同一 Partition 的不同 Record 的空间并不连续。


RDD 在缓存到存储内存之后, Partition 被转换成 Block , Record 在堆内或堆外存储内存中占用一块连续的空间。



将 Partition 由不连续的存储空间转换为连续存储空间的过程, Spark 称之为 " 展开 " ( Unroll )。




Partition ,其所需的 Unroll 空间可以直接累加计算,一次申请。而非序列化的 Partition 则要在遍历 Record 的过程中依次申请。如果最终 Unroll 成功,当前 Partition 所占用的 Unroll 空间被转换为正常的缓存 RDD 的存储空间


spark 运行内存 spark内存优化_spark_11


 淘汰


由于同一个 Executor 的所有的计算任务共享有限的存储内存空间,当有新的 Block 需要缓存但是剩余空间不足且无法动态占用时,就要对 LinkedHashMap 中的旧 Block 进行淘汰( Eviction ),而被淘汰的 Block 如果其存储级别中同时包含存储到磁盘的要求,则要对其进行落盘( Drop ),否则直接删除该 Block 。淘汰规则为:


被淘汰的旧 Block 要与新 Block 的 MemoryMode 相同,即同属于堆外或堆内内存


新旧 Block 不能属于同一个 RDD ,避免循环淘汰


旧 Block 所属 RDD 不能处于被读状态,避免引发一致性问题


遍历 LinkedHashMap 中 Block ,按照最近最少使用( LRU )的顺序淘汰,直到满足新 Block 所需的空间。其中 LRU 是 LinkedHashMap 的特性。


落盘


落盘的流程则比较简单,如果其存储级别符合 _ useDisk 为 true 的条件,再根据其 _ deserialized 判断是否是非序列化的形式,若是则对其进行序列化,最后将数据存储到磁盘,在 Storage 模块中更新其信息。




执行内存管理



多任务间内存分配


Executor 内运行的任务同样共享执行内存, Spark 用一个 HashMap 结构保存了任务到内存耗费的映射。每个任务可占用的执行内存大小的范围为 1/2N~ 1/N ,其中 N 为当前 Executor 内正在运行的任务的个数。每个任务在启动之时,要向 MemoryManager 请求申请最少为 1/2N 的执行内存,如果不能被满足要求则该任务被阻塞,直到有其他任务释放了足够的执行内存,该任务才可以被唤醒。


Shuffle 的内存占用



在排序和聚合过程中, Spark 会使用一种 ExternalAppendOnlyMap 结构在堆内执行内存中存储数据,但在 Shuffle 过程中所有数据并不能都保存到该哈希表中,当这个哈希表占用的内存会进行周期性地采样估算,当其大到一定程度,无法再从 MemoryManager 申请到新的执行内存时, Spark 就会将其全部内容存储到磁盘文件中,这个过程被称为溢存 (Spill) ,溢存到磁盘的文件最后会被归并 (Merge)





AppendOnlyMap



Spark 设计了两种:一种是全内存的 SizeTrackingAppendOnlyMap ,继承自 AppendOnlyMap ,另一种是内存+磁盘的 ExternalAppendOnlyMap 。


AppendOnlyMap 原理很简单,开一个大 Object 数组,蓝色部分存储 Key ,白色部分存储 Value


当要 put(K,V) 时,先 hash(K) 找存放位置,如果存放位置已经被占用,就使用 Quadraticprobing 探测方法来找下一个空闲位置。


有一个  destructiveSortedIterator (): Iterator [(K,V)]  方法,可以返回 Array 中排序后的 (K,V) pairs 。实现方法很简单:先将所有 (K,V) pairs compact 到 Array 的前端,并使得每个 (K,V) 占一个位置(原来占两个),之后直接调用 Array.sort ( keyComparator ) 排序。




spark 运行内存 spark内存优化_序列化_12


ExternalAppendOnlyMap



ExternalAppendOnlyMap 持有一个 AppendOnlyMap , shuffle 来的一个个 (K,V) record 先 insert 到 AppendOnlyMap 中, insert 过程与原始的 AppendOnlyMap 一模一样。



如果 AppendOnlyMap 快被装满时检查一下内存剩余空间是否可以够扩展,够就直接在内存中扩展,如果数据一旦超出规定的阈值,就将 currentMap 按照 keyhash 排序后 spill 到磁盘上。



每次 spill 完在磁盘上生成一个 spilledMap 文件,然后重新 new 出来一个 AppendOnlyMap 重复以上操作。最后一个 (K,V) record insert 到 AppendOnlyMap 后,表示所有 shuffle 来的 records 都被 insert 到了 ExternalAppendOnlyMap 中。




insert 结束调用 ExternalAppendOnlyMap . iterator 方法,真正完成聚合, iterator 返回了一个基于内存中 AppendOnlyMap 和 DiskIterator 两部分数据的多路归并迭代器。这个迭代器,每次在调用 next 方法的时候都会在内部的优先级队列(按每个迭代器最小 hash 值作为比较对象的堆结构),寻找最小的 hash 值且 key 值相等的所有元素(因为我们每个 map 都是排序过的,所以这总能实现),进行 merge ,将所有符合要求的元素 merge 完成后返回。这样便完成了最终的聚合操作。




多路归并




spark 运行内存 spark内存优化_spark 运行内存_13


个人优化建议


目标:内存有限的情况下,减少 shuffle 操作或需要 shuffle 的数据量


数据倾斜,某些 key 对应着大量的 value ,导致 shuffle 时内存不够出现大量 GC 和 Spill 到磁盘。查找出倾斜的 key 提前 filter 掉


尽量使用 reduceByKey,CombineBykey 替代 groupBy 类算子


需要 join 时如果其中一个 rdd 较小可以 broadcast 该 rdd


慎用 coalesce ( n )合并分区,不产生 shuffle 可能会导致从头到尾只有 n 个 task 执行


慎用 cache 和 persist ,缓存会占用大量内存,可能导致执行内存不足


避免使用会增加开销的 java 特性,例如基于指针的数据结构和包装器对象。将数据结构设计为更倾向于数组结构和基本类型,而不是标准的 Java 或是 Scala 集合类(例如 . HashMap )