【JVM故障問題排查心得】「記憶體診斷系列」Xmx和Xms的大小是小於Docker容器以及Pod的大小的,為啥還是會出現OOMKilled?

2023-01-01 15:00:27

為什麼我設定的大小關係沒有錯,還會OOMKilled?

這種問題常發生在JDK8u131或者JDK9版本之後所出現在容器中執行JVM的問題:在大多數情況下,JVM將一般預設會採用宿主機Node節點的記憶體為Native VM空間(其中包含了堆空間、直接記憶體空間以及棧空間),而並非是是容器的空間為標準。

堆記憶體和VM實際分配記憶體不一致

-XshowSettings:vm

Jps -lVvm

我們在執行的時候將JVM堆記憶體記憶體設定為3000MB,而-XshowSettings:vm列印出的JVM將最大堆大小為1.09G,如果按照這個記憶體進行分配記憶體的話很可能會導致實際記憶體和預分配記憶體所造成的不一致問題。

如何解決此問題

JVM 感知 cgroup 限制

解決JVM記憶體超限的問題,這種方法可以讓JVM自動感知Docker容器的cgroup限制,從而動態的調整堆記憶體大小。

JDK8u131在JDK9中有一個很好的特性,即JVM能夠檢測在Docker容器中執行時有多少記憶體可用。為了使jvm保留根據容器規範的記憶體,必須設定標誌-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap

注意:如果將這兩個標誌與Xms和Xmx標誌一起設定,那麼jvm的行為將是什麼?-Xmx標誌將覆蓋-XX:+ UseCGroupMemoryLimitForHeap標誌

引數分析

  • -XX:+ UseCGroupMemoryLimitForHeap標誌使JVM可以檢測容器中的最大堆大小。
  • -Xmx標誌將最大堆大小設定為固定大小。

除了JVM的堆空間,還會對於非堆Noheap和JVM的東西,還會有一些額外的記憶體使用情況。

使用JDK9的容器感知機制嘗試

設定了容器有4GB記憶體分配,而JVM使用1GM作為最大堆,因為容器中除了JVM之外沒有其他程序在執行,所以我們還可以進一步擴大一下對於Heap堆的分配?

-XX:MaxRAMFraction

在較低的版本的時候可以使用-XX:MaxRAMFraction引數,它告訴JVM使用可用記憶體/MaxRAMFract作為最大堆。使用-XX:MaxRAMFraction=1,我們將幾乎所有可用記憶體用作最大堆。

問題分析

最大堆佔用總記憶體是否仍然會導致你的程序因為記憶體的其他部分(如「元空間」)而被殺死?

答案:MaxRAMFraction=1仍將為其他非堆記憶體留出一些空間

注意:如果容器使用堆外記憶體,這可能會有風險,因為幾乎所有的容器記憶體都分配給了堆。您必須將-XX:MaxRAMFraction=2設定為堆只使用50%的容器記憶體,或者使用Xmx。

容器內部感知CGroup資源限制

Docker1.7開始將容器cgroup資訊掛載到容器中,所以應用可以從 /sys/fs/cgroup/memory/memory.limit_in_bytes 等檔案獲取記憶體、 CPU等設定,在容器的應用啟動命令中根據Cgroup設定正確的資源設定 -Xmx, -XX:ParallelGCThreads 等引數

Java10中,改進了容器整合

Java10+廢除了-XX:MaxRAM引數,因為JVM將正確檢測該值。在Java10中,改進了容器整合,無需新增額外的標誌,JVM將使用1/4的容器記憶體用於堆。

java10+確實正確地識別了記憶體的docker限制,但您可以使用新的標誌MaxRAMPercentage(例如:-XX:MaxRAMPercentage=75)而不是舊的MaxRAMFraction,以便更精確地調整堆的大小。

java10+上的UseContainerSupport選項,而且是預設啟用的,不用設定。同時 UseCGroupMemoryLimitForHeap 這個就棄用了,不建議繼續使用,同時還可以通過 -XX:InitialRAMPercentage、-XX:MaxRAMPercentage、-XX:MinRAMPercentage 這些引數更加細膩的控制 JVM 使用的記憶體比率。

-XX:MaxRAMFraction

Java 程式在執行時會呼叫外部程序、申請 Native Memory 等,所以即使是在容器中執行 Java 程式,也得預留一些記憶體給系統的。所以 -XX:MaxRAMPercentage 不能設定得太大。當然仍然可以使用-XX:MaxRAMFraction=1選項來壓縮容器中的所有記憶體。

上面我們知道了如何進行設定和控制對應的堆記憶體和容器記憶體的之間的關係,所以防止JVM的堆記憶體超過了容器記憶體,導致容器出現OOMKilled的情況。但是在整個JVM程序體系而言,不僅僅只包含了Heap堆記憶體,其實還有其他相關的記憶體儲存空間是需要我們考慮的,一邊防止這些記憶體空間會造成我們的容器記憶體溢位的場景。

Off Heap Space

接下來了我們需要進行分析出heap之外的一部分就是對外記憶體就是Off Heap Space,也就是Direct buffer memory堆外記憶體。主要通過的方式就是採用Unsafe方式進行申請記憶體,大多數場景也會通過Direct ByteBuffer方式進行獲取。好廢話不多說進入正題。

JVM引數MaxDirectMemorySize

研究一下jvm的-XX:MaxDirectMemorySize,該引數指定了DirectByteBuffer能分配的空間的限額,如果沒有顯示指定這個引數啟動jvm,預設值是xmx對應的值(低版本是減去倖存區的大小)。

而Runtime.maxMemory()在HotSpot VM裡的實現是:

-Xmx減去一個survivor space的預留大小

DirectByteBuffer物件是一種典型的」冰山物件」,在堆中存在少量的洩露的物件,但其下面連線用堆外記憶體,這種情況容易造成記憶體的大量使用而得不到釋放

-XX:MaxDirectMemorySize=size 用於設定 New I/O (java.nio) direct-buffer allocations 的最大大小,size 的單位可以使用 k/K、m/M、g/G;如果沒有設定該引數則預設值為 0,意味著JVM自己自動給NIO direct-buffer allocations選擇最大大小。

-XX:MaxDirectMemorySize的預設值是什麼?

  • 在sun.misc.VM中,它是Runtime.getRuntime.maxMemory(),這就是使用-Xmx設定的內容。而對應的JVM引數如何傳遞給JVM底層的呢?主要通過hotspot/share/prims/jvm.cpp。

  • jvm.cpp裡頭有一段程式碼用於把 -XX:MaxDirectMemorySize 命令引數轉換為key為 sun.nio.MaxDirectMemorySize的屬性。我們可以看出來他轉換為了該屬性之後,進行設定和初始化直接記憶體的設定。針對於直接記憶體的核心類就在, 在-XX:MaxDirectMemorySize 是用來設定NIO direct memory上限用的VM引數。但如果不設定它的話,direct memory預設最多能申請多少記憶體呢?這個引數預設值是-1,顯然不是一個「有效值」。

sun.nio.MaxDirectMemorySize 屬性,如果為 null 或者是空或者是 - 1,那麼則設定為 Runtime.getRuntime ().maxMemory ();因為當MaxDirectMemorySize引數沒被顯式設定時它的值就是-1,在Java類庫初始化時maxDirectMemory()被java.lang.System的靜態構造器呼叫。

這個max_capacity()實際返回的是 -Xmx減去一個survivor space的預留大小

結論分析說明

MaxDirectMemorySize沒顯式設定的時候,NIO direct memory可申請的空間的上限就是-Xmx減去一個survivor space的預留大小。例如如果您不設定-XX:MaxDirectMemorySize並設定-Xmx5g,則"預設" MaxDirectMemorySize也將是5GB-survivor space區,並且應用程式的總堆+直接記憶體使用量可能會增長到5 + 5 = 10 Gb 。

其他獲取 maxDirectMemory 的值的API方法

BufferPoolMXBean 及 JavaNioAccess.BufferPool (通過SharedSecrets獲取) 的 getMemoryUsed 可以獲取 direct memory 的大小;其中 java9 模組化之後,SharedSecrets 從原來的 sun.misc.SharedSecrets 變更到 java.base 模組下的 jdk.internal.access.SharedSecrets;要使用 --add-exports java.base/jdk.internal.access=ALL-UNNAMED 將其匯出到 UNNAMED,這樣才可以執行

記憶體分析問題

-XX:+DisableExplicitGC 與 NIO的direct memory

用了-XX:+DisableExplicitGC引數後,System.gc()的呼叫就會變成一個空呼叫,完全不會觸發任何GC(但是「函數呼叫」本身的開銷還是存在的哦~)。

做ygc的時候會將新生代裡的不可達的DirectByteBuffer物件及其堆外記憶體回收了,但是無法對old裡的DirectByteBuffer物件及其堆外記憶體進行回收,這也是我們通常碰到的最大的問題,如果有大量的DirectByteBuffer物件移到了old,但是又一直沒有做cms gc或者full gc,而只進行ygc,那麼我們的實體記憶體可能被慢慢耗光,但是我們還不知道發生了什麼,因為heap明明剩餘的記憶體還很多(前提是我們禁用了System.gc)。