spark内存管理

1 简介

spark作为内存计算框架,肯定会对内存进行管理,Spark通过使用 MemoryManage 对存储体系和计算使用的内存进行管理.本文将对spark的内存管理进行浅显的分析

2 堆内和堆外内存

Spark 将内存从逻辑上区分为堆内内存和堆外内存, 称为内存模型(MemoryMode).

spark on yarn 整合alluxio spark.yarn.am.memory_spark


在源码中,枚举类型MemoryMode中定义了堆内存和堆外内存

package org.apache.spark.memory;

import org.apache.spark.annotation.Private;

@Private
public enum MemoryMode {
  ON_HEAP, //堆内
  OFF_HEAP // 堆外
}

2.1 内存池 MemoryPool

import javax.annotation.concurrent.GuardedBy

/**
 * Manages bookkeeping for an adjustable-sized region of memory. This class is internal to
 * the [[MemoryManager]]. See subclasses for more details.
 *
 * @param lock a [[MemoryManager]] instance, used for synchronization. We purposely erase the type
 *             to `Object` to avoid programming errors, since this object should only be used for
 *             synchronization purposes.
 */
private[memory] abstract class MemoryPool(lock: Object) {

  @GuardedBy("lock")
  private[this] var _poolSize: Long = 0

  /**
   * Returns the current size of the pool, in bytes.
   * 返回当前内存池的大小
   */
  final def poolSize: Long = lock.synchronized {
    _poolSize
  }

  /**
   * Returns the amount of free memory in the pool, in bytes.
   * 返回空闲内存
   */
  final def memoryFree: Long = lock.synchronized {
    _poolSize - memoryUsed
  }

  /**
   * Expands the pool by `delta` bytes.
   * 根据delta,堆内存进行拓展,如果deta>=0 则进行拓展
   */
  final def incrementPoolSize(delta: Long): Unit = lock.synchronized {
    require(delta >= 0)
    _poolSize += delta
  }

  /**
   * Shrinks the pool by `delta` bytes.
   * 根据delta进行收缩内存
   */
  final def decrementPoolSize(delta: Long): Unit = lock.synchronized {
    require(delta >= 0)
    require(delta <= _poolSize)
    require(_poolSize - delta >= memoryUsed)
    _poolSize -= delta
  }

  /**
   * Returns the amount of used memory in this pool (in bytes).
   * 可用内存
   */
  def memoryUsed: Long
}

根据源码可知

MemoryPool有两个实现类

spark on yarn 整合alluxio spark.yarn.am.memory_spark_02

  • StorageMemoryPool
    存储内存池
  • ExecutionMemoryPool
    执行内存池

2.2 堆内内存

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

作用
Executor 内运行的并发任务共享 JVM 堆内内存, 这些任务在缓存 RDD 数据和广播数据时占用的内存被规划为存储内存
而这些任务在执行 Shuffle 时占用的内存被规划为执行内存.
剩余的部分不做特殊规划, 那些 Spark 内部的对象实例, 或者用户定义的 Spark 应用程序中的对象实例, 均占用剩余的空间.

存在问题
首先,我们通过spark代码new一个对象,JVM在对内内存中分配内存,创建对象并进行引用,spark保存该对象的引用,并行使用,当spark使用完成后,要对该对象进行删除,内存进行释放,等待JVM垃圾回收该对象占用的堆内内存
此时会出现以下问题

  • 1 jvm的对象都是通过序列化进行存储,将其转化为二进制字节流,访问时则需要反序列化,虽然节省了空间,但是增加了存储和读取时候的计算开销.
  • 2 对于 Spark 中序列化的对象,由于是字节流的形式,其占用的内存大小可直接计算,而对于非序列化的对象,其占用的内存是通过周期性地采样近似估算而得,即并不是每次新增的数据项都会计算一次占用的内存大小,这种方法降低了时间开销但是有可能误差较大,导致某一时刻的实际内存有可能远远超出预期。导致了明明内存够,但是显示内存不足
  • 3 在被 Spark 标记为释放的对象实例,很有可能在实际上并没有被 JVM 回收,导致实际可用的内存小于 Spark 记录的可用内存。所以 Spark 并不能准确记录实际可用的堆内内存,从而也就无法完全避免内存溢出(OOM, Out of Memory)的异常。

以此引出堆外内存

2.2 堆外内存

进一步优化内存的使用以及提高 Shuffle 时排序的效率,Spark 引入了堆外(Off-heap)内存,使之可以直接在工作节点的系统内存中开辟空间,存储经过序列化的二进制数据。
堆外内存意味着把内存对象分配在 Java 虚拟机的堆以外的内存,这些内存直接受操作系统管理(而不是虚拟机)。这样做的结果就是能保持一个较小的堆,以减少垃圾收集对应用的影响。利用 JDK Unsafe API,Spark 可以直接操作系统堆外内存,减少了不必要的内存开销,以及频繁的 GC 扫描和回收,提升了处理性能。堆外内存可以被精确地申请和释放(堆外内存之所以能够被精确的申请和释放,是由于内存的申请和释放不再通过JVM机制,而是直接向操作系统申请,JVM对于内存的清理是无法准确指定时间点的,因此无法实现精确的释放),而且序列化的数据占用的空间可以被精确计算,所以相比堆内内存来说降低了管理的难度,也降低了误差。

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

3 内存空间分配

3.1 静态内存管理(1.6之前)

Spark1.6之前采用的静态内存管理机制下,存储内存、执行内存和其他内存的大小在 Spark 应用程序运行期间均为固定的,但用户可以在应用程序启动前进行配置.

堆内内存管理

spark on yarn 整合alluxio spark.yarn.am.memory_大数据_03

  • Storage 内存(Storage Memory): 主要用于存储 Spark 的 cache 数据,例如 RDD 的缓存、Broadcast 变量,Unroll 数据等。
  • Execution 内存(Execution Memory):主要用于存放 Shuffle、Join、Sort、Aggregation 等计算过程中的临时数据。
  • other(有时候也叫用户内存):主要用于存储 RDD 转换操作所需要的数据,例如 RDD 依赖等信息。
  • 预留内存(Reserved Memory):系统预留内存,会用来存储Spark内部对象。
    预留内存(Reserved Memory): 防止 OOM
可用的存储内存 = systemMaxMemory * spark.storage.memoryFraction * spark.storage.safety Fraction
可用的执行内存 = systemMaxMemory * spark.shuffle.memoryFraction * spark.shuffle.safety Fraction

其中 systemMaxMemory 取决于当前 JVM 堆内内存的大小,最后可用的执行内存或者存储内存要在此基础上与各自的 memoryFraction 参数和 safetyFraction 参数相乘得出。
上述计算公式中的两个 safetyFraction 参数,其意义在于在逻辑上预留出 1-safetyFraction 这么一块保险区域,降低因实际内存超出当前预设范围而导致 OOM 的风险(上文提到,对于非序列化对象的内存采样估算会产生误差)。
值得注意的是,这个预留的保险区域仅仅是一种逻辑上的规划,在具体使用时 Spark 并没有区别对待,和”其它内存”一样交给了 JVM 去管理。
Storage内存和Execution内存都有预留空间,目的是防止OOM,因为Spark堆内内存大小的记录是不准确的,需要留出保险区域。

堆外内存管理

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

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

不需要做预留,spark可以精确计算

spark on yarn 整合alluxio spark.yarn.am.memory_垃圾回收_04


静态管理内存容易造成,极端现象,比如存储内存严重不足,但是执行内存非常充足,即存储内存和执行内存中的一方剩余大量的空间,而另一方却早早被占满,不得不淘汰或移出旧的内容以存储新的内容

故,引出统一内存管理

3.2 统一内存管理(1.6以后)

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

统一堆内内存管理

存储与执行内存不需要预留,可以借用

spark on yarn 整合alluxio spark.yarn.am.memory_spark_05

统一堆外内存管理

spark on yarn 整合alluxio spark.yarn.am.memory_垃圾回收_06


execution的内存使用级别是最高,优先execution

统一内存管理最重要的优化在于动态占用机制, 其规则如下:

  • 1 设定基本的存储内存和执行内存区域spark.storage.storageFraction, 该设定确定了双方各自拥有的空间的范围
  • 2 双方的空间都不足时, 则存储到硬盘. 若己方空间不足而对方空余时, 可借用对方的空间.
  • 3 执行内存的空间被对方占用后, 可让对方讲占用的部分转存到硬盘, 然后“归还”借用的空间
  • 4 存储内存的空间被对方占用后, 无法让对方“归还”, 因为需要考虑 Shuffle 过程中的诸多因素, 实现起来比较复杂.

spark on yarn 整合alluxio spark.yarn.am.memory_大数据_07

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

4 spark 垃圾回收

上文提到,spark内存空间如果缓存过多,则会引起频繁的垃圾回收导致性能降低,故优化垃圾回收

4.1 优化executor内存比例:

对于垃圾回收来说,最重要的就是调节RDD缓存中占用的内存空间,与算子执行时创建的对象占用的内存空间的比例,默认情况下,spark使用每个executor
60%的内存空间来缓存RDD。那么在task执行期间创建的对象只有40%的空间来存存放。
在这种情况下,很有可能因为你的内存空间不足,task创建的对象过大,那么一旦发现40%的内存空间不够用了,就会触发java虚拟机的垃圾回收操作。因此在极端的情况下垃圾回收可能会频繁的触发。
在上述情况下 ,如果发现垃圾回收频繁的发生没那么就需要对这个比例进行优化。使用
conf.set(“spark.storage.memoryFunction”,“0.5”)即可,1
可以将RDD缓存占用空间的比例降低从而给更多的task常见的对象进行使用
因此对于RDD的持久化完全可以使用kyro序列化,加上降低其executor内存占比的方式,来减少其内存消耗,给task提供更多的内存,从而避免task的执行频繁的垃圾回收。

4.2 垃圾回收调优

1

java堆空间被划分成了两块空间,一个是年轻代,一个是老年代。年轻代放的是短时间的存活的对象,老年代放的是长时间的存活对象。年轻代又被划分成了三块空间,Eden,Survivor1,Survivor2.
首先Eden区域和Survivor1区域用于存放对象,Survivor2区域备用。创建的对象,首先放入Eden区域和Survivor1区域,如果Eden区域满了,那么就会触发一次Minor GC,进行年轻代的垃圾回收。Eden和Survivor1区域中存活的对象,会被移动到Survivor2区域中。然后Survivor1和Survivor2的角色调换。Survivor1变成了备用。
如果一个对象,在年轻代,撑过了多次垃圾回收,都没有被回收掉,那么会被认为是长时间存活的,此时就会被移入老年代。此外,如果在将Eden和Survivor1中存活对象,尝试放入Survivor2中时,发现Survivor2放满了,那么会直接放入老年代。此时就出现了,超时间存活的对象,进入老年代的问题。
如果老年代空间满了没那么就会触发full GC进行老年的垃圾回收操作。

2

Spark中垃圾回收调优的目标就是,只有真正长时间存活的对象,才能进入老年代,短时间存活的对象,对只能呆在年轻代。不能因为某个Survivor区域空间不够,在Mintor GC时,就进入了老年代。从而造成了短时间存活的对象,长期呆在老年代中占据了空间,而且full GC时要回收大量的短时间存活的对象,导致full GC速度缓慢。
如果发现,在task执行期间,大量full gc 发生了 ,那么说明,年轻代的Survivor区域,给的空间不够大,此时可以执行一些操作来优化垃圾回收行为:
1.包括降低spark.storage.memoryFraction的比例,给年轻代更多的空间,来存放短时间存活的对象;
2.给Eden 区域分配更大的空间,使用-Xmm即可 ,通常建议给Eden 区域,预计大小的4/3;
3.如果使用的是HDFS文件,那么很好估计Eden区域大小,如果executor有4个task.然后每个hdfs压缩块 解压缩后大小是3倍,此外每个hdfs块的大小是64m,那么Eden区域的预计大小就是:4364MB.