Spark 的内存管理机制是其性能优化的核心,理解它对于避免 OutOfMemoryError
(OOM)、减少垃圾回收(GC)开销、提升计算效率至关重要。Spark 从 1.6 版本开始引入了 统一内存管理(Unified Memory Management),取代了之前的静态内存划分,成为默认且主流的机制。
一、 为什么需要统一内存管理?
- 静态内存管理(Spark 1.6 之前)的痛点:
- 僵化划分: 执行内存(用于 Shuffle、Join、Sort、Aggregation 等计算)和存储内存(用于缓存 RDD/DataFrame/Dataset)的比例是固定的(通过
spark.shuffle.memoryFraction
和spark.storage.memoryFraction
设置)。 - 利用率低: 计算密集型任务可能存储内存闲置,而缓存密集型任务可能执行内存闲置,造成资源浪费。
- OOM 风险高: 一方内存不足时,无法借用另一方的空闲内存,更容易触发 OOM。
- 僵化划分: 执行内存(用于 Shuffle、Join、Sort、Aggregation 等计算)和存储内存(用于缓存 RDD/DataFrame/Dataset)的比例是固定的(通过
- 统一内存管理的优势:
- 动态共享: 执行内存和存储内存共享一个统一的弹性内存池(Unified Region)。
- 边界可突破: 当一方内存不足而另一方有闲置时,可以“借用”对方的内存。
- 资源利用率高: 更灵活地适应不同计算负载(计算密集 vs 缓存密集)。
- 减少 OOM: 动态借用机制降低了因内存分区僵化导致的 OOM 概率。
二、 统一内存管理(Unified Memory Management)详解
-
JVM 堆内存整体划分:
Spark Executor 的 JVM 堆内存主要分为四大区域:内存区域 配置参数 默认占比 主要用途 是否可被统一内存池借用/收回 Reserved Memory spark.testing.reservedMemory
(仅测试)300MB 保障 Spark 内部元数据、安全性等 ❌ 不可动 User Memory (1.0 - spark.memory.fraction) * (Heap - Reserved)
25% (默认 spark.memory.fraction=0.6
)用户代码、自定义数据结构 (如 UDF 中创建的大对象、RDD aggregate
的初始值等)❌ 不可动 (OOM 常见区!) Unified Memory (Spark Memory) spark.memory.fraction * (Heap - Reserved)
60% (默认) 执行内存 (Execution) + 存储内存 (Storage) 共享池 ✅ 核心动态区 Off-Heap Memory (Tungsten) spark.memory.offHeap.size
spark.memory.offHeap.enabled=true
0 (默认关闭) 执行和存储内存的堆外扩展 (二进制操作,避免 GC) 独立管理 - 计算公式 (堆内):
UsableHeap = ExecutorHeap - ReservedMemory
UnifiedRegionSize = spark.memory.fraction * UsableHeap
UserMemorySize = (1.0 - spark.memory.fraction) * UsableHeap
- 计算公式 (堆内):
-
统一内存池(Unified Region)内部结构:
- 存储内存 (Storage Memory): 用于缓存
persist()
/cache()
的 RDD、DataFrame/Dataset partitions,以及广播变量 (Broadcast
variables) 数据。 - 执行内存 (Execution Memory): 用于 Shuffle 过程中的排序 (Sort)、哈希聚合 (HashAggregation)、Join 操作的哈希表 (HashTable)、以及洗牌数据的中间缓冲区等。
- 共享机制与边界 (
spark.memory.storageFraction
):- 在统一内存池内,没有预先划分死的边界线。
- 存储内存和执行内存都可以增长,直到占满整个统一内存池。
- 存在一个软边界,由
spark.memory.storageFraction
(默认 0.5) 定义。这个值表示存储内存的“最低保障”份额。 - 动态借用规则:
- 存储借用执行: 当存储内存使用量 < 其最低保障 (
UnifiedRegionSize * spark.memory.storageFraction
) 时,它可以占用执行内存的空闲部分。但当执行需要更多内存时,它可以强制收回存储借用的那部分内存(除非存储的数据正在被使用,无法驱逐)。收回通过 LRU (Least Recently Used) 策略淘汰缓存的 RDD blocks 来实现。 - 执行借用存储: 当执行内存使用量 < 其可用量时,它可以占用存储内存的空闲部分(即超过其最低保障的部分)。但当存储需要更多内存(达到其最低保障线)时,它不能强制收回执行借用的内存。执行借用的内存只有在任务完成释放后,才会被存储内存使用。这意味着执行内存可以“抢占”存储内存的空闲部分,且一旦占用,在任务结束前存储无法收回。
- 存储借用执行: 当存储内存使用量 < 其最低保障 (
图示统一内存池动态关系:
|----------------------- Unified Memory Region (60% Heap) ------------------------| | | | [Storage Minimum Reserve] [Free/Shared Area] | | (spark.memory.storageFraction * | | | UnifiedRegionSize) <---------------->| | | | | |<-------------------------------------|--------------------------------------->| | Storage Can Borrow From Here | Execution Can Borrow From Here | | | (And once borrowed, Storage CANNOT | | | force evict until Exec releases) | | | |--------------------------------------------------------------------------------|
- 关键点:
- 执行内存有更高的优先级,因为它通常持有任务执行中不可中断的关键数据结构(如 Shuffle 的排序缓冲区)。如果执行内存不足,任务可能失败。
- 存储内存的数据可以按 LRU 策略驱逐(Evict) 到磁盘(如果设置了
StorageLevel
包含MEMORY_AND_DISK
)或直接丢弃(如果只包含MEMORY_ONLY
)。驱逐是释放内存的主要手段。 - OOM 风险点:
- 执行内存不足: 当执行任务需要的内存超过了它能从统一池中借到的最大量(整个池大小)时,任务失败(OOM)。常见于数据倾斜导致单个 Task 需要处理海量数据做 Shuffle。
- 存储内存不足且无法驱逐: 当要缓存的数据量巨大,且统一池中存储部分 + 可借用的执行空闲部分都不够用,并且无法再驱逐更多旧的 blocks 时(例如所有 blocks 都在使用),缓存失败(部分 partitions 可能不缓存或 OOM)。
- User Memory 不足: 用户代码创建了过大的对象或数据结构,超出了 User Memory 区域,直接导致 OOM。最容易忽视的区域!
- 存储内存 (Storage Memory): 用于缓存
-
堆外内存 (Off-Heap Memory - Tungsten):
- 目的: 彻底规避 JVM 垃圾回收(GC)开销;利用操作系统原生内存管理;支持更紧凑的二进制数据格式。
- 启用:
spark.memory.offHeap.enabled=true
+spark.memory.offHeap.size=
- 管理: Tungsten 引擎自己管理这块堆外内存,不经过 JVM GC。
- 用途:
- 存储内存: 缓存二进制格式的 RDD/DataFrame partitions。
- 执行内存: Shuffle 排序、聚合、Join 操作的中间数据结构(如指针数组、排序缓冲区)。
- 优势:
- 大幅减少 GC 暂停: 对海量小对象操作(如 Shuffle)尤其有效。
- 内存使用更高效: 二进制格式比 Java 对象更紧凑,节省空间;避免对象头开销。
- 可突破 JVM Heap 大小限制: 允许使用更大的内存。
- 注意:
- 需要显式配置大小。
- 溢出(Spill)到磁盘的机制依然存在。
- OOM 表现为操作系统级别的内存分配失败(如
java.lang.OutOfMemoryError: Unable to acquire X bytes of memory
),而非 JVM Heap OOM。
三、 关键配置参数详解与调优建议
-
核心比例参数 (堆内):
spark.memory.fraction
(默认 0.6):- 作用: 统一内存池占
(Heap - 300MB)
的比例。 - 调优:
- 增大 (如 0.7, 0.75): 如果 User Memory 使用很少(用户代码不创建大对象),且计算/缓存需求大。增加统一池大小,减少 User OOM 风险(相对)。
- 减小 (如 0.5): 如果 User Memory 压力很大(用户代码有大量自定义对象),或者需要更多空间给非 Spark 管理的堆内数据结构。慎减,可能增加执行/存储 OOM 风险。
- 作用: 统一内存池占
spark.memory.storageFraction
(默认 0.5):- 作用: 在统一内存池中,存储内存的“最低保障”份额(比例)。
- 调优:
- 增大 (如 0.6): 缓存密集型应用。 确保有更多内存用于缓存,减少被执行抢占的风险,提高缓存命中率。但执行内存最大可用量相应减少。
- 减小 (如 0.4, 0.3): 计算密集型应用(特别是 Shuffle 重)。 确保执行内存有更大的潜在可用空间,减少执行 OOM 风险。但缓存空间可能被压缩得更快。
- 观察点: 查看 Spark UI Executors 页的
Storage Memory
使用量和Disk Bytes Spilled
。如果缓存频繁被驱逐且磁盘溢出多,考虑增大;如果执行任务频繁因内存不足失败或溢出严重,考虑减小。
-
堆外内存参数:
spark.memory.offHeap.enabled
(默认 false): 设为true
启用堆外内存。强烈建议在 Shuffle 重或 GC 成为瓶颈时开启!spark.memory.offHeap.size
(默认 0): 指定堆外内存大小 (e.g.,2g
,4g
)。必须大于 0 且在物理内存允许范围内。 通常设置为 JVM Heap 大小的一个比例 (如 0.5倍),总量不超过物理内存。
-
Executor 资源参数 (基础):
spark.executor.memory
/--executor-memory
: 最基础参数! 设置单个 Executor 的 JVM Heap 总大小 (e.g.,4g
,8g
,16g
)。调优起点通常是增大这个值。spark.executor.memoryOverhead
(默认 max(384MB, 0.1 *spark.executor.memory
)):- 作用: 为堆外内存 (非 Tungsten Off-Heap!)、线程栈、NIO Buffer、JVM 自身等预留的额外堆外内存。由 YARN/K8s 管理。
- 调优: 如果遇到
Overhead
相关的 OOM (如Container killed by YARN for exceeding memory limits
),显著增大此值 (e.g.,1g
,2g
,甚至spark.executor.memory
的 0.2-0.4 倍)。尤其是在使用堆外内存 (spark.memory.offHeap.size
) 或大量 NIO 操作时。
spark.executor.cores
/--executor-cores
: 单个 Executor 的 CPU 核心数。影响并行度和内存压力平衡。更多 cores 通常需要更大的spark.executor.memory
。典型值 4-8。
-
其他相关参数:
spark.sql.autoBroadcastJoinThreshold
(默认 10MB): 控制 Spark SQL 自动广播小表的阈值。增大可让更多 Join 变为广播 Join (BroadcastHashJoin),避免 Shuffle,极大减少执行内存压力! 根据 Driver/Executor 内存调整 (e.g.,50m
,100m
)。spark.sql.shuffle.partitions
(默认 200): Shuffle 后分区数。增加分区数可减少每个 Task 处理的数据量,从而降低单个 Task 的执行内存需求,缓解 OOM 和 GC。配合 AQE 使用更佳。spark.serializer
(默认 JavaSerializer): 务必设为org.apache.spark.serializer.KryoSerializer
! Kryo 序列化显著减少 Shuffle 数据量和缓存占用的大小,间接降低内存压力。需注册自定义类 (spark.kryo.classesToRegister
/spark.kryo.registrator
)。spark.storage.level
(默认MEMORY_ONLY
):缓存级别。如果内存不足,使用MEMORY_ONLY_SER
(序列化,省空间但耗CPU) 或MEMORY_AND_DISK
/MEMORY_AND_DISK_SER
(允许溢出到磁盘)。权衡内存、CPU、速度。
四、 性能优化策略与最佳实践
-
监控先行:
- Spark Web UI (核心工具):
- Executors Tab: 观察每个 Executor 的
Storage Memory
,Disk Used
,Task Time (GC Time)
,Shuffle
读写量。重点关注Max
与Median
的差异 (倾斜)。 - Storage Tab: 查看缓存的 RDDs/DataFrames 大小、分区数、存储级别、内存占比。
- Stages Tab / Stage Detail: 查看 Task 的
Shuffle Spill (Memory)
,Shuffle Spill (Disk)
,GC Time
,Duration
。大量溢出到磁盘 (Disk Spill
) 是内存不足的强烈信号!长 GC Time 也是问题。
- Executors Tab: 观察每个 Executor 的
- GC 日志分析: 启用 JVM GC 日志 (
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -XX:+PrintTenuringDistribution -Xloggc:
) 。分析 Full GC 频率和时长。频繁或长时间的 Full GC 是堆内存压力过大的标志。
- Spark Web UI (核心工具):
-
诊断 OOM 类型:
java.lang.OutOfMemoryError: Java heap space
: 堆内 OOM。 需要区分:unified
/execution
区不足: 通常伴随大量Disk Spill
或 Shuffle 失败。调优spark.memory.fraction
,spark.memory.storageFraction
, 增大spark.executor.memory
, 增加分区数, 解决数据倾斜。user
区不足: 用户代码创建了过大对象。优化 UDF/RDD 操作中的数据结构,减少对象创建/持有;适当增大spark.executor.memory
(间接增大 User 区),或在允许的情况下减小spark.memory.fraction
(增大 User 区占比)。
java.lang.OutOfMemoryError: Unable to acquire X bytes of memory
: 堆外内存 (通常是 Tungsten Off-Heap) 分配失败。 增大spark.memory.offHeap.size
或spark.executor.memoryOverhead
(如果是非 Tungsten 的 Overhead 不足)。Container killed by YARN for exceeding memory limits
: 物理内存 (Heap + MemoryOverhead) 超限。 增大spark.executor.memoryOverhead
(最常见),或减小spark.executor.memory
(如果 Heap 确实用不满)。
-
通用优化策略:
- 增加 Executor 资源: 增大
spark.executor.memory
是最直接缓解内存压力的方法。平衡cores
和memory
(避免 CPU 空闲等内存或反之)。 - 启用堆外内存:
spark.memory.offHeap.enabled=true
+ 合理设置spark.memory.offHeap.size
。强烈推荐用于 Shuffle 重负载。 - 优化数据结构和序列化: 使用
KryoSerializer
;在 UDF/RDD 操作中避免创建大对象或嵌套结构;优先使用原生 Scala/Spark SQL 操作而非自定义复杂逻辑。 - 减少数据驻留:
- 及时释放缓存: 使用
unpersist()
显式释放不再需要的 RDD/DataFrame 缓存。 - 选择合适的存储级别: 不要滥用
MEMORY_ONLY
。对大数据集或不频繁重用的数据,使用带序列化或允许落盘的级别 (MEMORY_ONLY_SER
,MEMORY_AND_DISK_SER
,DISK_ONLY
)。
- 及时释放缓存: 使用
- 优化 Shuffle:
- 增大分区数 (
spark.sql.shuffle.partitions
): 分散数据,减少单个 Task 内存压力。 - 使用广播 Join: 增大
spark.sql.autoBroadcastJoinThreshold
, 避免大表 Shuffle。 - 解决数据倾斜: 倾斜是执行内存 OOM 的最大元凶!应用加盐、两阶段聚合等技巧。
- 参考之前讨论的 Shuffle 参数调优 (如
file.buffer
,maxSizeInFlight
) 。
- 增大分区数 (
- 利用 AQE (Adaptive Query Execution - Spark 3.0+): 开启
spark.sql.adaptive.enabled=true
。AQE 能动态合并小分区、处理倾斜 Join、优化 Join 策略,自动减轻内存压力。 - 合理配置内存比例: 根据应用类型 (计算密集 vs 缓存密集) 调整
spark.memory.storageFraction
。监控 UI 指导决策。
- 增加 Executor 资源: 增大
总结:
Spark 统一内存管理通过动态共享执行和存储内存池,显著提高了资源利用率并降低了 OOM 风险。性能优化的核心在于:
- 深入理解堆内 (Reserved/User/Unified) 和堆外 (Tungsten/MemoryOverhead) 内存的划分、用途及动态规则 (特别是执行与存储的借用/收回)。
- 精准监控:利用 Spark UI 和 GC 日志识别瓶颈 (
Disk Spill
, GC 时间长, OOM 类型)。 - 参数调优:重点配置
spark.executor.memory
,spark.memory.fraction
,spark.memory.storageFraction
,spark.memory.offHeap.size
,spark.executor.memoryOverhead
,并结合spark.sql.shuffle.partitions
,spark.sql.autoBroadcastJoinThreshold
,spark.serializer
。 - 应用优化:解决数据倾斜、减少不必要缓存、优化数据结构/序列化、利用广播 Join/AQE。
通过系统性地理解和调优内存管理,可以显著提升 Spark 应用的稳定性、吞吐量和执行效率。