本文详细记录和分析了在应用升级到JDK 11后,由于堆外内存(Direct Memory)管理策略的变化导致的内存利用率告警问题。
近期,我们应用开始出现sunfire内存利用率的告警,规律是应用重启后,内存利用率缓慢增长,一段时间不重启后,就会出现告警,一开始看到内存利用率第一反应是堆内存利用率的问题,走了一些弯路,最终发现是堆外内存的影响,本文主要记录和总结该问题的排查过程。
登陆机器,使用 free -m 查看内存使用情况,可以看到内存利用率为76.5% = 6269/8192,不过这里有一个问题,这个76.5%和sufire上的82%是对不上的,原因是我们登陆机器后看到的是业务容器内存利用率,在sunfire上面选择单机就能分别看到POD、业务容器、运维容器利用率。
通过sunfire观察到运维容器内存利用率一直是比较稳定,重点需要分析的业务内存利用率,使用top命名查看各进程的内存使用情况,可以看到JAVA应用就占了74.3%,接下来继续分析JAVA的内存分布了。
可以看到,JAVA进程内存主要可以分为堆内存和非堆内存/堆外内存
Java 堆内存
1.
定义:
2.
配置:
-Xms
设置初始堆大小。
-Xmx
设置最大堆大小。
3.
构成:
非堆内存/堆外内存
非堆内存:
堆外内存(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来配置
堆外内存的优势
1.
降低延迟:
2.
提高效率:
Reserved (保留内存)/Committed (承诺内存)/Resident (常驻内存)
在分析下面的问题之前,我们先理解三个内存相关的名词,来帮助我们理解接下来的问题,在计算机系统中,特别是在涉及内存管理的上下文中,"Reserved"、"Committed" 和 "Resident" 是三个不同的术语,主要用于描述内存的使用情况。以下是对这三个术语的解释:
1.
Reserved (保留内存):
2.
Committed (承诺内存):
3.
Resident (常驻内存):
在本文中我们监控的内存利用率的指标,是统计的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 )) {
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 () {
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=MB
8607 :
Native Memory Tracking:
Total: reserved=7359 MB, committed=5538 MB
- Java Heap (reserved=3840 MB, committed=3840 MB)
(mmap: reserved=3840 MB, committed=3840 MB)
- Class (reserved=1227 MB, committed=527 MB)
(classes #83975 )
( instance classes #80682 , array classes #3293 )
(malloc =23 MB #393787 )
(mmap: reserved=1204 MB, committed=504 MB)
( Metadata: )
( reserved=444 MB, committed=443 MB)
( used=432 MB)
( free =11 MB)
( waste=0 MB =0.00 %)
( Class space:)
( reserved=760 MB, committed=61 MB)
( used=55 MB)
( free =6 MB)
( waste=0 MB =0.00 %)
- Thread (reserved=1183 MB, committed=134 MB)
(thread #1173 )
(stack : reserved=1178 MB, committed=128 MB)
(malloc =4 MB #7040 )
(arena=1 MB #2345 )
- Code (reserved=256 MB, committed=184 MB)
(malloc =15 MB #51500 )
(mmap: reserved=242 MB, committed=170 MB)
- GC (reserved=26 MB, committed=26 MB)
(malloc =15 MB #13986 )
(mmap: reserved=11 MB, committed=11 MB)
- Compiler (reserved=6 MB, committed=6 MB)
(malloc =6 MB #4322 )
- Internal (reserved=72 MB, committed=72 MB)
(malloc =72 MB #94258 )
- Other (reserved=525 MB, committed=525 MB)
(malloc =525 MB #447 )
- Symbol (reserved=73 MB, committed=73 MB)
(malloc =69 MB #977115 )
(arena=4 MB #1 )
- Native Memory Tracking (reserved=25 MB, committed=25 MB)
(tracking overhead=25 MB)
- Arena Chunk (reserved=55 MB, committed=55 MB)
(malloc =55 MB)
- Module (reserved=8 MB, committed=8 MB)
(malloc =8 MB #45253 )
- Synchronizer (reserved=2 MB, committed=2 MB)
(malloc =2 MB #15079 )
- (null) (reserved=60 MB, committed=60 MB)
(mmap: reserved=60 MB, committed=60 MB)
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
添加参数后: