Netty笔记:直接内存OOM且进程僵死问题排查

2022-04-10 15:42:29 浏览数 (2)

Netty 是一个异步事件驱动的网络通信层框架,用于快速开发高可用高性能的服务端网络框架与客户端程序,它极大地简化了 TCP 和 UDP 套接字服务器等网络编程。 和别人单独开发一个基于Netty的高性能Server入门netty不同,我深入了解Netty源自 数据透传Server直接内存OOM且进程僵死问题的排查。

一、问题与背景

一天自己接手的一个日志透传模块出现大量直接内存OOM的异常日志告警,且不久进程出现僵死,服务不可用。关键错误日志如下:

代码语言:java复制
io.netty.util.internal.OutOfDirectMemoryError: failed to allocate 16777216 byte(s) of direct memory (used: 2147483648, max: 2147483648)
        at io.netty.util.internal.PlatformDependent.incrementMemoryCounter(PlatformDependent.java:775)
        at io.netty.util.internal.PlatformDependent.allocateDirectNoCleaner(PlatformDependent.java:730)
        at io.netty.buffer.PoolArena$DirectArena.allocateDirect(PoolArena.java:645)
        at io.netty.buffer.PoolArena$DirectArena.newChunk(PoolArena.java:621)
        at io.netty.buffer.PoolArena.allocateNormal(PoolArena.java:204)
        at io.netty.buffer.PoolArena.tcacheAllocateNormal(PoolArena.java:188)
        at io.netty.buffer.PoolArena.allocate(PoolArena.java:138)
        at io.netty.buffer.PoolArena.allocate(PoolArena.java:128)
        at io.netty.buffer.PooledByteBufAllocator.newDirectBuffer(PooledByteBufAllocator.java:378)
        at io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:187)
        at io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:178)
        at io.netty.channel.unix.PreferredDirectByteBufAllocator.ioBuffer(PreferredDirectByteBufAllocator.java:53)
        at io.netty.channel.DefaultMaxMessagesRecvByteBufAllocator$MaxMessageHandle.allocate(DefaultMaxMessagesRecvByteBufAllocator.java:114)
        at io.netty.channel.epoll.EpollRecvByteAllocatorHandle.allocate(EpollRecvByteAllocatorHandle.java:75)
        at io.netty.channel.epoll.EpollDatagramChannel$EpollDatagramChannelUnsafe.epollInReady(EpollDatagramChannel.java:485)
        at io.netty.channel.epoll.AbstractEpollChannel$AbstractEpollUnsafe$1.run(AbstractEpollChannel.java:388)
        at io.netty.util.concurrent.AbstractEventExecutor.safeExecute(AbstractEventExecutor.java:164)
        at io.netty.util.concurrent.SingleThreadEventExecutor.runAllTasks(SingleThreadEventExecutor.java:472)
        at io.netty.channel.epoll.EpollEventLoop.run(EpollEventLoop.java:387)
        at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:989)
        at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
        at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
        at java.lang.Thread.run(Thread.java:748)

问题出现后,第一步就是要进行紧急定位与恢复。经过定位发现,该时刻点业务有瞬时集中上报大量数据,故直接内存OOM与其直接相关,通过JVM参数-XX:MaxDirectMemorySize=4G,翻倍直接内存大小并重启后业务恢复。

二、问题分析

1、现场回顾

  • netty版本:4.1.58.Final
  • jvm版本:1.8.0_242
  • 堆内存:2GB

之前对Netty和直接内存这块了解不是很多,于是对一些基本问题进行了深入理解。

1)直接内存的默认设置

程序在现网运行阶段,其实我们并没有设置-XX:MaxDirectMemorySize,那实际运行的直接内存为啥是2GB?

2)Netty直接内存申请机制

代码语言:java复制
private static void incrementMemoryCounter(int capacity) {
    if (DIRECT_MEMORY_COUNTER != null) {
        long newUsedMemory = DIRECT_MEMORY_COUNTER.addAndGet(capacity);
        if (newUsedMemory > DIRECT_MEMORY_LIMIT) {
            DIRECT_MEMORY_COUNTER.addAndGet(-capacity);
            throw new OutOfDirectMemoryError("failed to allocate "   capacity
                      " byte(s) of direct memory (used: "   (newUsedMemory - capacity)
                      ", max: "   DIRECT_MEMORY_LIMIT   ')');
        }
    }
}

2、是否存在内存泄漏?

虽然直接内存泄漏问题的排查是极其痛苦和繁琐,但千万不要被这堆讨厌的 OOM 日志和内存泄漏问题吓到。直接内存是否够用,我们先打印出相关的指标再做分析。

1)反射打印出堆外内存计数

由上文所知,Netty的PlatformDependent类中,incrementMemoryCounter方法进行直接内存统计判断,所以我参考了美团这篇技术文章的实现方案,使用反射获取到DIRECT_MEMORY_COUNTER

详细实现如下:

代码语言:java复制
// 使用得是spring的ReflectionUtils,spring yyds!
Field field = ReflectionUtils.findField(PlatformDependent.class, "DIRECT_MEMORY_COUNTER");
field.setAccessible(true);
directMem = (AtomicLong) field.get(PlatformDependent.class);

笔者后面补充:其实可以直接通过 PlatformDependent.usedDirectMemory() 访问获取到DIRECT_MEMORY_COUNTER的值,不用反射机制。

image.pngimage.png

按秒打印 DIRECT_MEMORY_COUNTER 的值后发现,其大小是会上下波动。

自己的一个极端猜想被实际实验给打破:高qps数据来了之后,DIRECT_MEMORY_COUNTER 会增加到最大值,哪怕后面qps降低了也不会对应调小。

2)使用netty自带的内存泄漏检测工具

Netty使用虚引用跟踪每一个 ByteBuf(涉及到java常见面试题《强应用、软引用、虚引用、虚幻引用的区别》)。

image.pngimage.png

这类问题排查十分困难,好在netty自带了一个内存泄漏的检测工具:

jvm启动参数增加 -Dio.netty.leakDetectionLevel=[检测级别]

  • disabled 完全关闭内存泄露检测
  • simple 以约1%的抽样率检测是否泄露,默认级别
  • advanced 抽样率同simple,但显示详细的泄露报告
  • paranoid 抽样率为100%,显示报告信息同advanced

注意抽样率越高,Netty性能越低! 不过日志并未显示任何异常的报告!

3)SimpleChannelInboundHandler自动释放是否存在性能瓶颈

通过继承SimpleChannelInboundHandler定义入站消息处理,在该类会保证消息最终被自动release。

参考阅读:https://segmentfault.com/a/1190000021469481

这里我有个猜想:自动释放机制是否存在性能瓶颈。

验证方法:

基于ChannelInboundHandlerAdapter,实现相关逻辑,buf.release();, 对比SimpleChannelInboundHandler 实现的性能。

实验发现,二者都在差不多qps场景下出现oom情况。

3、为何出现进程僵死?

观察程序gc日志我们发现,存在频繁full gc的情况。

image.pngimage.png

分析原理:

DirectByteBuffer(int cap)构造方法中才会初始化Cleaner对象,方法中检查当前内存是否超过允许的最大堆外内存,如果直接内存分配超出限制后,则会先尝试将不可达的Reference对象加入Reference链表中,依赖Reference的内部守护线程触发可以被回收DirectByteBuffer关联的Cleaner的run()方法

如果内存还是不足, 则执行 System.gc(),触发full gc,来回收堆内存中的DirectByteBuffer对象来触发堆外内存回收,如果还是超过限制,则抛出java.lang.OutOfMemoryError(代码位于java.nio.Bits#reserveMemory()方法)。

所以这样就导致了一个恶性循环,qps高 =》 直接内存满 =》触发full gc =》 jvm stw =》堆内存中的DirectByteBuffer对象释放慢 =》 直接内存满 =》触发full gc =》 ……。

尝试了增加jvm参数-XX: DisableExplicitGC,但是没有奏效。

4、初步结论

综上所述,不难发现问题根源还是在于业务瞬时qps过高,击穿了Netty,并导致了一系列恶性循环的后果。

直接解决方案:还是老办法,业务减少瞬间上报、适当加内存。

三、引申思考:引入反压

是否可以通过高水位控制,超过高水位设置Netty不可写,丢弃一部分数据保证服务不被击穿? 具体实现方式待调研!

image.pngimage.png

0 人点赞