阿里妹导读
本文详细记录和分析了在应用升级到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<Object>() { @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
添加参数后:
TOP监控
Resident内存4.2G,对比添加参数前减少了近0.6G(实际为500多 MB,这里单位为GB小数位截断导致),刚好和添加参数前的堆外内存526 MB差不多。
NMT监控
可以看到NMT监控到的JVM内存几乎没有变化,原因在于NMT监控的是reserved和committed内存,netty无论用哪种方式管理内存,都会在初始化时执行UNSAFE.allocateMemory这一行代码划分一块承诺内存 (Committed)。
jcmd 9964 VM.native_memory scale=MB9964: Native Memory Tracking: Total: reserved=7214MB, committed=5399MB- Java Heap (reserved=3840MB, committed=3840MB) (mmap: reserved=3840MB, committed=3840MB) - Class (reserved=1202MB, committed=501MB) (classes #81265) ( instance classes #78038, array classes #3227) (malloc=14MB #260100) (mmap: reserved=1188MB, committed=486MB) ( Metadata: ) ( reserved=428MB, committed=428MB) ( used=418MB) ( free=10MB) ( waste=0MB =0.00%) ( Class space:) ( reserved=760MB, committed=59MB) ( used=54MB) ( free=5MB) ( waste=0MB =0.00%) - Thread (reserved=1154MB, committed=130MB) (thread #1144) (stack: reserved=1148MB, committed=124MB) (malloc=4MB #6866) (arena=1MB #2287) - Code (reserved=255MB, committed=165MB) (malloc=13MB #47242) (mmap: reserved=242MB, committed=152MB) - GC (reserved=26MB, committed=26MB) (malloc=15MB #11967) (mmap: reserved=11MB, committed=11MB) - Compiler (reserved=5MB, committed=5MB) (malloc=4MB #3623) - Internal (reserved=45MB, committed=45MB) (malloc=45MB #28840) - Other (reserved=525MB, committed=525MB) (malloc=525MB #452) - Symbol (reserved=72MB, committed=72MB) (malloc=68MB #959726) (arena=4MB #1) - Native Memory Tracking (reserved=21MB, committed=21MB) (tracking overhead=21MB) - Module (reserved=7MB, committed=7MB) (malloc=7MB #38082) - Synchronizer (reserved=2MB, committed=2MB) (malloc=2MB #12973) - (null) (reserved=60MB, committed=60MB) (mmap: reserved=60MB, committed=60MB)
arthas监控
direct堆外内存10 MB = 10526012 B/(1024*1024)
[arthas@10089]$ mbean java.nio:name=direct,type=BufferPool OBJECT_NAME java.nio:name=direct,type=BufferPool ----------------------------------------------------- NAME VALUE ----------------------------------------------------- TotalCapacity 10526004 MemoryUsed 10526012 Name direct Count 147 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
sunfire监控
堆外内存:添加参数后可以看到堆外内存明显下降,和arthas监控的mbean堆外内存数据一致。
内存利用率:内存利用率明显下降,恢复到JDK11升级前水位。