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
的值,不用反射机制。
按秒打印 DIRECT_MEMORY_COUNTER
的值后发现,其大小是会上下波动。
自己的一个极端猜想被实际实验给打破:高qps数据来了之后, DIRECT_MEMORY_COUNTER
会增加到最大值,哪怕后面qps降低了也不会对应调小。
2)使用netty自带的内存泄漏检测工具
Netty使用虚引用跟踪每一个 ByteBuf(涉及到java常见面试题《强应用、软引用、虚引用、虚幻引用的区别》)。
这类问题排查十分困难,好在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的情况。
分析原理:
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不可写,丢弃一部分数据保证服务不被击穿? 具体实现方式待调研!