Spark
作为一个基于内存的分布式计算引擎,其内存管理模块在整个系统中扮演着非常重要的角色。理解
Spark
内存管理的基本原理,有助于更好地开发
Spark
应用程序和进行性能调优。
如果提交的时候内存分配过大则占用资源,内存分配过小就容易出现内存溢出和fullGC的问题,报如下异常:
java heap out of memory FetchFailedException
FileNotFoundException
Executor heartbeat timed out
executor lost
GC overhead limit exceeded
而spark在submit的时候都是设定连个内存分别如图所示:
Driver的内存管理相对来说较为简单,Spark不做具体规划。下面主要对Executor的内存管理进行分析
目录:
1
堆内堆外内存规划
2
内存空间分配
3
存储内存管理
4
执行内存管理
5
个人优化建议
一,堆内堆外内存规划:
Executor
的内存管理建立在
JVM
的内存管理之上,
Spark
对
JVM
的堆内(
On-heap
)空间进行了更为详细的分配,以充分利用内存。同时,
Spark
引入了堆外(
Off-heap
)内存,使之可以直接在工作节点的系统内存中开辟空间,进一步优化了内存的使用
堆内内存:受JVM管理
堆外内存:不受jvm管理
堆内内存:
•
由
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
的存储机制,或没有根据具体的数据规模和计算任务或做相应的配置,容易出现存储内存和执行内存中的一方剩余大量的空间,而另一方却早早被占满,不得不淘汰或移出旧的内容以存储新的内容,造成程序执行缓慢甚至失败
统一管理
•
Spark1.6
之后引入的统一内存管理机制,与静态内存管理的区别在于存储内存和执行内存共享同一块空间,可以动态占用对方的空闲区域
•
Spark1.6
之后引入的统一内存管理机制,与静态内存管理的区别在于存储内存和执行内存共享同一块空间,可以动态占用对方的空闲区域
动态占用机制
•
优点:在一定程度上提高了堆内和堆外内存资源的利用率,降低了开发者维护
Spark
内存的难度
存储内存管理
•
RDD
的持久化机制
:
•
如果一个
RDD
上要执行多次
Action
,可以在第一次
Action
中使用
persist
或
cache
方法,在内存或磁盘中持久化或缓存这个
RDD
,从而在后面的行动时提升计算速度。
•
堆内和堆外存储内存的设计,便可以对缓存
RDD
时使用的内存做统一的规划和管理
•
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
的存储空间
•
淘汰
•
由于同一个
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
)
排序。
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
完成后返回。这样便完成了最终的聚合操作。
多路归并
个人优化建议
•
目标:内存有限的情况下,减少
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
)