主要對 Executor 的內存管理進行分析,下文中的 Spark 內存均特指 Executor 的內存
堆內內存和堆外內存
作為一個 JVM 進程,Executor 的內存管理建立在 JVM 的內存管理之上,此外spark還引入了堆外內存(不在JVM中的內存),在spark中是指不屬于該executor的內存。
堆內內存:
由 JVM 控制,由GC(垃圾回收)進行內存回收堆外內存:
不受 JVM 控制,可以自由分配
堆外內存的優點: 減少了垃圾回收的工作。
堆外內存的缺點:
- 堆外內存難以控制,如果內存泄漏,那么很難排查
- 堆外內存相對來說,不適合存儲很復雜的對象。一般簡單的對象或者扁平化的比較適合。
堆內內存
堆內內存的大小,由 Spark 應用程序啟動時的 executor-memory 或 spark.executor.memory 參數配置,這些配置在 spark-env.sh 配置文件中。
Executor 內運行的并發任務共享 JVM 堆內內存,這些內存被規劃為 存儲(Storage)內存 和 執行(Execution)內存
Storage 內存:
用于存儲 RDD 的緩存數據 和 廣播(Broadcast)數據,主要用于存儲 spark 的 cache 數據,例如RDD的緩存Execution 內存:
執行 Shuffle 時占用的內存,主要用于存放 Shuffle、Join、Sort 等計算過程中的臨時數據用戶內存(User Memory):
主要用于存儲 RDD 轉換操作所需要的數據,例如 RDD 依賴等信息預留內存(Reserved Memory):
系統預留內存,會用來存儲Spark內部對象。
剩余的部分不做特殊規劃,那些 Spark 內部的對象實例,或者用戶定義的 Spark 應用程序中的對象實例,均占用剩余的空間。
Spark 對堆內內存的管理是一種邏輯上的”規劃式”的管理,因為對象實例占用內存的申請和釋放都由 JVM 完成,Spark 只能在申請后和釋放前記錄這些內存。
對于 Spark 中序列化的對象,由于是字節流的形式,其占用的內存大小可直接計算,而對于非序列化的對象,其占用的內存是通過周期性地采樣近似估算而得,這種方法降低了時間開銷但是有可能誤差較大,導致某一時刻的實際內存有可能遠遠超出預期。此外,在被 Spark 標記為釋放的對象實例,很有可能在實際上并沒有被 JVM 回收,導致實際可用的內存小于 Spark 記錄的可用內存。所以 Spark 并不能準確記錄實際可用的堆內內存,從而也就無法完全避免內存溢出(OOM, Out of Memory)的異常。
Spark 通過對存儲內存和執行內存各自獨立的規劃管理,可以決定是否要在存儲內存里緩存新的 RDD,以及是否為新的任務分配執行內存。
如果當前 Exector 內存不夠用,可以分配到其他內存占用小的 Exector 上。
在一定程度上可以提升其他 Exector 的內存利用率,減少當前 Exector 異常的出現。
堆外內存
為了進一步優化內存的使用以及提高 Shuffle 時排序的效率,Spark 1.6 引入了堆外(Off-heap)內存,使之可以直接在工作節點的系統內存中開辟空間,存儲經過序列化的二進制數據。
這種模式不在 JVM 內申請內存,而是調用 Java 的 unsafe 相關 API 進行諸如 C 語言里面的 malloc() 直接向操作系統申請內存,由于這種方式不進過 JVM 內存管理,所以可以避免頻繁的 GC,這種內存申請的缺點是必須自己編寫內存申請和釋放的邏輯。
Spark 可以直接操作系統堆外內存,減少了不必要的內存開銷,以及頻繁的 GC 掃描和回收,提升了處理性能。堆外內存可以被精確地申請和釋放,而且序列化的數據占用的空間可以被精確計算,所以相比堆內內存來說降低了管理的難度,也降低了誤差。
在默認情況下堆外內存并不啟用,可通過配置 spark.memory.offHeap.enabled 參數啟用,并由 spark.memory.offHeap.size 參數設定堆外空間的大小,單位為字節。堆外內存與堆內內存的劃分方式相同,所有運行中的并發任務共享存儲內存和執行內存。
如果堆外內存被啟用,那么 Executor 內將同時存在堆內和堆外內存,兩者的使用互補影響,這個時候 Executor 中的 Execution 內存是堆內的 Execution 內存和堆外的 Execution 內存之和,同理,Storage 內存也一樣。相比堆內內存,堆外內存只區分 Execution 內存和 Storage 內存。
spark內存分配
靜態內存管理
在 Spark 最初采用的靜態內存管理機制下,存儲內存、執行內存和其他內存的大小在 Spark 應用程序運行期間均為固定的,但用戶可以應用程序啟動前進行配置,堆內內存的分配如圖 所示:
可用堆內內存空間計算:
可用的存儲內存 = systemMaxMemory * spark.storage.memoryFraction * spark.storage.safetyFraction
可用的執行內存 = systemMaxMemory * spark.shuffle.memoryFraction * spark.shuffle.safetyFraction
靜態內存管理圖示——堆外
統一內存管理
Spark 1.6 之后引入的統一內存管理機制,與靜態內存管理的區別在于存儲內存和執行內存共享同一塊空間,可以動態占用對方的空閑區域,如圖 所示
統一內存管理圖示——堆內
reservedMemory 在 Spark 2.2.1 中是寫死的
統一內存管理圖示——堆外
其中最重要的優化在于動態占用機制,其規則如下:
程序提交的時候我們都會設定基本的 Execution 內存和 Storage 內存區域(通過 spark.memory.storageFraction 參數設置);
在程序運行時,如果雙方的空間都不足時,則存儲到硬盤;將內存中的塊存儲到磁盤的策略是按照 LRU 規則進行的。若己方空間不足而對方空余時,可借用對方的空間;(存儲空間不足是指不足以放下一個完整的 Block)
Execution 內存的空間被對方占用后,可讓對方將占用的部分轉存到硬盤,然后"歸還"借用的空間,Storage 占用 Execution 內存的數據被回收后,重新計算即可恢復。
Storage 內存的空間被對方占用后,目前的實現是無法讓對方"歸還",因為需要考慮 Shuffle 過程中的很多因素,實現起來較為復雜;而且 Shuffle 過程產生的文件在后面一定會被使用到。
動態占用機制圖示
Task 之間內存分布
為了更好地使用使用內存,Executor 內運行的 Task 之間共享著 Execution 內存。具體的,Spark 內部維護了一個 HashMap 用于記錄每個 Task 占用的內存。當 Task 需要在 Execution 內存區域申請 numBytes 內存,其先判斷 HashMap 里面是否維護著這個 Task 的內存使用情況,如果沒有,則將這個 Task 內存使用置為0,并且以 TaskId 為 key,內存使用為 value 加入到 HashMap 里面。之后為這個 Task 申請 numBytes 內存,如果 Execution 內存區域正好有大于 numBytes 的空閑內存,則在 HashMap 里面將當前 Task 使用的內存加上 numBytes,然后返回;如果當前 Execution 內存區域無法申請到每個 Task 最小可申請的內存,則當前 Task 被阻塞,直到有其他任務釋放了足夠的執行內存,該任務才可以被喚醒。每個 Task 可以使用 Execution 內存大小范圍為 1/2N ~ 1/N,其中 N 為當前 Executor 內正在運行的 Task 個數。一個 Task 能夠運行必須申請到最小內存為 (1/2N * Execution 內存);當 N = 1 的時候,Task 可以使用全部的 Execution 內存。
比如如果 Execution 內存大小為 10GB,當前 Executor 內正在運行的 Task 個數為5,則該 Task 可以申請的內存范圍為 10 / (2 * 5) ~ 10 / 5,也就是 1GB ~ 2GB的范圍。
示例
1. 只用了堆內內存
現在我們提交的 Spark 作業關于內存的配置如下:
--executor-memory 18g
由于沒有設置 spark.memory.fraction(Storage 和 Execution 共用內存 占可用內存的比例,默認為0.6) 和 spark.memory.storageFraction(Storage 內存占 Storage 和 Execution 共用內存 比例,默認0.5) 參數,我們可以看到 Spark UI 關于 Storage Memory 的顯示如下:
上圖很清楚地看到 Storage Memory 的可用內存是 10.1GB,這個數是咋來的呢?根據前面的規則,我們可以得出以下的計算:
systemMemory = spark.executor.memory
reservedMemory = 300MB
usableMemory = systemMemory - reservedMemory
StorageMemory= usableMemory * spark.memory.fraction * spark.memory.storageFraction
把數據代進去,得出結果為:5.312109375 GB。
和上面的 10.1GB 對不上。為什么呢?這是因為 Spark UI 上面顯示的 Storage Memory 可用內存其實等于 Execution 內存和 Storage 內存之和,也就是 usableMemory * spark.memory.fraction
我們設置了 --executor-memory 18g,但是 Spark 的 Executor 端通過 Runtime.getRuntime.maxMemory 拿到的內存其實沒這么大,只有 17179869184 字節,這個數據是怎么計算的?
Runtime.getRuntime.maxMemory 是程序能夠使用的最大內存,其值會比實際配置的執行器內存的值小。這是因為內存分配池的堆部分分為 Eden,Survivor 和 Tenured 三部分空間,而這里面一共包含了兩個 Survivor 區域,而這兩個 Survivor 區域在任何時候我們只能用到其中一個,所以我們可以使用下面的公式進行描述:
ExecutorMemory = Eden + 2 * Survivor + Tenured
Runtime.getRuntime.maxMemory = Eden + Survivor + Tenured
2. 用了堆內和堆外內存
現在如果我們啟用了堆外內存,情況咋樣呢?我們的內存相關配置如下:
spark.executor.memory 18g
spark.memory.offHeap.enabled true
spark.memory.offHeap.size 10737418240
從上面可以看出,堆外內存為 10GB,現在 Spark UI 上面顯示的 Storage Memory 可用內存為 20.9GB,如下:
總結
憑借統一內存管理機制,Spark 在一定程度上提高了堆內和堆外內存資源的利用率,降低了開發者維護 Spark 內存的難度,但并不意味著開發者可以高枕無憂。譬如,所以如果存儲內存的空間太大或者說緩存的數據過多,反而會導致頻繁的 GC 垃圾回收,降低任務執行時的性能。
使用建議
首先,建議使用新模式,所以接下來的配置建議都是基于新模式的。
spark.memory.fraction:如果 application spill 或踢除 block 發生的頻率過高(可通過日志觀察),可以適當調大該值,這樣 execution 和 storage 的總可用內存變大,能有效減少發生 spill 和踢除 block 的頻率
spark.memory.storageFraction:為 storage 占 storage、execution 內存總和的比例。雖然新方案中 storage 和 execution 之間可以發生內存借用,但總的來說,spark.memory.storageFraction 越大,運行過程中,storage 能用的內存就會越多。所以,如果你的 app 是更吃 storage 內存的,把這個值調大一點;如果是更吃 execution 內存的,把這個值調小一點
spark.memory.offHeap.enabled:堆外內存最大的好處就是可以避免 GC,如果你希望使用堆外內存,將該值置為 true 并設置堆外內存的大小,即設置
spark.memory.offHeap.size,這是必須的
另外,需要特別注意的是,堆外內存的大小不會算在 executor memory 中,也就是說加入你設置了 --executor memory 10G 和 -spark.memory.offHeap.size=10G,那總共可以使用 20G 內存,堆內和堆外分別 10G。