JsonJacksonCodec 发生引用泄漏问题

起因

日志偶现

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 偏执采样(每次获取对象时都检测一次),并记录上一次调用的调用栈信息
  • 对于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() 检测内存泄漏
image.png

为什么会出现泄露?

对于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的通知。

内存检测基本流程:


image.png

release的时候做了什么?

  • 清除从allLeaks集合删除DefaultResourceLeak引用(allLeaks集合存储的是当前活动状态DefaultResourceLeak)
  • 修改ByteBuf引用计数,核心类为AbstractReferenceCountedByteBuf
  • 释放内存,核心类为PoolArena,主要看PooledByteBuf,更合理的说法应该是回收到内存池
  • 回收DefaultHandle,将对象重新push到Stack中(前文提到从本地线程副本中的Stack弹出DefaultHandle)
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 200,783评论 5 472
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,396评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 147,834评论 0 333
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,036评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,035评论 5 362
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,242评论 1 278
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,727评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,376评论 0 255
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,508评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,415评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,463评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,140评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,734评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,809评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,028评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,521评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,119评论 2 341

推荐阅读更多精彩内容