有个生产环境CRM业务应用服务,情况有些奇怪,监控数据显示内存异常。内存使用率99.%多。通过生产监控看板发现,CRM内存超配或内存泄漏的现象,下面分析一下这个问题过程记录。
生产服务器采用阿里云ECS机器,配置是2HZ、8GB,单个应用服务独占,CRM应用独立部署,即单台服务器仅部署一个java应用服务。
用了6个节点6台机器,每台机器都差不多情况。
监控看板如下:
top命令查看物理占用情况
通过看板情况来看确实存在异常情况,下面进一步分析问题原因。
应用启动配置参数如下:
/usr/bin/java
-javaagent:/home/agent/skywalking-agent.jar
-Dskywalking.agent.service_name=xx-crm
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/xx-crm.hprof
-Dspring.profiles.active=prod
-server -Xms4884m -Xmx4884m -Xmn3584m
-XX:MetaspaceSize=512m
-XX:MaxMetaspaceSize=512m
-XX:CompressedClassSpaceSize=128m
-jar /home/xxs-crm.jar
堆内:最大最小堆内存4884m约4.8G左右,其中新生代-Xmn3584m 约3.5G左右,
非堆: 元数据区配置 512M,类压缩空间 128M, Code Cache、buffer等代码缓存区240M(没有配置参数,通过监控看板看到的)。
从监控看板的数据来看,我们简单统计一下内存分配数据情况。
通过JVM配置参数和监控看板数据可知:
堆内存:4.8G
非堆内存:(Metaspace)512M+(CompressedClassSpace)128M+(Code Cache、Buffer等)240M约等1GB左右。
堆内存(heap)+非堆内存(nonHeap)=5.8G
8GB物理内存除去操作系统本身占用大概占500M。即除了操作系统本身占用之外,还有7.5G可用内存。
但是 7.5-5.8=1.7GB,起码至少还有1~2GB空闲才合理呀!怎么内存占用率99%多,就意味着有1~2G不知道谁占去了,有点诡异!
先看一下JVM内存模型,环境是使用JDK8
JVM内存数据分区如下图所示:
堆heap分配结构如下图所示:
堆大家都比较熟悉,也容易理解的,也是java程序接触得最多的一块,不存在什么数据上统计错误,或占用不算之类的。
那说明额外占用也非堆里面,只不过没有统计到非堆里面去,曾经一度怀疑监控prometheus展示的数据有误。
其实不是监控统计的问题,那是什么问题呢?
先看一下dump文件数据,这里使用MAT工具(一个开源免费的内存分析工具,个人认为比较好用,推荐大家使用。下载地址:https://www.eclipse.org/mat/downloads.php)。
通过下载内存dump镜像观察到,如下图所示:
有个offHeapStore,这个东西堆外内存,可以初步判断是 ehcahe引起的。
通过ehcahe源码分析,发现ehcache里面也使用了netty的NIO方法内存,ehcache磁盘缓存写数据时会用到DirectByteBuffer。
DirectByteBuffer是使用非堆内存,不受GC影响。
在网络编程中,为避免频繁的在用户空间与内核空间拷贝数据,通常会直接从内核空间中申请内存,存放数据,在Java中,把内核空间的内存称之为直接内存,nio包中的ByteBuffer的allocateDirect方法,就是申请直接内存。
直接打开JDK源代码
ByteBuffer
/**
* Allocates a new direct byte buffer.
*
* The new buffer's position will be zero, its limit will be its
* capacity, its mark will be undefined, and each of its elements will be
* initialized to zero. Whether or not it has a
* {@link #hasArray backing array} is unspecified.
*
* @param capacity
* The new buffer's capacity, in bytes
*
* @return The new byte buffer
*
* @throws IllegalArgumentException
* If the capacity is a negative integer
*/
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
DirectByteBuffer对象是ByteBuffer的子类,对于直接内存的分配,就是在这个类中实现的。
在java中
分配给定大小的新本地内存块(以字节为单位)。 存储器的内容未初始化; 它们通常是垃圾。 结果本机指针永远不会为零,并且将针对所有值类型进行对齐。 通过调用freeMemory处理此内存,或使用reallocateMemory调整其大小。
DirectByteBuffer帮我们简化了直接内存的使用,我们不需要直接操作Unsafe类来进行直接内存的申请与释放,那么其是如何实现的呢?
直接内存的申请:
在DirectByteBuffer实例通过构造方法创建的时候,会通过Unsafe类的allocateMemory方法 帮我们申请直接内存资源。
直接内存的释放:
DirectByteBuffer本身是一个Java对象,其是位于堆内存中的,JDK的GC机制可以自动帮我们回收,但是其申请的直接内存,不再GC范围之内,无法自动回收。好在JDK提供了一种机制,可以为堆内存对象注册一个钩子函数(其实就是实现Runnable接口的子类),当堆内存对象被GC回收的时候,会回调run方法,我们可以在这个方法中执行释放DirectByteBuffer引用的直接内存,即在run方法中调用Unsafe 的freeMemory 方法。注册是通过sun.misc.Cleaner类来实现的。
// Primary constructor
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;
}
从代码中我们可以看到构造方法中的确是用了unsafe.allocateMemory方法帮我们分配了直接内存,另外,在构造方法的最后,通过 Cleaner.create方法注册了一个钩子函数,用于清除直接内存的引用。
Cleaner.create方法声明如下所示:
public static Cleaner create(Object heapObj, Runnable task)
其中第一个参数是一个堆内存对象,第二个参数是一个Runnable任务,表示这个堆内存对象被回收的时候,需要执行的回调方法。我们可以看到在DirectByteBuffer的最后一行中,传入的这两个参数分别是this,和一个Deallocator(实现了Runnable接口),其中this表示就是当前DirectByteBuffer实例,也就是当前DirectByteBuffer被回收的时候,回调Deallocator的run方法
Deallocator就是用于清除DirectByteBuffer引用的直接内存,代码如下所示:
private static class Deallocator
implements Runnable
{
private static Unsafe unsafe = Unsafe.getUnsafe();
private long address;
private long size;
private int capacity;
private Deallocator(long address, long size, int capacity) {
assert (address != 0);
this.address = address;
this.size = size;
this.capacity = capacity;
}
public void run() {
if (address == 0) {
// Paranoia
return;
}
unsafe.freeMemory(address);
address = 0;
Bits.unreserveMemory(size, capacity);
}
}
在DirectByteBuffer实例创建时,分配内存之前调用了Bits.reserveMemory,如果分配失败调用了Bits.unreserveMemory,同时在Deallocator释放完直接内存的时候,也调用了Bits.unreserveMemory方法。
这两个方法,主要是记录jdk已经使用的直接内存的数量,当分配直接内存时,需要进行增加,当释放时,需要减少,源码如下:
static void reserveMemory(long size, int cap) {
if (!memoryLimitSet && VM.isBooted()) {
maxMemory = VM.maxDirectMemory();
memoryLimitSet = true;
}
// optimist!
if (tryReserveMemory(size, cap)) {
return;
}
final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();
// retry while helping enqueue pending Reference objects
// which includes executing pending Cleaner(s) which includes
// Cleaner(s) that free direct buffer memory
while (jlra.tryHandlePendingReference()) {
if (tryReserveMemory(size, cap)) {
return;
}
}
// trigger VM's Reference processing
System.gc();
// a retry loop with exponential back-off delays
// (this gives VM some time to do it's job)
boolean interrupted = false;
try {
long sleepTime = 1;
int sleeps = 0;
while (true) {
if (tryReserveMemory(size, cap)) {
return;
}
if (sleeps >= MAX_SLEEPS) {
break;
}
if (!jlra.tryHandlePendingReference()) {
try {
Thread.sleep(sleepTime);
sleepTime <<= 1;
sleeps++;
} catch (InterruptedException e) {
interrupted = true;
}
}
}
// no luck
throw new OutOfMemoryError("Direct buffer memory");
} finally {
if (interrupted) {
// don't swallow interrupts
Thread.currentThread().interrupt();
}
}
}
//释放内存时,减少引用直接内存的计数
static void unreserveMemory(long size, int cap) {
long cnt = count.decrementAndGet();
long reservedMem = reservedMemory.addAndGet(-size);
long totalCap = totalCapacity.addAndGet(-cap);
assert cnt >= 0 && reservedMem >= 0 && totalCap >= 0;
}
通过上面代码的分析,可以认为Bits类是直接内存的分配担保,当有足够的直接内存可以用时,增加直接内存应用计数,否则,调用System.gc,进行垃圾回收,需要注意的是,System.gc只会回收堆内存中的对象,但是 DirectByteBuffer对象被回收时,那么其引用的直接内存也会被回收,试想现在刚好有其他的DirectByteBuffer可以被回收,那么其被回收的直接内存就可以用于本次DirectByteBuffer直接的内存的分配。
回到本问题上来,当有文件需要暂存到ehcache的磁盘缓存时,使用到了NIO中的FileChannel来读取文件,默认ehcache使用了堆内的HeapByteBuffer来给FileChannel作为读取文件的缓冲,FileChannel读取文件使用的IOUtil的read方法,针对HeapByteBuffer底层还用到一个临时的DirectByteBuffer来和操作系统进行直接的交互。
ehcache使用HeapByteBuffer作为读文件缓冲:
IOUtil对于HeapByteBuffer实际会用到一个临时的DirectByteBuffer来和操作系统进行交互。
DirectByteBuffer泄漏根因分析
默认情况下这个临时的DirectByteBuffer会被缓存在一个ThreadLocal的bufferCache里不会释放,每一个bufferCache有一个DirectByteBuffer的数组,每次当前线程需要使用到临时DirectByteBuffer时会取出自己bufferCache里的DirectByteBuffer数据,选取一个不小于所需size的,如果bufferCache为空或者没有符合的,就会调用Bits重新创建一个,使用完之后再缓存到bufferCache里。
这里的问题在于 :这个bufferCache是ThreadLocal的,意味着极端情况下有N个调用线程就会有N组 bufferCache,就会有N组DirectByteBuffer被缓存起来不被释放,而且不同于在IO时直接使用DirectByteBuffer,这N组DirectByteBuffer连GC时都不会回收。我们的文件服务在读写ehcache的磁盘缓存时直接使用的tomcat的worker线程池,
这个worker线程池的配置上限是2000,我们的配置中心上的配置的参数:
所以,这种隐藏的问题影响所有使用到HeapByteBuffer的地方而且很隐秘,由于在CRM服务中大量使用了ehcache存在较大的sizeIO且调用线程比较多的场景下容易暴露出来。
获取临时DirectByteBuffer的逻辑:
bufferCache从ByteBuffer数组里选取合适的ByteBuffer:
将ByteBuffer回种到bufferCache:
NIO中的FileChannel、SocketChannel等Channel默认在通过IOUtil进行IO读写操作时,除了会使用HeapByteBuffer作为和应用程序的对接缓冲,但在底层还会使用一个临时的DirectByteBuffer来和系统进行真正的IO交互,为提高性能,当使用完后这个临时的DirectByteBuffer会被存放到ThreadLocal的缓存中不会释放,当直接使用HeapByteBuffer的线程数较多或者IO操作的size较大时,会导致这些临时的DirectByteBuffer占用大量堆外直接内存造成泄漏。
那么除了减少直接调用ehcache读写的线程数有没有其他办法能解决这个问题?并发比较高的场景下意味着减少业务线程数并不是一个好办法。
在Java1.8_102版本开始,官方提供一个参数jdk.nio.maxCachedBufferSize,这个参数用于限制可以被缓存的DirectByteBuffer的大小,对于超过这个限制的DirectByteBuffer不会被缓存到ThreadLocal的bufferCache中,这样就能被GC正常回收掉。唯一的缺点是读写的性能会稍差一些,毕竟创建一个新的DirectByteBuffer的代价也不小,当然通过测试验证对比分析,性能也没有数量级的差别。
增加参数:
-XX:MaxDirectMemorySize=1600m
-Djdk.nio.maxCachedBufferSize=500000 ---注意不能带单位
就是调整了-Djdk.nio.maxCachedBufferSize=500000(注意这里是字节数,不能用m、k、g等单位)。
增加调整参数之后,运行一段时间,持续观察整体DirectByteBuffer稳定控制在1.5G左右,性能也几乎没有衰减。
一切恢复正常,再看监控看板没有看到占满内存告警。
调整应用启动参数配置,业务系统调整后的启动命令参数如下:
java
-javaagent:/home/agent/skywalking-agent.jar
-Dskywalking.agent.service_name=xx-crm
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/xx-crm.hprof
-Dspring.profiles.active=prod
-server -Xms4608m -Xmx4608m -Xmn3072m
-XX:MetaspaceSize=300m
-XX:MaxMetaspaceSize=512m
-XX:CompressedClassSpaceSize=64m
-XX:MaxDirectMemorySize=1600m
-Djdk.nio.maxCachedBufferSize=500000
-jar /home/xx-crm.jar
碰到这类非堆内存问题有两种解决办法:
1、在业务允许条件下减少IO线程数。
2、调整配置应用启动参数,-Djdk.nio.maxCachedBufferSize=xxx 记住 -XX:MaxDirectMemorySize 参数也要配置上。
如果不配置 -XX:MaxDirectMemorySize 这个参数(最大直接内存),JVM就默认取-Xmx的值当作它的最大值(可能就会像我一样遇到超配的情况)。
3、遇到问题多读一下JDK源码,并学会内存分析工具。