专栏名称: 阿里开发者
阿里巴巴官方技术号,关于阿里的技术创新均将呈现于此
目录
相关文章推荐
白鲸出海  ·  REKKIE AR护目镜,滑雪装备中的科技与狠活 ·  8 小时前  
阿里开发者  ·  记一次内存利用率问题排查 ·  22 小时前  
白鲸出海  ·  出货量破10万台,追觅科技押对了 ·  2 天前  
阿里开发者  ·  LLM 联网搜索,到底是咋回事? ·  2 天前  
51好读  ›  专栏  ›  阿里开发者

记一次内存利用率问题排查

阿里开发者  · 公众号  · 科技公司  · 2025-02-26 08:30

正文

阿里妹导读


本文详细记录和分析了在应用升级到JDK 11后,由于堆外内存(Direct Memory)管理策略的变化导致的内存利用率告警问题。

背景

近期,我们应用开始出现sunfire内存利用率的告警,规律是应用重启后,内存利用率缓慢增长,一段时间不重启后,就会出现告警,一开始看到内存利用率第一反应是堆内存利用率的问题,走了一些弯路,最终发现是堆外内存的影响,本文主要记录和总结该问题的排查过程。

 


环境

  • JDK版本: ajdk11_11.0.14.13_fp2

  • netty版本: 4.1.31.Final

问题分析

内存利用率

登陆机器,使用 free -m 查看内存使用情况,可以看到内存利用率为76.5% = 6269/8192,不过这里有一个问题,这个76.5%和sufire上的82%是对不上的,原因是我们登陆机器后看到的是业务容器内存利用率,在sunfire上面选择单机就能分别看到POD、业务容器、运维容器利用率。

 

 

  

业务容器内存利用率

通过sunfire观察到运维容器内存利用率一直是比较稳定,重点需要分析的业务内存利用率,使用top命名查看各进程的内存使用情况,可以看到JAVA应用就占了74.3%,接下来继续分析JAVA的内存分布了。

  

JAVA进程内存

 

可以看到,JAVA进程内存主要可以分为堆内存和非堆内存/堆外内存

Java 堆内存

1. 定义:

  • Java 堆内存是 JVM 用来存储所有 Java 对象的内存区域,所有通过 new 关键字创建的对象以及数组都在此区域分配内存。

2. 配置:

  • Java 堆内存由 JVM 的垃圾回收器(GC)自动管理,负责回收不再被引用的对象以释放内存。

  • 堆内存的使用情况可以通过 JVM 参数 -Xms 和 -Xmx 来配置,其中:

  • -Xms 设置初始堆大小。

  • -Xmx 设置最大堆大小。

3. 构成:

  • 堆内存通常被分为两个主要部分: 新生代(Young Generation)和老年代(Old Generation)。

  • 新生代: 包含新创建的对象,消费垃圾回收频繁。 由于新对象大多数是短命的,因此 GC 处理频率较高。

  • 老年代: 存放长生命周期的对象,GC 处理不如新生代频繁。

非堆内存/堆外内存

非堆内存:

  • 非堆内存是指不受 Java 垃圾回收管理的内存区域,包括方法区、Java 方法栈区、native 堆(C heap)等。

  • 特别强调的是,方法区(Metaspace 区域在现代 JVM 中),存储类的元数据和静态信息,也被视为非堆内存。

堆外内存(Direct Memory):

定义

堆外内存通常指直接内存(Direct Memory),可以通过 java.nio.ByteBuffer allocateDirect() 方法分配,它包含Mapped Buffer pool和Direct Buffer pool。与 Java 堆内存相比,堆外内存不受垃圾回收的影响,因此可以减少 Full GC 对应用性能的影响,但需要手动管理内存生命周期。在sunfire中,堆外内存的数据来自JMX的接口,可通过java.nio:type=BufferPool,name=direct和java.nio:type=BufferPool,name=mapped查询出来。

配置

堆外内存可以通过 JVM 参数MaxDirectMemorySize来配置

  • -XX:MaxDirectMemorySize=

堆外内存的优势

1. 降低延迟:

  • 使用堆外内存可以避免因 Full GC 导致的 Stop-The-World 现象,从而减少应用的暂停时间。

2. 提高效率:

  • 通过减少 Java 堆和原生堆之间的数据拷贝,可以提高数据的读写效率。 例如,在使用 NIO 进行大文件操作时,堆外内存可以直接进行内存映射,提高访问速度。 许多大数据处理框架,如 Spark、Flink 和 Kafka,利用堆外内存以提高性能和资源利用率。 例如,Netty 作为一套高性能网络通信框架,也大量使用了堆外内存来实现高效的数据传输。

Reserved (保留内存)/Committed (承诺内存)/Resident (常驻内存)

在分析下面的问题之前,我们先理解三个内存相关的名词,来帮助我们理解接下来的问题,在计算机系统中,特别是在涉及内存管理的上下文中,"Reserved"、"Committed" 和 "Resident" 是三个不同的术语,主要用于描述内存的使用情况。以下是对这三个术语的解释:

1. Reserved (保留内存):

  • 保留内存是指操作系统已经为某个应用程序预留的虚拟内存地址空间,但并没有实际分配物理内存。 换句话说,保留的内存区域可以被应用程序使用,但在实际使用之前,操作系统不必立刻为其分配物理 RAM。 保留内存的目的是为了保证应用程序可以在将来使用这些地址,而不会与其他应用程序发生冲突。

2. Committed (承诺内存):

  • 承诺内存是指已经分配并实际使用的内存。 这部分内存可以被视为已承诺给应用程序使用的物理内存,操作系统为其分配了物理 RAM。 简单来说,承诺内存就是已经被分配并实际存在于物理内存中的那部分内存。

3. Resident (常驻内存):

  • 常驻内存指的是当前在物理 RAM 中驻留的内存页。 这部分内存是已经承诺并分配的内存,且确实加载到了物理内存中。 常驻内存与承诺内存的主要区别在于,承诺内存不一定在物理内存中,可能会被交换到磁盘上,而常驻内存则永远是在物理内存中。

在本文中我们监控的内存利用率的指标,是统计的Resident (常驻内存)。

哪块内存区域的变化导致了内存利用率的增长

了解到了java进程的内存主要构成,那再回到一开始的问题,到底是什么原因导致了pod内存利用率的告警,通过每个指标的对比,就能很快的发现堆外内存的增长和内存利用率的陡升是同步的,查看当天做了什么变更,进行了JDK11升级的发布,那么问题就回到了为什么JDK11的升级会引发内存利用率的陡升呢? 

 

 

为什么JDK11的升级会引发内存利用率的陡升

1. 决策内存管理策略

之前JDK8时,USE_DIRECT_BUFFER_NO_CLEANER = true,走noCleaner(PlatformDependent.allocateDirectNoCleaner)的分支,升级到JDK11后,走hasCleaner(ByteBuffer.allocateDirect)的分支。

if (maxDirectMemory == 0     || !hasUnsafe()     || !PlatformDependent0.hasDirectBufferNoCleanerConstructor()) {    USE_DIRECT_BUFFER_NO_CLEANER = false;} else {    USE_DIRECT_BUFFER_NO_CLEANER = true;}
private static ByteBuffer allocateDirect(int capacity) {return PlatformDependent.useDirectBufferNoCleaner() ?PlatformDependent.allocateDirectNoCleaner(capacity) : ByteBuffer.allocateDirect(capacity);}

2. NoCleaner策略(PlatformDependent.allocateDirectNoCleaner)

UNSAFE.allocateMemory这一行代码会调用native方法allocateMemory划分一块承诺内存 (Committed)

// 核心代码newDirectBuffer(UNSAFE.allocateMemory(capacity), capacity)

3. HasCleaner策略(ByteBuffer.allocateDirect)

可以看到HasCleaner策略,除了执行UNSAFE.allocateMemory外汇,还会执行UNSAFE.setMemory(base, size, (byte) 0)这一行代码,这就是堆外内存增长的核心原因了; 这个方法背后会调用native方法setMemory,找到承诺并分配的内存加载到RAM物理内存中成为Resident内存。

DirectByteBuffer(int cap) {                  
   super(-1, 0, cap, cap);    boolean pa = VM.isDirectMemoryPageAligned();    int ps = Bits.pageSize();    long size = Math.max(1L, (long)cap + (pa ? ps : 0));    Bits.reserveMemory(size, cap);
   long base = 0;    try {        base = UNSAFE.allocateMemory(size);    } catch (OutOfMemoryError x) {        Bits.unreserveMemory(size, cap);        throw x;    }    UNSAFE.setMemory(base, size, (byte) 0);    if (pa && (base % ps != 0)) {        // Round up to page boundary        address = base + ps - (base & (ps - 1));    } else {        address = base;    }    cleaner = Cleaner.create(this, new Deallocator(base, size, cap));    att = null;}

为什么JDK8升级到JDK11之后USE_DIRECT_BUFFER_NO_CLEANER = false

可以看到USE_DIRECT_BUFFER_NO_CLEANER依赖于maxDirectMemory、hasUnsafe() 、PlatformDependent0.hasDirectBufferNoCleanerConstructor(),通过观察日志应用启动日志(其实在升级JDK11的时候就会新增这个debug级别的告警日志但被忽略了-.-)发现maxDirectMemory和hasUnsafe()在JDK8和JDK11是一致的,不一样的就是PlatformDependent0.hasDirectBufferNoCleanerConstructor这个方法的返回值,下面我们看下为什么hasDirectBufferNoCleanerConstructor返回值不一样。

if (maxDirectMemory == 0     || !hasUnsafe()     || !PlatformDependent0.hasDirectBufferNoCleanerConstructor()) {    USE_DIRECT_BUFFER_NO_CLEANER = false;} else {    USE_DIRECT_BUFFER_NO_CLEANER = true;}

 

当DirectBuffer构造器不为null时,hasDirectBufferNoCleanerConstructor返回true,就会走到else分支 设置USE_DIRECT_BUFFER_NO_CLEANER = true; 而当DIRECT_BUFFER_CONSTRUCTOR不为null,需要ReflectionUtil.trySetAccessible设置成功。

final Object maybeDirectBufferConstructor =                        AccessController.doPrivileged(new PrivilegedAction() {                            @Override                            public Object run() {                                try {                                    final Constructor> constructor =                                            direct.getClass().getDeclaredConstructor(long.class, int.class);                                    Throwable cause = ReflectionUtil.trySetAccessible(constructor, true);                                    if (cause != null) {                                        return cause;                                    }                                    return constructor;                                } catch (NoSuchMethodException e) {                                    return e;                                } catch (SecurityException e) {                                    return e;                                }                            }                        });
DIRECT_BUFFER_CONSTRUCTOR = directBufferConstructor

由于默认没设置io.netty.tryReflectionSetAccessible的值,当java版本低于JDK9时,返回了true,也就是说之前是JDK8,ReflectionUtil.trySetAccessible设置成功了,所以DIRECT_BUFFER_CONSTRUCTOR不为null,走到else分支 设置USE_DIRECT_BUFFER_NO_CLEANER = true,升级到JDK11后就走到了if分支USE_DIRECT_BUFFER_NO_CLEANER = false

public static Throwable trySetAccessible(AccessibleObject object, boolean checkAccessible) {        if (checkAccessible && !PlatformDependent0.isExplicitTryReflectionSetAccessible()) {            return new UnsupportedOperationException("Reflective setAccessible(true) disabled");        }        try {            object.setAccessible(true);            return null;        } catch (SecurityException e) {            return e;        } catch (RuntimeException e) {            return handleInaccessibleObjectException(e);        }    }
 private static boolean explicitTryReflectionSetAccessible0() {        // we disable reflective access        return SystemPropertyUtil.getBoolean("io.netty.tryReflectionSetAccessible", javaVersion() < 9);    }
public static boolean getBoolean




    
(String key, boolean def) {        String value = get(key);        if (value == null) {            return def;        }
       value = value.trim().toLowerCase();        if (value.isEmpty()) {            return def;        }
       if ("true".equals(value) || "yes".equals(value) || "1".equals(value)) {            return true;        }
       if ("false".equals(value) || "no".equals(value) || "0".equals(value)) {            return false;        }
       logger.warn(                "Unable to parse the boolean system property '{}':{} - using the default value: {}",                key, value, def        );
       return def;    }

应该选择HasCleaner还是NoCleaner策略

一般建议采用NoCleaner策略,即使当前应该还没有达到内存利用率瓶颈。

因为noCleaner是Netty在4.1引入的策略:创建不带Cleaner的DirectByteBuffer对象,这样做的好处是绕开带Cleaner的DirectByteBuffer执行构造方法和执行Cleaner的clean()方法中一些额外开销,一方面可以减少Resident (常驻内存)的使用,另外当堆外内存不够的时候,也不会触发System.gc(),提高性能。

JDK11如何和JDK 8一样采用NoCleaner策略

在Java启动参数增加如下部分:

  • 添加jvm参数 -Dio.netty.tryReflectionSetAccessible=true 参数

  • 添加jvm参数 --add-opens=java.base/jdk.internal.misc=ALL-UNNAMED 参数 打开Unsafe权限

  • 添加jvm参数 --add-opens=java.base/java.nio=ALL-ALL-UNNAMED 打开nio的包访问限制 

JVM添加以上参数后,我门再来看一下内存变化,java进程RES内存减少了500多MB,也就是对应堆外内存占用的Resident (常驻内存)。

添加参数前:

TOP监控

Resident内存4.8G

 

NMT监控

$jcmd 8607 VM.native_memory scale=MB8607:
Native Memory Tracking:
Total: reserved=7359MB, committed=5538MB-                 Java Heap (reserved=3840MB, committed=3840MB)                            (mmap: reserved=3840MB, committed=3840MB) -                     Class (reserved=1227MB, committed=527MB)                            (classes #83975)                            (  instance classes #80682, array classes #3293)                            (malloc=23MB #393787)                            (mmap: reserved=1204MB, committed=504MB)                            (  Metadata:   )                            (    reserved=444MB, committed=443MB)                            (    used=432MB)                            (    free=11MB)                            (    waste=0MB =0.00%)                            (  Class space:)                            (    reserved=760MB, committed=61MB)                            (    used=55MB)                            (    free=6MB)                            (    waste=0MB =0.00%) -                    Thread (reserved=1183MB, committed=134MB)                            (thread #1173)                            (stack: reserved=1178MB, committed=128MB)                            (malloc=4MB #7040)                            (arena=1MB #2345) -                      Code (reserved=256MB, committed=184MB)                            (malloc=15MB #51500)                            (mmap: reserved=242MB, committed=170MB) -                        GC (reserved=26MB, committed=26MB)                            (malloc=15MB #13986)                            (mmap: reserved=11MB, committed=11MB) -                  Compiler (reserved=6MB, committed=6MB)                            (malloc=6MB #4322) -                  Internal (reserved=72MB, committed=72MB)                            (malloc=72MB #94258) -                     Other (reserved=525MB, committed=525MB)                            (malloc=525MB #447) -                    Symbol (reserved=73MB, committed=73MB)                            (malloc=69MB #977115)                            (arena=4MB #1) -    Native Memory Tracking (reserved=25MB, committed=25MB)                            (tracking overhead=25MB) -               Arena Chunk (reserved=55MB, committed=55MB)                            (malloc=55MB) -                    Module (reserved=8MB, committed=8MB)                            (malloc=8MB #45253) -              Synchronizer (reserved=2MB, committed=2MB)                            (malloc=2MB #15079) -                    (null) (reserved=60MB, committed=60MB)                            (mmap: reserved=60MB, committed=60MB)

arthas监控

direct堆外内存526 MB = 551703298 B/(1024*1024)

[arthas@8607]$ mbean java.nio:name=direct,type=BufferPool
OBJECT_NAME java.nio:name=direct,type=BufferPool ----------------------------------------------------- NAME VALUE ----------------------------------------------------- TotalCapacity 551703289 MemoryUsed 551703298 Name direct Count 202 ObjectName java.nio:type=BufferPool,name=direct

mapped堆外内存几乎没有:

 OBJECT_NAME    java.nio:name=mapped,type=BufferPool                                                                                             -----------------------------------------------------                                                                                             NAME           VALUE                                                                                                                            -----------------------------------------------------                                                                                             TotalCapacity  1024                                                                                                                              MemoryUsed     1024                                                                                                                              Name           mapped                                                                                                                            Count          1                                                                                                                                 ObjectName     java.nio:type=BufferPool,name=mapped 

添加参数后:







请到「今天看啥」查看全文