Spark内存管理浅析

一、内存管理要解决的问题

在执行Spark的应用程序时,Spark集群会启动Driver和Executor两种JVM进程,前者为主控进程,后者负责执行具体的计算任务。Driver和Eexcutor都是JVM进程,Spark内存管理也建立在JVM的内存管理之上。

Spark内存管理管什么?先以join为例看一看内存的消耗,如图:

内存消耗主要在三部分:用户代码数据计算消耗的内存、框架执行消耗的内存(Join、Sort、Aggregations)、RDD缓存及广播变量。

用户代码部分管不了,当用户引入了一个大的list或map可能会OOM,但框架执行和缓存可以做一些管理,以保证运行的稳定性和性能。我们带着以下两个问题看下spark是怎么处理的。

1、如何平衡任务执行内存和RDD缓存的空间占用?

2、如何让共享Executor内存的多任务高效的执行且大数据下避免OOM?

二、内存区域划分和管理

1、内存区域划分

内存按逻辑划分为4个区域,分别是 Reserved Memory、User Memory、Execution Memory 和 Storage Memory。其中,Reserved Memory 固定为 300MB,不受开发者控制,它是 Spark 预留的、用来存储各种 Spark 内部对象的内存区域;User Memory 用于存储开发者自定义的数据结构,例如 RDD 算子中引用的数组、列表、映射等等。Execution Memory 用来执行分布式任务。分布式任务的计算,主要包括数据的转换、过滤、映射、排序、聚合、归并等环节,而这些计算环节的内存消耗,统统来自于 Execution Memory。Storage Memory 用于缓存分布式数据集,比如 RDD Cache、广播变量等等。这种划分是从应用层来划分的,在JVM看来是没有区别的,是一些软限制。若用户代码运行时的实际内存消耗量可能超过用户代码空间的界限,侵占框架使用的空间,此时框架的空间还是按配置的大小确定的,如果框架也使用了大量内存空间,则可能造成内存溢出。

 大小配置项:

内存抢占规则:Execution Memory 和 Storage Memory 之间可以相互转化。转化规则,一共可以总结为 3 条:

  • 如果对方的内存空间有空闲,双方可以互相抢占;
  • 对于 Storage Memory 抢占的 Execution Memory 部分,当分布式任务有计算需要时,Storage Memory 必须立即归还抢占的内存,涉及的缓存数据要么落盘、要么清除;
  • 对于 Execution Memory 抢占的 Storage Memory 部分,即便 Storage Memory 有收回内存的需要,也必须要等到分布式任务执行完毕才能释放。

2、Task内存管理

Executor内运行的任务共享执行内存,为了保证Task合理地进行内存使用,执行内存池需要保证在N个运行Task的情况下,每个Task所能分配到的内存在总内存的 1/2N~1/N 之间,每个任务在第一次申请内存时,要向Executor的内存管理器请求申请最少为1/2N 的执行内存,如果不能被满足要求则该任务被阻塞,直到有其他任务释放了足够的执行内存,该任务才被唤醒,这样避免了多任务并发执行内存不够导致OOM。

task中内存的申请和释放发生在需要较多内存的一些计算场景,普通的计算如transfromer操作是不需要的。在运行期间,spark会评估当前数据结构占用的内存,当达到阈值时会申请内存进行扩容,若满足不了则溢出到磁盘。任务结束后释放内存,内存管理器及时更新内存池状态和任务数,唤醒阻塞的任务。

private[memory] def acquireMemory(): Long = lock.synchronized {
  while (true) {
    val numActiveTasks = memoryForTask.keys.size
    val curMem = memoryForTask(taskAttemptId)
    maybeGrowPool(numBytes - memoryFree)
    val maxPoolSize = computeMaxPoolSize()
    val maxMemoryPerTask = maxPoolSize / numActiveTasks
    val minMemoryPerTask = poolSize / (2 * numActiveTasks)
    val maxToGrant = math.min(numBytes, math.max(0, maxMemoryPerTask - curMem))
    val toGrant = math.min(maxToGrant, memoryFree)
    if (toGrant < numBytes && curMem + toGrant < minMemoryPerTask) {
      lock.wait()
    } else {
      memoryForTask(taskAttemptId) += toGrant
      return toGrant
    }
}

内部结构:

操作系统内存(OS Memory)是整个架构的基础,无论执行内存如何分配,都离不开系统内存的支持。Java虚拟机(JVM)的堆内存(Heap)提供了对Java对象的存储支持,其实质依然是从操作系统申请获得的内存。内存管理器(MemoryManager)提供了四种逻辑上的内存池,分别为堆外执行内存池(offHeapExecutionMemoryPool)、堆上执行内存池(onHeapExecutionMemoryPool)、堆外存储内存池(offHeapStorageMemoryPool)、堆上存储内存池(onHeapStorageMemoryPool)。内存管理器提供了在Tungsten的堆外内存上分配内存的UnsafeMemoryAllocator和在Tungsten的堆内存上分配内存的HeapMemoryAllocator。UnsafeMemoryAllocator通 过sun.misc.Unsafe的 各种API操 纵 操 作 系 统 内存,HeapMemoryAllocator则通过在JVM Heap上分配对象的方式操纵JVM Heap。由于每个节点只有一个MemoryManager,而每个任务尝试都会有一个TaskMemoryManager为其管理内存,所以多个TaskMemoryManager将分享MemoryManager管理的内存。每个TaskMemoryManager管理的任务内存又会有多个内存消费者(MemoryConsumer)进行消费。

①MemoryConsumer调用TaskMemoryManager提供的API,获取/释放执行内存。

②TaskMemoryManager提供的API实际都依赖于MemoryManager的具体实现。MemoryManager通过offHeapExecutionMemoryPool和onHeapExecutionMemoryPool,分别对堆外内存和堆内存进行逻辑操作。MemoryManager通过UnsafeMemoryAllocator和HeapMemoryAllocator,分别对堆外内存和堆内存进行物理操作。

③UnsafeMemoryAllocator通过sun.misc.Unsafe的各种API操作系统内存。

④HeapMemoryAllocator通过在JVM Heap上分配对象的方式操纵JVMHeap。

内存消费者组件:

Shuffle write中ExternalSorter使用案例:

def insertAll(records: Iterator[Product2[K, V]]): Unit = {
   while (records.hasNext) {
      addElementsRead()
      val kv = records.next()
      buffer.insert(getPartition(kv._1), kv._1, kv._2.asInstanceOf[C])
      estimatedSize = buffer.estimateSize()
      if (maybeSpill(buffer, estimatedSize)) {
        buffer = new PartitionedPairBuffer[K, C]
      }
  }
}

protected def maybeSpill(collection: C, currentMemory: Long): Boolean = {
    if (currentMemory >= myMemoryThreshold) {
      // Claim up to double our current memory from the shuffle memory pool
      val amountToRequest = 2 * currentMemory - myMemoryThreshold
      val granted = acquireMemory(amountToRequest)
      myMemoryThreshold += granted
      // If we were granted too little memory to grow further (either tryToAcquire returned 0,
      // or we already had more memory than myMemoryThreshold), spill the current collection
      shouldSpill = currentMemory >= myMemoryThreshold
    }
    // Actually spill
    if (shouldSpill) {
      spill(collection)
      releaseMemory()
    }
}

Tungsten是Spark为了提升JVM带来的性能限制而提出的一个优化计划,主要是考虑如下两个因素:

  • Java对象所占的内存空间大,不像C/C++等更加底层的语言
  • JVM的GC机制在回收大空间时开销大

Tungsten优化体现在三个方面:

  1. 内存管理与二进制处理(Memory Management and Binary Processing):不处理Java对象的数据,而是直接处理二进制的数据,并引入了类似OS中的Page,提升性能。(堆外内存)
  2. 缓存敏感计算(Cache-aware computation): 设计算法和数据结构以充分利用memory hierarchy
  3. 动态代码生成(Code generation): 使用code generation去充分利用最新的编译器和CPU的性能

Tungsten把内存按照page的方式进行管理。一个Page被封装成MemoryBlock结构,用来表示一段连续的内存。Page在堆内和堆外上都做了实现,如果是On-heap的话,则先找到对象,然后对象中通过offset来具体定位地址,而如果是Off-heap的话,则直接定位。

3、堆外内存

目前spark的实现针对堆内、堆外内存的管理是独立的,空间互不共享,也就是说task最开始用堆外,用着用着发现不够了,这个时候即使堆内还有空闲,task也没法用。

官方推荐用堆内,因为tungsten在堆内也用内存页管理内存,也用压缩的二进制数据结构,因此gc效率往往可以保障,对于开发者来说,堆外更多地是一种备选项。

问题回顾:

1、如何平衡执行内存和缓存的空间占用?

通过划分执行内存和存储内存空间并统一管理,来平衡这两类存储的共享和竞争。

2、如何让共享Executor内存的多任务高效的执行且大数据下避免OOM?

通过在task级别对内存资源进行细粒度管理,限制每个task的最大使用空间,同时保证task的最小使用空间。并监控shuffle中数据结构的内存大小,达到阈值时申请内存,足够则扩容,否则溢出磁盘。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值