Spark 作为一个基于内存的分布式计算引擎,其内存管理模块在整个系统中扮演着非常重要的角色

理解 Spark 内存管理的基本原理,有助于更好地开发 Spark 应用程序和进行性能调优

在执行 Spark 的应用程序时,Spark 集群会启动 Driver 和 Executor 两种 JVM 进程,前者为主控进程,负责创建 Spark 上下文,提交 Spark 作业(Job),并将作业转化为计算任务(Task),在各个 Executor 进程间协调任务的调度,后者负责在工作节点上执行具体的计算任务,并将结果返回给 Driver,同时为需要持久化的 RDD 提供存储功能。由于 Driver 的内存管理相对来说较为简单,本文主要对 Executor 的内存管理进行分析,下文中的 Spark 内存均特指 Executor 的内存

1. 堆内和堆外内存规划

作为一个 JVM 进程,Executor 的内存管理建立在 JVM 的内存管理之上,Spark 对 JVM 的堆内(On-heap)空间进行了更为详细的分配,以充分利用内存

同时,Spark 引入了堆外(Off-heap)内存,使之可以直接在工作节点的系统内存中开辟空间,进一步优化了内存的使用

spark的内存管理机制 简述spark内存模型_spark

1.1 堆内内存

堆内内存的大小,由 Spark 应用程序启动时的 –executor-memoryspark.executor.memory 参数配置

Executor 内运行的并发任务共享 JVM 堆内内存,按内存存储内容大致分为三类:

  1. 存储(Storage)内存:缓存 RDD 数据和广播(Broadcast)数据
  2. 执行(Execution)内存:执行 Shuffle 时占用的内存
  3. 其他(Other)内存:不做特殊规划,Spark 内部的对象实例,或者用户定义的 Spark 应用程序中的对象实例

Spark 不同版本提供不同的内存管理模式,这三部分占用的空间大小各不相同(下面会进行介绍)

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

申请内存

  1. Spark 在代码中 new 一个对象实例
  2. JVM 从堆内内存分配空间,创建对象并返回对象引用
  3. Spark 保存该对象的引用,记录该对象占用的内存

释放内存

  1. Spark 记录该对象释放的内存,删除该对象的引用
  2. 等待 JVM 的垃圾回收机制释放该对象占用的堆内内存

Spark 并不能准确记录实际可用的堆内内存,原因如下:

  1. Spark 中序列化的对象,如果是字节流的形式,其占用的内存大小可直接计算,而对于非序列化的对象,其占用的内存是通过周期性地采样近似估算而得
  2. 被 Spark 标记为释放的对象实例,很有可能在实际上并没有被 JVM 回收,导致实际可用的内存小于 Spark 记录的可用内存

因此无法完全避免内存溢出(OOM, Out of Memory)的异常

1.2 堆外内存

为了进一步优化内存的使用以及提高 Shuffle 时排序的效率,Spark 引入了堆外(Off-heap)内存,使之可以直接在工作节点的系统内存中开辟空间,存储经过序列化的二进制数据

利用 JDK Unsafe API(从 Spark 2.0 开始,在管理堆外的存储内存时不再基于 Tachyon,而是与堆外的执行内存一样,基于 JDK Unsafe API 实现[3]),Spark 可以直接操作系统堆外内存,减少了不必要的内存开销,以及频繁的 GC 扫描和回收,提升了处理性能

堆外内存可以被精确地申请和释放,而且序列化的数据占用的空间可以被精确计算,所以相比堆内内存来说降低了管理的难度,也降低了误差。

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

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

1.3 内存管理接口

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

//申请存储内存
def acquireStorageMemory(blockId: BlockId, numBytes: Long, memoryMode: MemoryMode): Boolean
//申请展开内存(1.6版本后没有)
def acquireUnrollMemory(blockId: BlockId, numBytes: Long, memoryMode: MemoryMode): Boolean
//申请执行内存
def acquireExecutionMemory(numBytes: Long, taskAttemptId: Long, memoryMode: MemoryMode): Long
//释放存储内存
def releaseStorageMemory(numBytes: Long, memoryMode: MemoryMode): Unit
//释放执行内存
def releaseExecutionMemory(numBytes: Long, taskAttemptId: Long, memoryMode: MemoryMode): Unit
//释放展开内存(1.6版本后没有)
def releaseUnrollMemory(numBytes: Long, memoryMode: MemoryMode): Unit

从定义的方法中可以看出:

  1. 在调用这些方法时都需要指定其内存模式(MemoryMode),这个参数决定了是在堆内还是堆外完成这次操作
  2. 除了 StorageMemory 和 ExecutionMemory,还有个 UnrollMemory 用于缓存Iterator形式的Block数据
  3. MemoryManager 有两个实现类:StaticMemoryManager、UnifiedMemoryManager。前者就是1.6版本之前的内存管理器,后者则实现了最新的内存管理机制

2. 内存空间分配

2.1 静态内存管理(Static Memory Management)

堆内内存:

在 Spark 最初采用的静态内存管理机制下,存储内存、执行内存和其他内存的大小在 Spark 应用程序运行期间均为固定的,但用户可以应用程序启动前进行配置,堆内内存的分配如图所示:

spark的内存管理机制 简述spark内存模型_JVM_02


可用的堆内内存的大小可按照下面的方式计算:

  • 可用的存储内存 = systemMaxMemory * spark.storage.memoryFraction * spark.storage.safetyFraction
  • 可用的执行内存 = systemMaxMemory * spark.shuffle.memoryFraction * spark.shuffle.safetyFraction

safetyFraction 参数意义:在逻辑上预留出 1-safetyFraction 这么一块保险区域,降低因实际内存超出当前预设范围而导致 OOM 的风险(上文提到,对于非序列化对象的内存采样估算会产生误差)

堆外内存:

堆外的空间分配较为简单,只有存储内存和执行内存

可用的执行内存和存储内存占用的空间大小直接由参数 spark.memory.storageFraction 决定,由于堆外内存占用的空间可以被精确计算,所以无需再设定保险区域

spark的内存管理机制 简述spark内存模型_JVM_03

Spark 1.6之前,静态内存管理对应的管理类为StaticMemoryManager,它不能根据不同的数据处理场景调整内存的比例,在内存使用和性能方面都存在局限性,官网原话如下:

  1. There are no sensible defaults that apply to all workloads
  2. Tuning memory fractions requires user expertise of internals
  3. Applications that do not cache use only a small fraction of available memory

2.2 统一内存管理(Unified Memory Management)

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

spark的内存管理机制 简述spark内存模型_内存管理_04

spark的内存管理机制 简述spark内存模型_spark的内存管理机制_05


其中最重要的优化在于动态占用机制,其规则如下:

  1. 设定基本的存储内存和执行内存区域(spark.storage.storageFraction 参数),该设定确定了双方各自拥有的空间的范围
    双方的空间都不足时,则存储到硬盘;若己方空间不足而对方空余时,可借用对方的空间;(存储空间不足是指不足以放下一个完整的 Block)
  2. 执行内存的空间被对方占用后,可让对方将占用的部分转存到硬盘,然后"归还"借用的空间
  3. 存储内存的空间被对方占用后,无法让对方"归还",因为需要考虑 Shuffle 过程中的很多因素,实现起来较为复杂

spark的内存管理机制 简述spark内存模型_spark_06

凭借统一内存管理机制,Spark 在一定程度上提高了堆内和堆外内存资源的利用率,降低了开发者维护 Spark 内存的难度,但并不意味着开发者可以高枕无忧

如果存储内存的空间太小或者说缓存的数据过多,反而会导致频繁的全量垃圾回收,降低任务执行时的性能,因为缓存的 RDD 数据通常都是长期驻留内存的

所以要想充分发挥 Spark 的性能,需要开发者进一步了解存储内存和执行内存各自的管理方式和实现原理

3. 内存性能优化的参数总结

1.6版本之后,新增的内存配置项如下:

  • spark.memory.fraction(默认值为0.75):“execution内存+storage内存” 占Executor总内存比例。若值越低,则发生spill和evict的频率就越高。注意,设置比例时要考虑Spark自身需要的内存量。
  • spark.memory.storageFraction(默认值为0.5):显然,这是存储内存所占spark.memory.fraction设置比例内存的大小。当整体的存储容量超过该比例对应的容量时,缓存的数据会被evict。
  • spark.memory.useLegacyMode(默认值为false):若设置为true,则使用1.6版本前的内存管理机制。此时,如下五项配置均生效:
  • spark.storage.memoryFraction
  • spark.storage.safetyFraction
  • spark.storage.unrollFraction
  • spark.shuffle.memoryFraction
  • spark.shuffle.safetyFraction

【spark1.6.0之前版本】

spark1.6.0之前版本,execution 和 storage 的内存分配是独立配置的,使用的参数配置分别是:

spark.storage.memoryFraction:storage内存占Executor总内存比例,default 0.6
spark.shuffle.memoryFraction:execution内存占Executor总内存比例,default 0.2

spark1.6.0之前版本,上述两块内存是互相隔离的,无法空闲借用。这就导致了Executor的内存利用率不高,而且需要根据Application的具体情况,使用者自己来调节这两个参数优化Spark的内存使用。

【spark1.6.0及之后版本】

spark1.6.0及之后版本,execution内存和storage内存支持合并配置,使用的参数配置分别是:

spark.memory.fraction:“execution内存+storage内存” 占Executor总内存比例,default 0.6
spark.memory.storageFraction:storage内存 默认 占Executor总内存比例,default 0.5