JsonJacksonCodec 發(fā)生引用泄漏問(wèn)題

起因

日志偶現(xiàn)

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)

日志上已經(jīng)體現(xiàn)出了錯(cuò)誤的根因:ByteBuf.release() was not called,大概意思是分配了內(nèi)存但是沒(méi)有及時(shí)釋放,詳細(xì)的信息可以參考鏈接: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) {
        // ....
    }
    // ...
}

查閱代碼很快就定位拋出異常的地方,結(jié)合上下文很快就有了猜測(cè):getAttribute()方法中的ByteBuf buf沒(méi)有及時(shí)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();
    }
}

以為解決了,發(fā)上去之后發(fā)現(xiàn)還是出現(xiàn)了,代碼指向還是沒(méi)變化,也就是說(shuō),不是因?yàn)檫@里?于是翻閱代碼,查看了JsonJacksonCodec的源代碼,才注意到codec.getValueEncoder().encode(obj)返回的是一個(gè)ByteBuf的對(duì)象,而查閱ByteBuf#toString()方法也沒(méi)有找到相關(guān)的release調(diào)用,所以說(shuō)在進(jìn)行Encode也出現(xiàn)引用泄漏。

ByteBuf byteBuf = null;
try {
    byteBuf = codec.getValueEncoder().encode(obj);
    jsonValue = byteBuf.toString(StandardCharsets.UTF_8);
} catch (Exception e) {
    // ...
} finally {
    if (byteBuf != null) {
        byteBuf.release();
    }
}

發(fā)布,異常不再出現(xiàn),默認(rèn)已解決。

初步結(jié)論

問(wèn)題原因首先是調(diào)用者對(duì)JsonJacksonCodec的使用不恰當(dāng)。

其次,JsonJacksonCodec的代碼設(shè)計(jì)的真不算優(yōu)秀。Encode對(duì)象內(nèi)部構(gòu)造了ByteBuf,而Decode對(duì)象卻要求傳入ByteBuf。而且,從程序設(shè)計(jì)的角度,應(yīng)該提供一套更加簡(jiǎn)單實(shí)用的API,將ByteBuf的細(xì)節(jié)隱藏在背后,也就不會(huì)輕易出現(xiàn)ByteBuf的引用沒(méi)有被釋放的問(wèn)題。

其他關(guān)注

  • 對(duì)于ByteBuf的使用,需要更加謹(jǐn)慎,閱讀文檔中的關(guān)于Who destroys it?部分,誰(shuí)最后訪問(wèn)了它,誰(shuí)銷毀它,除非1、當(dāng)組件A將引用傳遞給另外的組件B,決定是否銷毀對(duì)象的決定權(quán)在組件B,2、如果組件不再引用計(jì)數(shù)對(duì)象,則銷毀它。(銷毀,指引用計(jì)數(shù)歸零)
  • 對(duì)于ResourceLeakDetector,默認(rèn)是Simple級(jí)別,意味著只會(huì)記錄打印報(bào)告是否存在泄露。如果需要更加詳細(xì)的報(bào)告,可以打開(kāi)ADVANCED,甚至PARANOID。有更高級(jí)的采樣策略,以及報(bào)告被泄露的對(duì)象最后一次訪問(wèn)的地址等信息。
    • DISABLED 不做任何檢測(cè)
      -SIMPLE 采樣檢測(cè),說(shuō)明發(fā)生了內(nèi)存泄漏
    • ADVANCED 采樣檢測(cè),記錄上一次調(diào)用的調(diào)用棧信息
    • PARANOID 偏執(zhí)采樣(每次獲取對(duì)象時(shí)都檢測(cè)一次),并記錄上一次調(diào)用的調(diào)用棧信息
  • 對(duì)于ByteBuf的分配,默認(rèn)為PooledByteBufAllocator,所以分配出來(lái)的對(duì)象release后會(huì)重新放回到內(nèi)存池里,并且采用了線程副本的技術(shù)方案保證了內(nèi)存對(duì)象分配的線程安全問(wèn)題。
    關(guān)于引用泄漏的報(bào)告,并非是直接拋出異常,而是打印日志提醒用戶排查泄漏(如果內(nèi)存大量泄漏是有OOM風(fēng)險(xiǎn)的)。

對(duì)結(jié)論的進(jìn)一步補(bǔ)充

主要補(bǔ)充一些關(guān)于ByteBuf的分配與銷毀的邏輯。

默認(rèn)是采用池化內(nèi)存

類名: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; // 默認(rèn)
    logger.debug("-Dio.netty.allocator.type: pooled (unknown: {})", allocType);
}

DEFAULT_ALLOCATOR = alloc;

默認(rèn)是采用直接內(nèi)存(堆外內(nèi)存)

類名: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);

對(duì)象分配的核心流程

AbstractByteBufAllocator#buffer() -> directBuffer()

  PooledByteBufAllocator#newDirectBuffer() 分配內(nèi)存

    PoolArena#allocate() -> DirectArena#newByteBuf()

      PooledDirectByteBuf#newInstance -> ObjectPool#get()

        Recycler#get()
  PooledByteBufAllocator#toLeakAwareBuffer() 檢測(cè)內(nèi)存泄漏
image.png

為什么會(huì)出現(xiàn)泄露?

對(duì)于ByteBuf而言存在兩個(gè)內(nèi)存銷毀的能力,一套是JVM系統(tǒng)依靠對(duì)象可達(dá)性分析來(lái)決策對(duì)象銷毀,一套是基于對(duì)象引用次數(shù)來(lái)決策對(duì)象的銷毀(放回對(duì)象池)。那么就可能存在,當(dāng)引用被JVM的回收機(jī)制回收時(shí),對(duì)象引用的內(nèi)存空間卻沒(méi)有被釋放(堆外內(nèi)存),最后內(nèi)存泄漏積壓足夠多出現(xiàn)了OOM。

為什么不能直接依據(jù)JVM的機(jī)制來(lái)完成回收?主要還是因?yàn)榇罅渴褂枚淹鈨?nèi)存,不在JVM管控范圍內(nèi),并且池化后分配的內(nèi)存可以反復(fù)利用,所以當(dāng)對(duì)象被JVM回收之前需要一些機(jī)制主動(dòng)將堆外內(nèi)存銷毀。

從源代碼上看,ByteBuf的最終引用端點(diǎn)為兩個(gè)

1、一個(gè)是我們程序所分配得到的一個(gè)引用,比如buf = ByteBufAllocator.DEFAULT.buffer(jsonValue.length() * 3)

2、對(duì)象分配時(shí),在DefaultHandler內(nèi)部存在一個(gè)value的應(yīng)用,而DefaultHandler的引用每次都是從線程副本中的Stack對(duì)象彈出,也就是說(shuō)彈出后這個(gè)引用就無(wú)效了

所以,當(dāng)以上兩個(gè)對(duì)象的引用都銷毀后,ByteBuf就是一個(gè)失去引用的對(duì)象,將會(huì)被JVM所回收,而回收時(shí)并不會(huì)觸發(fā)回收相對(duì)應(yīng)的堆外內(nèi)存,以此造成堆外內(nèi)存泄漏。

內(nèi)存泄漏檢測(cè)機(jī)制

通過(guò)ResourceLeakDetector實(shí)現(xiàn)內(nèi)存泄漏的機(jī)制,而這套機(jī)制的核心原理則是通過(guò)JDK提供的WeakReference回收機(jī)制,以及配備的相對(duì)應(yīng)的回收通知機(jī)制(ReferenceQueue)來(lái)完成,相關(guān)細(xì)節(jié)查閱如下文檔。

WeakReference

ReferenceQueue

當(dāng)是否存在內(nèi)存泄漏檢測(cè)完成后,檢測(cè)結(jié)果返回一個(gè)DefaultResourceLeak對(duì)象,PooledDirectByteBuf被wrapper成了SimpleLeakAwareByteBuf或者AdvancedLeakAwareByteBuf對(duì)象。而DefaultResourceLeak繼承了WeakReference,并在創(chuàng)建時(shí)就注冊(cè)了ReferenceQueue。當(dāng)SimpleLeakAwareByteBuf不可達(dá)之后,如果發(fā)生了一次GC后,DefaultResourceLeak所包含的ByteBuf對(duì)象就會(huì)被JVM回收,JVM回收后會(huì)通過(guò)ReferenceQueue完成回調(diào)通知。下一次獲取ByteBuf時(shí)又會(huì)調(diào)用內(nèi)存泄漏檢測(cè)函數(shù)進(jìn)行檢測(cè)。

PS: 為何需要等到SimpleLeakAwareByteBuf不可達(dá)之后才可以被GC回收呢?DefaultResourceLeak所包含的對(duì)象其實(shí)就是WeakReference對(duì)象,正常情況下它在下一次GC就會(huì)被回收。因?yàn)锽yteBuf在被wrapper成DefaultResourceLeak之后它還逃逸到SimpleLeakAwareByteBuf對(duì)象,所以它能被正?;厥毡仨毚_保SimpleLeakAwareByteBuf不可達(dá)。其次,GC回調(diào)會(huì)往queue(全局靜態(tài)的引用)寫(xiě)入一個(gè)item,所以在做內(nèi)存泄漏檢測(cè)時(shí)可以循環(huán)poll queue得到WeakReference對(duì)象被GC的通知。

內(nèi)存檢測(cè)基本流程:


image.png

release的時(shí)候做了什么?

  • 清除從allLeaks集合刪除DefaultResourceLeak引用(allLeaks集合存儲(chǔ)的是當(dāng)前活動(dòng)狀態(tài)DefaultResourceLeak)
  • 修改ByteBuf引用計(jì)數(shù),核心類為AbstractReferenceCountedByteBuf
  • 釋放內(nèi)存,核心類為PoolArena,主要看PooledByteBuf,更合理的說(shuō)法應(yīng)該是回收到內(nèi)存池
  • 回收DefaultHandle,將對(duì)象重新push到Stack中(前文提到從本地線程副本中的Stack彈出DefaultHandle)
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容