服务在启动时总会出现这样的日志
但只会出现一次,而且报告内存泄漏的线程也不固定,由报告线程栈来看,不由得让人产生这样的疑问 “难道所有使用了netty的框架都有内存泄漏?”
由于对netty不了解, 那时的我是这样认为的(2020年前我其实没写过什么Java代码,更别提Netty啦)
-
内存泄漏日志只出现一次,那应该就只有一次内存泄漏,没什么影响
-
已经发现了内存泄漏,框架应该会把泄漏的内存回收掉,没什么影响
但事实确不是这样,随着服务的长时间运行最终会迎来堆外内存OOM(如果服务存在堆外内存泄漏,但一时半会又无法定位解决,还是不要设置-XX:MaxDirectMemorySize为好,让它触发oom killer挂掉重启或者捕获了OutOfMemory后异常退出服务触发重启,不然服务还是会正常接收请求,但返回数据为空,影响服务质量)
事实是什么?
为了看清事实,我们需要先了解一点netty检测内存泄漏的原理
对于使用netty的PooledByteBufAllocator分配出去的ByteBuf对象(下文皆指堆外内存),netty都会用一个DefaultResourceLeak的弱引用对象去跟踪,当ByteBuf对象被垃圾回收,DefaultResourceLeak对象就会被放到与之关联的引用队列中(DefaultResourceLeak对象创建时指定的队列),当有业务线程调用newDirectBuffer分配内存时就会调用reportLeak触发内存泄漏检查(这就是为什么内存泄漏时,报告线程不固定的原因),而且每个泄漏点只会报告一次(netty会把每次已经报告的泄漏点记录下来)
那么netty具体是怎样利用弱引用发现内存泄漏的?
前面提到过netty使用DefaultResourceLeak这个类的对象弱引用netty分配出去的ByteBuf对象,对于没有强引用只有弱引用引用的对象,只要有垃圾回收发生,此对象就可以被回收,而且垃圾回收线程还会把与之关联的弱引用对象放到与之关联的引用队列中,用户就可以从引用队列中拿到弱引用对象,做一些事
netty的ByteBuf使用引用计数来确定内存是否要归还,当用户调用ByteBuf.release时会减少引用计数,当引用计数为0时就会归还
那么我们当然会想到,当从引用队列中发现有ByteBuf的引用计数不为0,那不就是有内存泄漏了?
想法是好的,但是此时被DefaultResourceLeak弱引用的ByteBuf对象已经被垃圾回收了,我们已无法访问
那netty是怎么做的?
前面提到过当ByteBuf被创建时,同时会创建一个DefaultResourceLeak的对象弱引用此ByteBuf,除此之外还会把DefaultResourceLeak对象放到一个全局的Set中
当调用ByteBuf.release时发现引用计数是0时,会做2件事
-
把内存归还
-
把DefaultResourceLeak对象从全局的Set中清理掉
所以当从引用队列中取出DefaultResourceLeak对象后,只需判断此对象在不在全局的Set中,如果在那说明就存在内存泄漏
说了这么多,我们就只得出了一个可能众所周知但是我不知道的结论就是,一旦发现日志中有netty内存泄漏日志,那么100%有内存泄漏,不要心存侥幸,迟早要OOM
接下来就是要定位,内存泄漏点了
内存创建的地方(netty内存泄漏日志中已指出)
内存使用的地方
内存泄漏的地方(这才是重点)
而对于内存泄漏的地方,贴心的netty5已经帮我们实现了https://netty.io/wiki/reference-counted-objects.html
由于"Recent access records"这句话在我的内存泄漏报告日志中也有出现,于是查看netty-4.1.70.Final(线上使用的版本)代码发现netty4也同样支持这个功能并且相关参数也打开了(-Dio.netty.leakDetection.level=advanced -Dio.netty.leakDetection.acquireAndReleaseOnly=false),但是我的内存泄漏报告日志中并没有最近访问ByteBuf的线程栈内容(如果有应该以看到诸如dubbo,brpc,lettuce的字样)
不过没关系,我们回到文章开头的三张图,无论内存泄漏日志中的线程栈怎么变(下图其实是dubbo的EventLoop线程,yrpc是yy内部搞的dubbo的通信协议),但都是同一个栈,由这个线程栈大概可心知道它做了什么,就是netty的事件循环线程检测到有读事件后申请ByteBuf从socket读数据到ByteBuf
由日志我们知道,泄漏的ByteBuf是netty的EvetLoop线程创建的,接下来我们要确定的是,到底是dubbo,lettuce,brpc-java中的那个框架在使用ByteBuf后没有release
netty提供了三种常用的事件循环实现,NIO,EPOLL,KQUEUE,常用的就是JDK的NIO,LINUX的EPOLL
话不多说jstack导出线程栈,不得不说运气是好到爆炸,只有brpc-jave使用的是NIO,如果dubbo,lettuce,brpc-java,使用的都是NIO,或都是EPOLL,那么我们就无法区分,就需要我们手动进行设置,其中一个与其它两个不同,以便确认是那个框架创建的ByteBuf泄漏了
lettuce使用的是EpollEventLoop
dubbo使用的是EpollEventLoop
brpc使用的NioEventLoop与泄漏日志中的线程栈吻合
基于此我们可以断定,是brpc-java框架泄漏了netty创建的ByteBuf,那么接下来我们要阅读分析brpc-java源码,以便定位内存泄漏点
其实也简单,顺着创建使用释放的代码流程看下去寻找可能泄漏的地方,感兴趣可以了解下https://github.com/baidu/starlight/tree/brpc-java-v3
中间过程就不多说了,直接说结果,brpc-java到底是如何泄漏ByteBuf的?
到底是brpc-java框架的问题,还是业务使用方的问题,至少写到这时我是不确定的
我们要了解一个其它rpc协议一般没有的功能,brpc的C++版本的brpc协议有个attachment是字节数组类型(一般用于放序列化后的二进制数据),有些场景使用这个attachment携带数据时,可减少一次内存拷贝(比如直接传输序列化后的二进制数据),brpc-java的brpc协议也支持这个功能,由于有attechment的存在,本该在rpc框架内部创建使用释放的ByteBuf,却透传给了用户(正常用户只会拿到一个堆内存中的pb对象的返回结果),而我们基于brpc-c++实现的KV存储服务就恰恰使用了attachment来携带数据,业务调用方使用brpc-java,而这个attachment就是在netty的事件循环线程创建的ByteBuf中的一部分
c++
java
然后我们可能会想到ByteBuf一定是在业务代码中泄漏了?
非也,业务代码可谓固若金汤,在try{}finally{}中有调用ByteBuf.release,那问题到底出在那?
说到这我们不得不要,了解下brpc-java客户端发送请求,与接收请求的逻辑
发送逻辑:
-
用户线程调用接口传入请求参数(pb对象)
-
rpc内部产生此次请求唯一id,创建Future,以唯一id为key,保存Future(”接收逻辑步骤3“的rpc线程使用Future通知用户线程,请求已经返回了)
-
创建定时任务,触发时间为用户设置的socket读超时(定时器超时删除Future,构造超时返回结果,返回给用户)
-
序列化请求参数(pb对象),发送请求并等待返回,超时时间为用户调置的socket写超时(超时后抛异常,直接抛给用户)
-
调用Future.get阻塞等待返回结果(正常返回取消步骤3的定时任务),超时时间为用户设置的socket读超时
接收逻辑:
-
netty事件循环线程检测到读事件触发
-
创建ByteBuf从socket读取数据,通知rpc线程处理
-
rpc线程以唯一id查找Future,如果找到则删除Future,并反序列化数据为pb对象,唤醒“发送逻辑步骤5”,如果未找到Future,说明已超时用户线程已返回,则调用ByteBuf.release释放
这里只是简单描述下接收发送逻辑,还有很多细节没说,但足以支撑我们用来理解内存是怎么泄漏的了,从“发送逻辑步骤3”,跟“接收逻辑步骤3”,我们可以可做以下设想来创造泄漏场景
内存泄漏场景如下
用户调用rpc接口的线程超时是10ms(超时后invoke内部会cancel掉用户调用线程,用户线程会从“发送逻辑步骤5”的Future.get被interrupted返回)
ThreadPool.invoke(()->callRpc(), 10, TimeUnit.MICROSECONDS)
而socke读超时是1s
在“发送逻辑步骤3”的定时任务还没执行前,或”接收逻辑3“已经完成后,用户调用线程就提前超时返回了,那么rpc框架本该透传给用户线程使用并释放的ByteBuf,就会出现没有接收者去使用去释放最终导致泄漏
到这里你以为你乖乖的设置好超时就可以了?
继续查看代码发现,brpc-java使用的定时器的精度是100ms的
做个测试创建一个10ms的定时任务(为了更有说服力我实际创建了3个)
对于小于100ms的定时任务要过了100ms才触发,对于大于100ms小于200ms的定时任务要过了200ms才触发
这就有趣了,也就是说你的用户线程的调用超时就算与readSocketTimeOut一致但小于100ms,如果服务调用耗时大于用户线程的调用超时它还是会出现内存泄漏
依赖定时任务来判断有没有接收者的实现就有问题,定时任务只能做兜底,它晚一点触发也没什么影响的场景,这里显然不合适,这里只能在用户线程超时前调度定时任务才能保证没内存泄漏(比如尽可能让用户线程不超时,或用户线程超时要大于socket读超时)
比如修改rpc调用如下
Future future = ThreadPool.submit(()->callRpc())
future.get(10, TimeUnit.MICROSECONDS);
future调用超时后不会cancel调用线程,调用线程会继续等待返回结果,走正常的处理逻辑,最终释放ByteBuf
写到这我突然觉得在这种设计下,一边是用户线程,一边是rpc线程,一旦存在多线程协作就存在同步通信的问题
这似乎不是框架的问题,我已经把数据给你用户了,但你用户不接收又不用还不释放,关我框架什么事?
这似乎又是框架的问题,我用户已经不要这个数据了,你为什么还要给我返回,关我用户什么事?