记一次Netty引发的直接内存OOM事件

目录

问题描述

近期在我们环境上遇到了一个关于直接内存OOM的问题,曾经是没有遇到过的:

根据使用人员的反馈,最近产品使用一段时间就会发生这个OOM,重启后又能好一阵子。

猜测大概的原因是近期我们的产品配置了一个多维列表视图,这个视图会发起大量的接口请求,其中一个接口返回了大量的数据以支持整个视图的渲染:

经过观察,每次访问这个视图时直接内存都会上涨,并且始终是100%,直到最大限制时出现OOM:

问题分析

初步分析

通过异常日志可以看到,这里正在申请4194304 bytes(也就是4MB)的直接内存,但目前已经申请了802890569 bytes(也就是765MB),而最大限制是805306368 bytes(768MB),因此发生OOM。

由于我这里启动应用程序时仅使用 -Xmx=768m指定了最大堆内存,而没有指定最大直接内存,HotSpot默认将最大堆内存作为最大直接内存限制,也就是768MB。


对于使用了这么多直接内存,并且是逐步增长的,而且增长了之后没有释放,于是本能地第一时间怀疑的是不是Netty在使用直接内存时没有调用ByteBuf的release方法释放内存。

堆栈快照分析

于是打算进一步看看内存明细,看看是不是有很多ByteBuf对象没有被回收,使用jmap命令生成堆栈hprof文件

1
jmap -dump:format=b,file=heap.hprof 30551

然后使用MAT分析hprof文件:

确实发现了很多ByteBuf对象没释放!

怀疑Netty存在内存泄漏

确定了这个问题之后就得看为什么Netty中会存在这么多没释放的ByteBuf对象了,Netty作为一款成熟的基础网络框架,应当不会犯这种低级错误。 在github上查一下,也没有发现与OOM有关的open状态的issue:

但是呢内存确实是持续增长的,不可能是我踩着狗屎第一个遇到吧。于是问了下AI,当Netty出现内存泄漏后应该怎么排查,AI告诉我在应用程序启动参数上加上开启Netty内存泄漏检测,于是我照着做了:

1
nohup java $jvm_opts -Xmx768M -jar main.jar -Dio.netty.leakDetection.level=ADVANCED -Dio.netty.leakDetectionTargetRecords=10 --spring.config.location=shared.properties,main.properties,nodes.yml > /dev/null 2>&1 &

但在下一次OOM发生的时候也没有发现任何关于内存泄漏的输出,那就真的没有内存泄漏了,至少不是Netty操作直接内存产生的内存泄漏。

于是将异常堆栈日志丢到搜索引擎上搜索一番,看到关于PooledByteBufAllocator对象的一些说明,这个Allocator为了提升内存分配效率,为每个线程都提供一个ByteBuf缓存,调用release时并没有释放内存,而是将其放回内存池中。如果每次请求申请的内存太大 又开启了太多的线程,那就真的有可能发生OOM。

Netty源码分析

我们来翻一番PooledByteBufAllocator的源码,从异常栈位置跟进:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
protected ByteBuf newDirectBuffer(int initialCapacity, int maxCapacity) {
//每个线程独享的内存池
PoolThreadCache cache = threadCache.get();
PoolArena<ByteBuffer> directArena = cache.directArena;

final ByteBuf buf;
if (directArena != null) {
//从内存池中分配ByteBuf,返回的是PooledByteBuf对象,这个对象的release方法没有真正是否内存,而是返回内存池
buf = directArena.allocate(cache, initialCapacity, maxCapacity);
} else {
buf = PlatformDependent.hasUnsafe() ?
UnsafeByteBufUtil.newUnsafeDirectByteBuf(this, initialCapacity, maxCapacity) :
new UnpooledDirectByteBuf(this, initialCapacity, maxCapacity);
}

return toLeakAwareBuffer(buf);
}
1
2
3
4
5
PooledByteBuf<T> allocate(PoolThreadCache cache, int reqCapacity, int maxCapacity) {
PooledByteBuf<T> buf = newByteBuf(maxCapacity);
allocate(cache, buf, reqCapacity);
return buf;
}

回头再看

这里我有点好奇调用newDirectBuffer时传个参数如何,到底申请多大的内存,毕竟这个问题以前并没有遇到,只是调用了一个返回数据比较大的接口时才遇到的,于是通过arthas观察请求入参:

1
watch io.netty.buffer.PooledByteBufAllocator newDirectBuffer "{params}" -x 3 -b

当从前端界面发起这个接口请求时,newDirectBuffer被调用了多次,但是分配的内存都不是太多:

单独重复多次这个请求时,并不是每次都能看到直接内存在增长,这或许是使用了旧线程,那这个线程的内存池已经足够分配接下来需要申请的内存。

结论

由于Netty PooledByteBufAllocator线程内部内存池缓存的原因,如果开启的请求线程太多,就会持续的占用内存,这种情况下无非两种处理方案:&#x20;

  1. 减少线程数 &#x20;
  2. 增加直接内存限制