3. 统一内存管理机制:Reserved Memory、User Memory、Spark Memory 的划分与计算
Spark 1.6 引入的统一内存管理,说白了就是把以前各自为政的堆内存整合到了一起。我刚开始接触 Spark 时,用的还是 1.5 的静态内存管理,那时候调参全靠猜,经常搞混 spark.storage.memoryFraction 和 spark.shuffle.memoryFraction。后来统一内存管理出来,我第一反应是:终于不用再纠结这两个比例了。
但别高兴太早。统一内存管理虽然简化了配置,但内部的分区逻辑反而更精细了。你想想看,一个 Executor 的 JVM 堆内存,到底被切成了几块?每块能放多少数据?
嗯,这就是我们今天要聊的核心。
3.1 内存区域的三大分区
在 Spark 1.6+ 中,一个 Executor 的 JVM 堆被划分为三个区域:Reserved Memory、User Memory 和 Spark Memory。其中 Spark Memory 又细分为 Storage Memory 和 Execution Memory。
核心公式:
可用堆内存 = Reserved Memory + User Memory + Spark Memory
Spark Memory = Storage Memory + Execution Memory
我画了一张图,帮你直观理解这三块内存的层级关系:
3.2 Reserved Memory:那 300MB 到底干了什么?
Reserved Memory 是 Spark 预留的硬性开销,固定 300MB。这部分内存不参与任何业务计算,纯粹用于 Spark 内部元数据存储和 JVM 自身开销。
我曾经遇到过一个坑:有个同事把 Executor 内存设成了 512MB,结果发现 Spark 作业死活跑不起来。我一看日志,报的是 Failed to allocate memory。原因很简单——512MB 减去 300MB 的 Reserved Memory,只剩 212MB 给 User Memory 和 Spark Memory 分,这点内存连个像样的 Shuffle 都跑不动。
避坑指南:
我曾经在给客户做 POC 时,对方坚持用 1GB 的 Executor 内存跑 200GB 的数据。我直接告诉他:减去 300MB 预留,你实际能用的只有 700MB 左右,再按默认比例分,Spark Memory 只有 420MB。这连一个中等规模的 Shuffle 都扛不住。
建议:Executor 内存至少 2GB 起步,否则 Reserved Memory 占比太高,不划算。
3.3 User Memory:被你忽略的「隐形内存」
User Memory 是留给用户自己用的。你在 map 或 foreach 里 new 出来的对象、你定义的 UDF 里产生的临时数据,都算 User Memory。
它的计算公式很简单:
User Memory = (可用堆内存 - Reserved Memory) × (1 - spark.memory.fraction)
默认情况下,spark.memory.fraction 是 0.6。也就是说,扣除 300MB 后,剩下内存的 40% 归 User Memory。
举个例子:
Executor 内存 = 8GB
可用堆内存 = 8GB - 300MB ≈ 7.7GB
Spark Memory = 7.7GB × 0.6 ≈ 4.62GB
User Memory = 7.7GB × 0.4 ≈ 3.08GB
你看,3GB 的 User Memory 其实不小。但很多人不知道它的存在,结果在 UDF 里疯狂 new 对象,最后 OOM 了还以为是 Spark 内存不够。
我的经验:
我个人习惯在写 UDF 时,估算一下每条记录产生的临时对象大小。如果一条记录会产生 1KB 的临时对象,一个分区有 10 万条记录,那一个 Task 就需要 100MB 的 User Memory。如果并行度是 100,那一个 Executor 就需要 10GB 的 User Memory——这还没算 Spark Memory 呢。
所以,别小看 User Memory。它往往是 OOM 的隐形杀手。
3.4 Spark Memory:Storage 和 Execution 的「共享池」
Spark Memory 是统一内存管理的核心。它由 Storage Memory 和 Execution Memory 组成,默认各占 50%。
计算公式:
Spark Memory = (可用堆内存 - Reserved Memory) × spark.memory.fraction
Storage Memory = Spark Memory × spark.memory.storageFraction
Execution Memory = Spark Memory × (1 - spark.memory.storageFraction)
默认 spark.memory.storageFraction 是 0.5,所以 Storage 和 Execution 各占一半。
但这里有个关键机制:它们可以互相借用。
| 场景 | Storage 借 Execution | Execution 借 Storage |
|---|---|---|
| 能否借用 | 可以 | 可以 |
| 是否需要归还 | 需要,但 Execution 可以强制回收 | 需要,但 Storage 不能强制回收 |
| 回收策略 | Execution 需要时,直接驱逐 Storage 的缓存块 | Storage 需要时,只能等 Execution 主动释放 |
| 典型影响 | 缓存的数据可能被频繁驱逐,导致重算 | Execution 内存不足时,会 spill 到磁盘 |
说白了,Execution 是「强势方」,Storage 是「弱势方」。Execution 需要内存时,可以直接把 Storage 借出去的内存抢回来——哪怕 Storage 还在用。这会导致 RDD 缓存被驱逐,下次用到时就得重算。
我遇到过这样一个案例:一个 ETL 作业,用了 persist(StorageLevel.MEMORY_ONLY) 缓存中间结果,但后面跟了一个大 Shuffle。结果 Shuffle 把缓存的内存全抢走了,缓存的数据被反复驱逐和重算,作业跑了 3 小时还没跑完。我改成 MEMORY_AND_DISK 后,缓存被驱逐时直接落盘,作业 40 分钟就跑完了。
关键结论:
- 如果你主要做 Shuffle、Join、Aggregation,可以适当调大
spark.memory.storageFraction(比如 0.3),给 Execution 留更多空间。 - 如果你主要做 RDD 缓存、广播变量,可以调大
spark.memory.storageFraction(比如 0.7),减少缓存被驱逐的概率。 - 但无论怎么调,Execution 永远可以强制回收 Storage 借出的空间。这是设计上的硬约束。
3.5 实战:如何计算你的 Executor 到底能放多少数据?
假设你有一个 8GB 的 Executor,spark.memory.fraction=0.6,spark.memory.storageFraction=0.5。我们来算一下:
- Reserved Memory:300MB(固定)
- 可用堆内存:8GB - 300MB ≈ 7.7GB
- Spark Memory:7.7GB × 0.6 ≈ 4.62GB
- User Memory:7.7GB × 0.4 ≈ 3.08GB
- Storage Memory(初始):4.62GB × 0.5 ≈ 2.31GB
- Execution Memory(初始):4.62GB × 0.5 ≈ 2.31GB
也就是说,你最多能缓存 2.31GB 的 RDD 数据(如果 Execution 不抢的话)。但一旦 Shuffle 开始,Execution 可能把 Storage 的内存抢走,缓存的数据就会被驱逐。
所以,我个人习惯在预估缓存大小时,只按 Spark Memory × spark.memory.storageFraction 来算,也就是 2.31GB。如果实际数据超过这个值,我会考虑用 MEMORY_AND_DISK 或者调大 spark.memory.storageFraction。
注意:
以上计算都是基于 JVM 堆内内存。如果你用了堆外内存(off-heap),那又是另一套规则了。我们会在后面的章节专门讲堆外内存。
3.6 小结
统一内存管理把堆内存分成了三块:Reserved Memory(300MB 固定)、User Memory(用户自定义对象)、Spark Memory(Storage + Execution)。Storage 和 Execution 可以互相借用,但 Execution 是强势方,可以强制回收。
记住一个原则:User Memory 是隐形的,Storage Memory 是脆弱的,Execution Memory 是霸道的。理解了这三者的关系,你调参时就不会再盲目了。