环境:JDK8
现象:
测试压测过程中,有一个转发服务容器的内存占比98%,过一段时间后,容器重启;问题可复现
定位:
容器有重启行为,可以使用dmesg 先查看系统错误日志
dmesg -H | tail -n 50
发现:是宿主机 cgroup将容器进程kill掉
1. 是否是堆内存泄漏?
首先看堆内存是不是泄漏有泄漏?
当容器内存占用率达到90%左右时,通过jmap命令查看服务进程堆内存大小。发现堆内存属于正常范围内。排除堆内存溢出问题。
2. 是否堆外内存泄露
给服务进程添加启动参数:-XX:NativeMemoryTracking=summary, 重启服务,用于开启Native Memory Tracking (NMT)特性;
注意:其中该值默认为off,可以设置为summary或者detail来开启;开启的话,大概会增加5%-10%的性能消耗。
在大概容器内存占用率在30%左右时,基于此时jvm运行状态,设定为baseline
./jcmd pid VM.native_memory baseline
内存达到80%的时候jcmd生成的信息
./jcmd pid VM.native_memory summary.diff
可以看到heap大小不变,是堆的capacity。而native memory其他项都增加得不多,而此时的internal增加了约800M。
internal具体是什么含义还不清楚,不过确实能看到堆外内存在不断增加;
那是什么地方用到了堆外内存了呢?
我们的代码中并没有显示的申请堆外内存;应该是调用某些框架的时候,隐式的用到了堆外内存,大概率是Netty了,我们的代码中有使用Netty进行Stomp协议连接。
大概看了下Netty的代码,默认使用的就是DirectBuffer。
问题验证:
通过增加-Dio.netty.maxDirectMemory参数限制Netty可以使用的directMemory上限
当directMemory达到最大值时,在日志中看到报了OutOfDirectMemoryError,并且定位到了是PlatformDependent.incrementMemoryCounter时因为内存不足报的错;符合预期;基本上就是Netty相关问题。
问题代码定位:
多次细心review代码中和Netty相关的部分,发现当客户端建立连接后,服务端会记录客户端,当有新的消息需要广播的时候,会轮询客户端列表,创建对应的消息帧(DirectBuffer)进行发送;
而客户端断链时,服务端并没有删除该客户端,导致给很多无效的客户端,创建了消息帧(DirectBuffer),导致Netty使用的堆外内存超出限制,在设置了-Dio.netty.maxDirectMemory参数的情况下会报OutOfDirectMemoryError;在没有设置-Dio.netty.maxDirectMemory参数的时候,会一直使用堆外内存,直到超过容器设置的最大内存,被宿主机kill掉。
参考:docker - Why is Internal memory in java Native Memory Tracking increasing - Stack Overflow
通过对上面的分析,我们有一些问题需要解答
堆外内存溢出引发的思考
1.为什么要用堆外内存?
在IO操作中,使用堆外内存,而不是堆内内存,在数据传输的过程中可以减少堆内内存和堆外内存之间的相互拷贝。
2.堆外内存的回收
https://www.cnblogs.com/xiaojiesir/p/15449937.html
我们试想这么一种场景,因为 DirectByteBuffer 对象有可能长时间存在于堆内内存,所以它很可能晋升到 JVM 的老年代,所以这时候 DirectByteBuffer 对象的回收需要依赖 Old GC 或者 Full GC 才能触发清理。如果长时间没有 Old GC 或者 Full GC 执行,那么堆外内存即使不再使用,也会一直在占用内存不释放,很容易将机器的物理内存耗尽,这是相当危险的。
那么在使用 DirectByteBuffer 时我们如何避免物理内存被耗尽呢?因为 JVM 并不知道堆外内存是不是已经不足了,所以我们最好通过 JVM 参数 -XX:MaxDirectMemorySize 指定堆外内存的上限大小,当堆外内存的大小超过该阈值时,就会触发一次 Full GC 进行清理回收,如果在 Full GC 之后还是无法满足堆外内存的分配,那么程序将会抛出 OOM 异常。
此外在 ByteBuffer.allocateDirect 分配的过程中,如果没有足够的空间分配堆外内存,在 Bits.reserveMemory 方法中也会主动调用 System.gc() 强制执行 Full GC,但是在生产环境一般都是设置了 -XX:+DisableExplicitGC,System.gc() 是不起作用的,所以依赖 System.gc() 并不是一个好办法。
当发生 GC 时,DirectByteBuffer 对象被回收,内存中的对象引用情况发生了如下变化:
此时 Cleaner 对象不再有任何引用关系,在下一次 GC 时,该 Cleaner 对象将被添加到 ReferenceQueue 中,并执行 clean() 方法。clean() 方法主要做两件事情:
- 将 Cleaner 对象从 Cleaner 链表中移除;
- 调用 unsafe.freeMemory 方法清理堆外内存。
Netty堆外内存回收原理详解 | Hexo
Java NIO中的中堆外内存的回收:
java.nio.DirectByteBuffer#DirectByteBuffer(int)构造器方法进行直接内存申请的时候,在该方法的最后通过Cleaner虚引用了分配好的DirectByteBuffer对象,在DirectByteBuffer对象被GC后,该Cleaner会被添加到虚引用的队列中,触发Deallocator这个线程的回收。
// Primary constructor
//
DirectByteBuffer(int cap) { // package-private
super(-1, 0, cap, cap, null);
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);
...
//通过Cleaner虚引用了DirectByteBuffer,在DirectByteBuffer对象被GC回收的时候
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}
Netty中堆外内存的回收
ByteBuf通过实现ReferenceCounted接口实现引用计数法,真正的实现在AbstractReferenceCountedByteBuf抽象类中;
ReferenceCounted中主要关注两个方法:
retain(),调用此方法会将引用计数加1
release(), 调用此方法会将引用计数减1
当我们主动调用ByteBuf.release()方法时:
对于UnpooledByteBuf 的回收,在有Cleaner的时候,和上面的NIO中的回收逻辑一样;
在没有Cleaner的时候,通过Unsafe回收;
对于PooledByteBuf 的回收,会首先尝试让io.netty.buffer.PoolThreadCache进行堆外内存回收,如果可以的话,会放入对应大小的MemoryRegionCache中;
如果io.netty.buffer.PoolThreadCache回收失败,会让io.netty.buffer.PoolArena进行回收;
参考:PoolThreadLocalCache
https://www.cnblogs.com/xiaojiesir/p/15477089.html
当我们忘记调用ByteBuf.release()方法时:
Netty会为ByteBuf对象创建一个弱引用ResourceLeakTracker(DefaultResourceLeak)指向它,同时传入一个refQueue,如果ByteBuf被GC回收了而没有调用release释放,则JVM会将WeakReference加入到refQueue中,Netty通过refQueue就可以判断是否发生资源泄漏,一旦检测到泄漏就会调用reportLeak()报告泄漏情况。
Netty是如何检测资源泄漏的?_defaultresourceleak-CSDN博客
netty(十二)初识Netty - ByteBuf 内存回收 - 简书
3.怎么限制堆外内存的大小
Netty怎么限制, java启动命令怎么限制
4.容器中启动java程序,怎么限制直接内存使用大小
通过这两个参数-XX:MaxDirectMemorySize和-Dio.netty.maxDirectMemory
参考:-XX:MaxDirectMemorySize和-Dio.netty.maxDirectMemory区别-CSDN博客
5.常见堆外内存的使用
5.1 一般开发人员,直接申请的堆外内存,主要是通过java.nio.ByteBuffer#allocateDirect申请的内存,可以使用参数数“MaxDirectMemorySize”来限制它的大小。
而且这种方式申请的内存是自带cleaner的
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
可以通过GC回收。
5.2 另外一种常见的就是调用框架API,隐式使用到的堆外内存
Netty中的默认堆外内存申请,Netty4.1以后,-Dio.netty.maxDirectMemory有用(默认noCleaner策略,-XX:MaxDirectMemorySize不起作用);
TODO:netty的堆外内存没有cleaner,怎么回收的呢?
GZIPInputStream 使用 Inflater 申请堆外内存、我们没有调用 close() 方法来主动释放。如果忘记关闭,Inflater 对象的生命会延续到下一次 GC,有一点 类似堆内的弱引用。在此过程中,堆外内存会一直增长。
EhCache 这种缓存框架,提供了多种策略,可以设定将数据存储在非堆上。
还有像 RocketMQ 都走了堆外分配
当我们使用了这些框架,并且遇到了堆外内存的问题后,可以从这些方面入手