一次Java直接内存溢出定位经历

环境: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

spring boot - Java Heap Dump : How to find the objects/class that is taking memory by 1. io.netty.buffer.ByteBufUtil 2. byte[] array - 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() 方法主要做两件事情:

  1. 将 Cleaner 对象从 Cleaner 链表中移除;
  2. 调用 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回收;

参考:Netty 内存回收之 noCleaner 策略

对于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(十二)初识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 都走了堆外分配

当我们使用了这些框架,并且遇到了堆外内存的问题后,可以从这些方面入手

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值