解讀 Java 雲原生實踐中的內存問題

Java 憑藉着自身活躍的開源社區和完善的生態優勢,在過去的二十幾年一直是最受歡迎的編程語言之一。步入雲原生時代,蓬勃發展的雲原生技術釋放雲計算紅利,推動業務進行雲原生化改造,加速企業數字化轉型。

然而 Java 的雲原生轉型之路面臨着巨大的挑戰,Java 的運行機制和雲原生特性存在着諸多矛盾。企業藉助雲原生技術進行深層次成本優化,資源成本管理被上升到前所未有的高度。公有云上資源按量收費,用戶對資源用量十分敏感。在內存使用方面,基於 Java 虛擬機的執行機制使得任何 Java 程序都會有固定的基礎內存開銷,相比 C++/Golang 等原生語言,Java 應用佔用的內存巨大,被稱爲“內存吞噬者”,因此 Java 應用上雲更加昂貴。並且應用集成到雲上之後系統複雜度增加,普通用戶對雲上 Java 應用內存沒有清晰的認識,不知道如何爲應用合理配置內存,出現 OOM 問題時也很難排障,遇到了許多問題。

爲什麼堆內存未超過 Xmx 卻發生了 OOM?怎麼理解操作系統和JVM的內存關係?爲什麼程序佔用的內存比 Xmx 大不少,內存都用在哪兒了?爲什麼線上容器內的程序內存需求更大?本文將 EDAS 用戶在 Java 應用雲原生化演進實踐中遇到的這些問題進行了抽絲剝繭的分析,並給出雲原生 Java 應用內存的配置建議。

背景知識

K8s 應用的資源配置

雲原生架構以 K8s 爲基石,應用在 K8s 上部署,以容器組的形態運行。K8s 的資源模型有兩個定義,資源請求(request)和資源限制(limit),K8s 保障容器擁有 request數量的資源,但不允許使用超過limit數量的資源。以如下的內存配置爲例,容器至少能獲得 1024Mi 的內存資源,但不允許超過 4096Mi,一旦內存使用超限,該容器將發生OOM,而後被 K8s 控制器重啓。

 spec:
  containers:
  - name: edas
    image: alibaba/edas
    resources:
      requests:
        memory: "1024Mi"
      limits:
        memory: "4096Mi"
    command: ["java", "-jar", "edas.jar"]

容器 OOM

對於容器的 OOM 機制,首先需要來複習一下容器的概念。當我們談到容器的時候,會說這是一種沙盒技術,容器作爲一個沙盒,內部是相對獨立的,並且是有邊界有大小的。容器內獨立的運行環境通過 Linux的Namespace 機制實現,對容器內 PID、Mount、UTS、IPD、Network 等 Namespace 進行了障眼法處理,使得容器內看不到宿主機 Namespace 也看不到其他容器的 Namespace;而所謂容器的邊界和大小,是指要對容器使用 CPU、內存、IO 等資源進行約束,不然單個容器佔用資源過多可能導致其他容器運行緩慢或者異常。Cgroup 是 Linux 內核提供的一種可以限制單個進程或者多個進程所使用資源的機制,也是實現容器資源約束的核心技術。容器在操作系統看來只不過是一種特殊進程,該進程對資源的使用受 Cgroup 的約束。當進程使用的內存量超過 Cgroup 的限制量,就會被系統 OOM Killer 無情地殺死。

所以,所謂的容器 OOM,實質是運行在Linux系統上的容器進程發生了 OOM。Cgroup 並不是一種晦澀難懂的技術,Linux 將其實現爲了文件系統,這很符合 Unix 一切皆文件的哲學。對於 Cgroup V1 版本,我們可以直接在容器內的 /sys/fs/cgroup/ 目錄下查看當前容器的 Cgroup 配置。

對於容器內存來說,memory.limit_in_bytes 和 memory.usage_in_bytes 是內存控制組中最重要的兩個參數,前者標識了當前容器進程組可使用內存的最大值,後者是當前容器進程組實際使用的內存總和。一般來說,使用值和最大值越接近,OOM 的風險越高。

 # 當前容器內存限制量
$ cat /sys/fs/cgroup/memory/memory.limit_in_bytes
4294967296
# 當前容器內存實際用量
$ cat /sys/fs/cgroup/memory/memory.usage_in_bytes
39215104

JVM OOM

說到 OOM,Java 開發者更熟悉的是 JVM OOM,當 JVM 因爲沒有足夠的內存來爲對象分配空間並且垃圾回收器也已經沒有空間可回收時,將會拋出 java.lang.OutOfMemoryError。按照 JVM 規範,除了程序計數器不會拋出 OOM 外,其他各個內存區域都可能會拋出 OOM。最常見的 JVM OOM 情況有幾種:

  • java.lang.OutOfMemoryError:Java heap space 堆內存溢出。當堆內存 (Heap Space) 沒有足夠空間存放新創建的對象時,就會拋出該錯誤。一般由於內存泄露或者堆的大小設置不當引起。對於內存泄露,需要通過內存監控軟件查找程序中的泄露代碼,而堆大小可以通過-Xms,-Xmx等參數修改。
  • java.lang.OutOfMemoryError:PermGen space / Metaspace 永久代/元空間溢出。永久代存儲對象包括class信息和常量,JDK 1.8 使用 Metaspace 替換了永久代(Permanent Generation)。通常因爲加載的 class 數目太多或體積太大,導致拋出該錯誤。可以通過修改 -XX:MaxPermSize 或者 -XX:MaxMetaspaceSize 啓動參數, 調大永久代/元空間大小。
  • java.lang.OutOfMemoryError:Unable to create new native thread 無法創建新線程。每個 Java 線程都需要佔用一定的內存空間, 當 JVM 向底層操作系統請求創建一個新的 native 線程時, 如果沒有足夠的資源分配就會報此類錯誤。可能原因是 native 內存不足、線程泄露導致線程數超過操作系統最大線程數 ulimit 限制或是線程數超過 kernel.pid_max。需要根據情況進行資源升配、限制線程池大小、減少線程棧大小等操作。

爲什麼堆內存未超過 Xmx 卻發生了 OOM?

相信很多人都遇到過這一場景,在 K8s 部署的 Java 應用經常重啓,查看容器退出狀態爲exit code 137 reason: OOM Killed 各方信息都指向明顯的 OOM,然而 JVM 監控數據顯示堆內存用量並未超過最大堆內存限制Xmx,並且配置了 OOM 自動 heapdump 參數之後,發生 OOM 時卻沒有產生 dump 文件。

根據上面的背景知識介紹,容器內的 Java 應用可能會發生兩種類型的 OOM 異常,一種是 JVM OOM,一種是容器 OOM。JVM 的 OOM 是 JVM 內存區域空間不足導致的錯誤,JVM 主動拋出錯誤並退出進程,通過觀測數據可以看到內存用量超限,並且 JVM 會留下相應的錯誤記錄。而容器的 OOM 是系統行爲,整個容器進程組使用的內存超過 Cgroup 限制,被系統 OOM Killer 殺死,在系統日誌和 K8s 事件中會留下相關記錄。

總的來說,Java程序內存使用同時受到來自 JVM 和 Cgroup 的限制,其中 Java 堆內存受限於 Xmx 參數,超限後發生 JVM OOM;整個進程內存受限於容器內存limit值,超限後發生容器 OOM。需要結合觀測數據、JVM 錯誤記錄、系統日誌和 K8s 事件對 OOM 進行區分、排障,並按需進行配置調整。

怎麼理解操作系統和 JVM 的內存關係?

上文說到 Java 容器 OOM 實質是 Java 進程使用的內存超過 Cgroup 限制,被操作系統的 OOM Killer 殺死。那在操作系統的視角里,如何看待 Java 進程的內存?操作系統和 JVM 都有各自的內存模型,二者是如何映射的?對於探究 Java 進程的 OOM 問題,理解 JVM 和操作系統之間的內存關係非常重要。

以最常用的 OpenJDK 爲例,JVM 本質上是運行在操作系統上的一個 C++ 進程,因此其內存模型也有 Linux 進程的一般特點。Linux 進程的虛擬地址空間分爲內核空間和用戶空間,用戶空間又細分爲很多個段,此處選取幾個和本文討論相關度高的幾個段,描述 JVM 內存與進程內存的映射關係。

  1. 代碼段。一般指程序代碼在內存中的映射,這裏特別指出是 JVM 自身的代碼,而不是Java代碼。
  • 數據段。在程序運行初已經對變量進行初始化的數據,此處是 JVM 自身的數據。
  • 堆空間。運行時堆是 Java 進程和普通進程區別最大的一個內存段。Linux 進程內存模型裏的堆是爲進程在運行時動態分配的對象提供內存空間,而幾乎所有JVM內存模型裏的東西,都是 JVM 這個進程在運行時新建出來的對象。而 JVM 內存模型中的 Java 堆,只不過是 JVM 在其進程堆空間上建立的一段邏輯空間。
  • 棧空間。存放進程的運行棧,此處並不是 JVM 內存模型中的線程棧,而是操作系統運行 JVM 本身需要留存的一些運行數據。

如上所述,堆空間作爲 Linux 進程內存佈局和 JVM 內存佈局都有的概念,是最容易混淆也是差別最大的一個概念。Java 堆相較於 Linux 進程的堆,範圍更小,是 JVM 在其進程堆空間上建立的一段邏輯空間,而進程堆空間還包含支撐 JVM 虛擬機運行的內存數據,例如 Java 線程堆棧、代碼緩存、GC 和編譯器數據等。

爲什麼程序佔用的內存比 Xmx 大不少,內存都用在哪了?

在 Java 開發者看來,Java 代碼運行中開闢的對象都放在 Java 堆中,所以很多人會將 Java 堆內存等同於 Java 進程內存,將 Java 堆內存限制參數Xmx當作進程內存限制參數使用,並且把容器內存限制也設置爲 Xmx 一樣大小,然後悲催地發現容器被 OOM 了。

實質上除了大家所熟悉的堆內存(Heap),JVM 還有所謂的非堆內存(Non-Heap),除去 JVM 管理的內存,還有繞過 JVM 直接開闢的本地內存。Java 進程的內存佔用情況可以簡略地總結爲下圖:

JDK8 引入了 Native Memory Tracking (NMT)特性,可以追蹤 JVM 的內部內存使用。默認情況下,NMT 是關閉狀態,使用 JVM 參數開啓:-XX:NativeMemoryTracking=[off | summary | detail]

 $ java -Xms300m -Xmx300m -XX:+UseG1GC -XX:NativeMemoryTracking=summary -jar app.jar

此處限制最大堆內存爲 300M,使用 G1 作爲 GC 算法,開啓 NMT 追蹤進程的內存使用情況。

注意:啓用 NMT 會導致 5% -10% 的性能開銷。

開啓 NMT 後,可以使用 jcmd 命令打印 JVM 內存的佔用情況。此處僅查看內存摘要信息,設置單位爲 MB。

 $ jcmd <pid> VM.native_memory summary scale=MB

JVM 總內存

Native Memory Tracking:

Total: reserved=1764MB, committed=534MB

NMT 報告顯示進程當前保留內存爲 1764MB,已提交內存爲 534MB,遠遠高於最大堆內存 300M。保留指爲進程開闢一段連續的虛擬地址內存,可以理解爲進程可能使用的內存量;提交指將虛擬地址與物理內存進行映射,可以理解爲進程當前佔用的內存量。

需要特別說明的是,NMT 所統計的內存與操作系統統計的內存有所差異,Linux 在分配內存時遵循 lazy allocation 機制,只有在進程真正訪問內存頁時纔將其換入物理內存中,所以使用 top 命令看到的進程物理內存佔用量與 NMT 報告中看到的有差別。此處只用 NMT 說明 JVM 視角下內存的佔用情況。

Java Heap

Java Heap (reserved=300MB, committed=300MB)
    (mmap: reserved=300MB, committed=300MB)

Java 堆內存如設置的一樣,實際開闢了 300M 的內存空間。

Metaspace

Class (reserved=1078MB, committed=61MB)
      (classes #11183)
      (malloc=2MB #19375) 
      (mmap: reserved=1076MB, committed=60MB)

加載的類被存儲在 Metaspace,此處元空間加載了 11183 個類,保留了近 1G,提交了 61M。

加載的類越多,使用的元空間就越多。元空間大小受限於-XX:MaxMetaspaceSize(默認無限制)和 -XX:CompressedClassSpaceSize(默認 1G)。

Thread

Thread (reserved=60MB, committed=60MB)
       (thread #61)
       (stack: reserved=60MB, committed=60MB)

JVM 線程堆棧也需要佔據一定空間。此處 61 個線程佔用了 60M 空間,每個線程堆棧默認約爲 1M。堆棧大小由 -Xss 參數控制。

Code Cache

Code (reserved=250MB, committed=36MB)
     (malloc=6MB #9546) 
     (mmap: reserved=244MB, committed=30MB)

代碼緩存區主要用來保存 JIT 即時編譯器編譯後的代碼和 Native 方法,目前緩存了 36M 的代碼。代碼緩存區可以通過 -XX:ReservedCodeCacheSize 參數進行容量設置。

GC

GC (reserved=47MB, committed=47MB)
   (malloc=4MB #11696) 
   (mmap: reserved=43MB, committed=43MB)

GC 垃圾收集器也需要一些內存空間支撐 GC 操作,GC 佔用的空間與具體選用的 GC 算法有關,此處的 GC 算法使用了 47M。在其他配置相同的情況下,換用 SerialGC:

 GC (reserved=1MB, committed=1MB)
   (mmap: reserved=1MB, committed=1MB)

可以看到 SerialGC 算法僅使用 1M 內存。這是因爲 SerialGC 是一種簡單的串行算法,涉及數據結構簡單,計算數據量小,所以內存佔用也小。但是簡單的 GC 算法可能會帶來性能的下降,需要平衡性能和內存表現進行選擇。

Symbol

 Symbol (reserved=15MB, committed=15MB)
       (malloc=11MB #113566) 
       (arena=3MB #1)

JVM 的 Symbol 包含符號表和字符串表,此處佔用 15M。

非 JVM 內存

NMT 只能統計 JVM 內部的內存情況,還有一部分內存不由JVM管理。除了 JVM 託管的內存之外,程序也可以顯式地請求堆外內存 ByteBuffer.allocateDirect,這部分內存受限於 -XX:MaxDirectMemorySize 參數(默認等於-Xmx)。System.loadLibrary 所加載的 JNI 模塊也可以不受 JVM 控制地申請堆外內存。綜上,其實並沒有一個能準確估量 Java 進程內存用量的模型,只能夠儘可能多地考慮到各種因素。其中有一些內存區域能通過 JVM 參數進行容量限制,例如代碼緩存、元空間等,但有些內存區域不受 JVM 控制,而與具體應用的代碼有關。

Total memory = Heap + Code Cache + Metaspace + Thread stacks + 
               Symbol + GC + Direct buffers + JNI + ...

爲什麼線上容器比本地測試內存需求更大?

經常有用戶反饋,爲什麼相同的一份代碼,在線上容器裏跑總是要比本地跑更耗內存,甚至出現 OOM。可能的情況的情況有如下幾種:

沒有使用容器感知的 JVM 版本

在一般的物理機或虛擬機上,當未設置 -Xmx 參數時,JVM 會從常見位置(例如,Linux 中的 /proc目錄下)查找其可以使用的最大內存量,然後按照主機最大內存的 1/4 作爲默認的 JVM 最大堆內存量。而早期的 JVM 版本並未對容器進行適配,當運行在容器中時,仍然按照主機內存的 1/4 設置 JVM最 大堆,而一般集羣節點的主機內存比本地開發機大得多,容器內的 Java 進程堆空間開得大,自然更耗內存。同時在容器中又受到 Cgroup 資源限制,當容器進程組內存使用量超過 Cgroup 限制時,便會被 OOM。爲此,8u191 之後的 OpenJDK 引入了默認開啓的 UseContainerSupport 參數,使得容器內的 JVM 能感知容器內存限制,按照 Cgroup 內存限制量的 1/4 設置最大堆內存量。

線上業務耗費更多內存

對外提供服務的業務往往會帶來更活躍的內存分配動作,比如創建新的對象、開啓執行線程,這些操作都需要開闢內存空間,所以線上業務往往耗費更多內存。並且越是流量高峯期,耗費的內存會更多。所以爲了保證服務質量,需要依據自身業務流量,對應用內存配置進行相應擴容。

雲原生 Java 應用內存的配置建議

  1. 使用容器感知的 JDK 版本。對於使用 Cgroup V1 的集羣,需要升級至 8u191+、Java 9、Java 10 以及更高版本;對於使用 Cgroup V2 的集羣,需要升級至 8u372+ 或 Java 15 及更高版本。
  2. 使用 NativeMemoryTracking(NMT) 瞭解應用的 JVM 內存用量。NMT 能夠追蹤 JVM 的內存使用情況,在測試階段可以使用 NMT 瞭解程序JVM使用內存的大致分佈情況,作爲內存容量配置的參考依據。JVM 參數 -XX:NativeMemoryTracking 用於啓用 NMT,開啓 NMT 後,可以使用 jcmd 命令打印 JVM 內存的佔用情況。
  3. 根據 Java 程序內存使用量設置容器內存 limit。容器 Cgroup 內存限制值來源於對容器設置的內存 limit 值,當容器進程使用的內存量超過 limit,就會發生容器 OOM。爲了程序在正常運行或業務波動時發生 OOM,應該按照 Java 進程使用的內存量上浮 20%~30% 設置容器內存 limit。如果初次運行的程序,並不瞭解其實際內存使用量,可以先設置一個較大的 limit 讓程序運行一段時間,按照觀測到的進程內存量對容器內存 limit 進行調整。
  4. OOM 時自動 dump 內存快照,併爲 dump 文件配置持久化存儲,比如使用 PVC 掛載到 hostPath、OSS 或 NAS,儘可能保留現場數據,支撐後續的故障排查。

原文鏈接

本文爲阿里雲原創內容,未經允許不得轉載。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章