起因
日志偶现
2022-11-15 18:36:34.166 [redisson-netty-5-4] [] [ERROR] [io.netty.util.ResourceLeakDetector.reportTracedLeak:319] LEAK: ByteBuf.release() was not called before it's garbage-collected. See https://netty.io/wiki/reference-counted-objects.html for more information.
Recent access records:
Created at:
io.netty.buffer.PooledByteBufAllocator.newDirectBuffer(PooledByteBufAllocator.java:402)
io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:187)
io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:173)
io.netty.buffer.AbstractByteBufAllocator.buffer(AbstractByteBufAllocator.java:107)
org.redisson.codec.JsonJacksonCodec$1.encode(JsonJacksonCodec.java:81)
// ...
sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
java.lang.reflect.Method.invoke(Method.java:498)
org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:190)
org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:138)
org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:105)
org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:878)
org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:792)
org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)
org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1040)
org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:943)
org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006)
org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:909)
javax.servlet.http.HttpServlet.service(HttpServlet.java:652)
org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883)
javax.servlet.http.HttpServlet.service(HttpServlet.java:733)
org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:227)
org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)
org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53)
org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)
org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)
// ...
org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)
org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)
org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)
org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)
org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)
org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)
org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93)
org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)
org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)
org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)
org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)
org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)
org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)
org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)
// ...
org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)
日志上已经体现出了错误的根因:ByteBuf.release() was not called,大概意思是分配了内存但是没有及时释放,详细的信息可以参考链接:https://netty.io/wiki/reference-counted-objects.html
排查
private final JsonJacksonCodec codec = new JsonJacksonCodec(JSONUtil.getCommonMapper());
public Object getAttribute(String key) {
// ....
try {
ByteBuf buf = ByteBufAllocator.DEFAULT.buffer(jsonValue.length() * 3);
buf.writeCharSequence(jsonValue, StandardCharsets.UTF_8);
return codec.getValueDecoder().decode(buf, new State());
} catch (Exception e) {
// ....
}
}
protected void setAttrObj(String key, Object obj) {
// ....
String jsonValue = null;
try {
jsonValue = codec.getValueEncoder().encode(obj).toString(StandardCharsets.UTF_8); // 异常指向这里
} catch (Exception e) {
// ....
}
// ...
}
查阅代码很快就定位抛出异常的地方,结合上下文很快就有了猜测:getAttribute()方法中的ByteBuf buf没有及时release掉。
ByteBuf buf = null;
try {
buf = ByteBufAllocator.DEFAULT.buffer(jsonValue.length() * 3);
buf.writeCharSequence(jsonValue, StandardCharsets.UTF_8);
return codec.getValueDecoder().decode(buf, new State());
} catch (Exception e) {
// ...
} finally {
if (buf != null) {
buf.release();
}
}
以为解决了,发上去之后发现还是出现了,代码指向还是没变化,也就是说,不是因为这里?于是翻阅代码,查看了JsonJacksonCodec的源代码,才注意到codec.getValueEncoder().encode(obj)返回的是一个ByteBuf的对象,而查阅ByteBuf#toString()方法也没有找到相关的release调用,所以说在进行Encode也出现引用泄漏。
ByteBuf byteBuf = null;
try {
byteBuf = codec.getValueEncoder().encode(obj);
jsonValue = byteBuf.toString(StandardCharsets.UTF_8);
} catch (Exception e) {
// ...
} finally {
if (byteBuf != null) {
byteBuf.release();
}
}
发布,异常不再出现,默认已解决。
初步结论
问题原因首先是调用者对JsonJacksonCodec的使用不恰当。
其次,JsonJacksonCodec的代码设计的真不算优秀。Encode对象内部构造了ByteBuf,而Decode对象却要求传入ByteBuf。而且,从程序设计的角度,应该提供一套更加简单实用的API,将ByteBuf的细节隐藏在背后,也就不会轻易出现ByteBuf的引用没有被释放的问题。
其他关注
- 对于ByteBuf的使用,需要更加谨慎,阅读文档中的关于Who destroys it?部分,谁最后访问了它,谁销毁它,除非1、当组件A将引用传递给另外的组件B,决定是否销毁对象的决定权在组件B,2、如果组件不再引用计数对象,则销毁它。(销毁,指引用计数归零)
- 对于ResourceLeakDetector,默认是Simple级别,意味着只会记录打印报告是否存在泄露。如果需要更加详细的报告,可以打开ADVANCED,甚至PARANOID。有更高级的采样策略,以及报告被泄露的对象最后一次访问的地址等信息。
- DISABLED 不做任何检测
-SIMPLE 采样检测,说明发生了内存泄漏 - ADVANCED 采样检测,记录上一次调用的调用栈信息
- PARANOID 偏执采样(每次获取对象时都检测一次),并记录上一次调用的调用栈信息
- DISABLED 不做任何检测
- 对于ByteBuf的分配,默认为PooledByteBufAllocator,所以分配出来的对象release后会重新放回到内存池里,并且采用了线程副本的技术方案保证了内存对象分配的线程安全问题。
关于引用泄漏的报告,并非是直接抛出异常,而是打印日志提醒用户排查泄漏(如果内存大量泄漏是有OOM风险的)。
对结论的进一步补充
主要补充一些关于ByteBuf的分配与销毁的逻辑。
默认是采用池化内存
类名:ByteBufUtil
String allocType = SystemPropertyUtil.get("io.netty.allocator.type", PlatformDependent.isAndroid() ? "unpooled" : "pooled");
allocType = allocType.toLowerCase(Locale.US).trim();
ByteBufAllocator alloc;
if ("unpooled".equals(allocType)) {
alloc = UnpooledByteBufAllocator.DEFAULT;
logger.debug("-Dio.netty.allocator.type: {}", allocType);
} else if ("pooled".equals(allocType)) {
alloc = PooledByteBufAllocator.DEFAULT;
logger.debug("-Dio.netty.allocator.type: {}", allocType);
} else {
alloc = PooledByteBufAllocator.DEFAULT; // 默认
logger.debug("-Dio.netty.allocator.type: pooled (unknown: {})", allocType);
}
DEFAULT_ALLOCATOR = alloc;
默认是采用直接内存(堆外内存)
类名:PlatformDependent
// We should always prefer direct buffers by default if we can use a Cleaner to release direct buffers.
DIRECT_BUFFER_PREFERRED = CLEANER != NOOP
&& !SystemPropertyUtil.getBoolean("io.netty.noPreferDirect", false);
对象分配的核心流程
AbstractByteBufAllocator#buffer() -> directBuffer()
PooledByteBufAllocator#newDirectBuffer() 分配内存
PoolArena#allocate() -> DirectArena#newByteBuf()
PooledDirectByteBuf#newInstance -> ObjectPool#get()
Recycler#get()
PooledByteBufAllocator#toLeakAwareBuffer() 检测内存泄漏
为什么会出现泄露?
对于ByteBuf而言存在两个内存销毁的能力,一套是JVM系统依靠对象可达性分析来决策对象销毁,一套是基于对象引用次数来决策对象的销毁(放回对象池)。那么就可能存在,当引用被JVM的回收机制回收时,对象引用的内存空间却没有被释放(堆外内存),最后内存泄漏积压足够多出现了OOM。
为什么不能直接依据JVM的机制来完成回收?主要还是因为大量使用堆外内存,不在JVM管控范围内,并且池化后分配的内存可以反复利用,所以当对象被JVM回收之前需要一些机制主动将堆外内存销毁。
从源代码上看,ByteBuf的最终引用端点为两个
1、一个是我们程序所分配得到的一个引用,比如buf = ByteBufAllocator.DEFAULT.buffer(jsonValue.length() * 3)
2、对象分配时,在DefaultHandler内部存在一个value的应用,而DefaultHandler的引用每次都是从线程副本中的Stack对象弹出,也就是说弹出后这个引用就无效了
所以,当以上两个对象的引用都销毁后,ByteBuf就是一个失去引用的对象,将会被JVM所回收,而回收时并不会触发回收相对应的堆外内存,以此造成堆外内存泄漏。
内存泄漏检测机制
通过ResourceLeakDetector实现内存泄漏的机制,而这套机制的核心原理则是通过JDK提供的WeakReference回收机制,以及配备的相对应的回收通知机制(ReferenceQueue)来完成,相关细节查阅如下文档。
WeakReference
ReferenceQueue
当是否存在内存泄漏检测完成后,检测结果返回一个DefaultResourceLeak对象,PooledDirectByteBuf被wrapper成了SimpleLeakAwareByteBuf或者AdvancedLeakAwareByteBuf对象。而DefaultResourceLeak继承了WeakReference,并在创建时就注册了ReferenceQueue。当SimpleLeakAwareByteBuf不可达之后,如果发生了一次GC后,DefaultResourceLeak所包含的ByteBuf对象就会被JVM回收,JVM回收后会通过ReferenceQueue完成回调通知。下一次获取ByteBuf时又会调用内存泄漏检测函数进行检测。
PS: 为何需要等到SimpleLeakAwareByteBuf不可达之后才可以被GC回收呢?DefaultResourceLeak所包含的对象其实就是WeakReference对象,正常情况下它在下一次GC就会被回收。因为ByteBuf在被wrapper成DefaultResourceLeak之后它还逃逸到SimpleLeakAwareByteBuf对象,所以它能被正常回收必须确保SimpleLeakAwareByteBuf不可达。其次,GC回调会往queue(全局静态的引用)写入一个item,所以在做内存泄漏检测时可以循环poll queue得到WeakReference对象被GC的通知。
内存检测基本流程:
release的时候做了什么?
- 清除从allLeaks集合删除DefaultResourceLeak引用(allLeaks集合存储的是当前活动状态DefaultResourceLeak)
- 修改ByteBuf引用计数,核心类为AbstractReferenceCountedByteBuf
- 释放内存,核心类为PoolArena,主要看PooledByteBuf,更合理的说法应该是回收到内存池
- 回收DefaultHandle,将对象重新push到Stack中(前文提到从本地线程副本中的Stack弹出DefaultHandle)