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

語言: CN / TW / HK

theme: Chinese-red

為什麼我設置的大小關係沒有錯,還會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)。