【JVM故障問題排查心得】「記憶體診斷系列」JVM記憶體與Kubernetes中pod的記憶體、容器的記憶體不一致所引發的OOMKilled問題總結(下)

2022-12-01 15:01:07

承接上文

之前文章根據《【JVM故障問題排查心得】「記憶體診斷系列」JVM記憶體與Kubernetes中pod的記憶體、容器的記憶體不一致所引發的OOMKilled問題總結(上)》我們知道了如何進行設定和控制對應的堆記憶體和容器記憶體的之間的關係,所以防止JVM的堆記憶體超過了容器記憶體,導致容器出現OOMKilled的情況。但是在整個JVM程序體系而言,不僅僅只包含了Heap堆記憶體,其實還有其他相關的記憶體儲存空間是需要我們考慮的,一邊防止這些記憶體空間會造成我們的容器記憶體溢位的場景,正如下圖所示。

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

JVM引數MaxDirectMemorySize

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

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

-XX:MaxDirectMemorySize

-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的JVM原始碼來分一下。

  // Convert the -XX:MaxDirectMemorySize= command line flag
  // to the sun.nio.MaxDirectMemorySize property.
  // Do this after setting user properties to prevent people
  // from setting the value with a -D option, as requested.
  // Leave empty if not supplied
  if (!FLAG_IS_DEFAULT(MaxDirectMemorySize)) {
    char as_chars[256];
    jio_snprintf(as_chars, sizeof(as_chars), JULONG_FORMAT, MaxDirectMemorySize);
    Handle key_str = java_lang_String::create_from_platform_dependent_str("sun.nio.MaxDirectMemorySize", CHECK_NULL);
    Handle value_str  = java_lang_String::create_from_platform_dependent_str(as_chars, CHECK_NULL);
    result_h->obj_at_put(ndx * 2,  key_str());
    result_h->obj_at_put(ndx * 2 + 1, value_str());
    ndx++;
  }

jvm.cpp 裡頭有一段程式碼用於把 - XX:MaxDirectMemorySize 命令引數轉換為 key 為 sun.nio.MaxDirectMemorySize的屬性。我們可以看出來他轉換為了該屬性之後,進行設定和初始化直接記憶體的設定。針對於直接記憶體的核心類就在http://www.docjar.com/html/api/sun/misc/VM.java.html。大家有興趣可以看一下對應的視線。在JVM原始碼裡面的目錄是:java.base/jdk/internal/misc/VM.java,我們看一下該類關於直接記憶體的重點部分。

public class VM {

    // the init level when the VM is fully initialized
    private static final int JAVA_LANG_SYSTEM_INITED     = 1;
    private static final int MODULE_SYSTEM_INITED        = 2;
    private static final int SYSTEM_LOADER_INITIALIZING  = 3;
    private static final int SYSTEM_BOOTED               = 4;
    private static final int SYSTEM_SHUTDOWN             = 5;


    // 0, 1, 2, ...
    private static volatile int initLevel;
    private static final Object lock = new Object();

    //......

    // A user-settable upper limit on the maximum amount of allocatable direct
    // buffer memory.  This value may be changed during VM initialization if
    // "java" is launched with "-XX:MaxDirectMemorySize=<size>".
    //
    // The initial value of this field is arbitrary; during JRE initialization
    // it will be reset to the value specified on the command line, if any,
    // otherwise to Runtime.getRuntime().maxMemory().
    //
    private static long directMemory = 64 * 1024 * 1024;

上面可以看出來64MB最初是任意設定的。在-XX:MaxDirectMemorySize 是用來設定NIO direct memory上限用的VM引數。可以看一下JVM的這行程式碼。

product(intx, MaxDirectMemorySize, -1,
        "Maximum total size of NIO direct-buffer allocations")

但如果不設定它的話,direct memory預設最多能申請多少記憶體呢?這個引數預設值是-1,顯然不是一個「有效值」。所以真正的預設值肯定是從別的地方來的。


    // Returns the maximum amount of allocatable direct buffer memory.
    // The directMemory variable is initialized during system initialization
    // in the saveAndRemoveProperties method.
    //
    public static long maxDirectMemory() {
        return directMemory;
    }

    //......

    // Save a private copy of the system properties and remove
    // the system properties that are not intended for public access.
    //
    // This method can only be invoked during system initialization.
    public static void saveProperties(Map<String, String> props) {
        if (initLevel() != 0)
            throw new IllegalStateException("Wrong init level");

        // only main thread is running at this time, so savedProps and
        // its content will be correctly published to threads started later
        if (savedProps == null) {
            savedProps = props;
        }

        // Set the maximum amount of direct memory.  This value is controlled
        // by the vm option -XX:MaxDirectMemorySize=<size>.
        // The maximum amount of allocatable direct buffer memory (in bytes)
        // from the system property sun.nio.MaxDirectMemorySize set by the VM.
        // If not set or set to -1, the max memory will be used
        // The system property will be removed.
        String s = props.get("sun.nio.MaxDirectMemorySize");
        if (s == null || s.isEmpty() || s.equals("-1")) {
            // -XX:MaxDirectMemorySize not given, take default
            directMemory = Runtime.getRuntime().maxMemory();
        } else {
            long l = Long.parseLong(s);
            if (l > -1)
                directMemory = l;
        }
        // Check if direct buffers should be page aligned
        s = props.get("sun.nio.PageAlignDirectMemory");
        if ("true".equals(s))
            pageAlignDirectMemory = true;
    }
    //......
}

從上面的原始碼可以讀取 sun.nio.MaxDirectMemorySize 屬性,如果為 null 或者是空或者是 - 1,那麼則設定為 Runtime.getRuntime ().maxMemory ();如果有設定 MaxDirectMemorySize 且值大於 - 1,那麼使用該值作為 directMemory 的值;而 VM 的 maxDirectMemory 方法則返回的是 directMemory 的值。

因為當MaxDirectMemorySize引數沒被顯式設定時它的值就是-1,在Java類庫初始化時maxDirectMemory()被java.lang.System的靜態構造器呼叫,走的路徑就是這條:

if (s.equals("-1")) {  
    // -XX:MaxDirectMemorySize not given, take default  
    directMemory = Runtime.getRuntime().maxMemory();  
}

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

JVM_ENTRY_NO_ENV(jlong, JVM_MaxMemory(void))  
  JVMWrapper("JVM_MaxMemory");  
  size_t n = Universe::heap()->max_capacity();  
  return convert_size_t_to_jlong(n);  
JVM_END  

這個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,這樣才可以執行

public BufferPoolMXBean getDirectBufferPoolMBean(){
        return ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class)
                .stream()
                .filter(e -> e.getName().equals("direct"))
                .findFirst()
                .orElseThrow();
}
public JavaNioAccess.BufferPool getNioBufferPool(){
     return SharedSecrets.getJavaNioAccess().getDirectBufferPool();
}

記憶體分析問題

-XX:+DisableExplicitGC 與 NIO的direct memory

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

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