1. 程式人生 > >Netty記憶體池洩漏問題

Netty記憶體池洩漏問題

Netty是Java高效能網路程式設計的明星框架,本文選自 《Netty進階之路:跟著案例學Netty》 一書,書中內容精選自1000多個一線業務實際案例,真正從原理到實踐全景式講解Netty專案實踐。
為了提升訊息接收和傳送效能,Netty針對ByteBuf的申請和釋放採用池化技術,通過PooledByteBufAllocator可以建立基於記憶體池分配的ByteBuf物件,這樣就避免了每次訊息讀寫都申請和釋放ByteBuf。由於ByteBuf涉及byte[]陣列的建立和銷燬,對於效能要求苛刻的系統而言,重用ByteBuf帶來的效能收益是非常可觀的。
記憶體池是一把雙刃劍,如果使用不當,很容易帶來記憶體洩漏和記憶體非法引用等問題,另外,除了記憶體池,Netty同時也支援非池化的ByteBuf,多種型別的ByteBuf功能存在一些差異,使用不當很容易帶來各種問題。
業務路由分發模組使用Netty作為通訊框架,負責協議訊息的接入和路由轉發,在功能測試時沒有發現問題,轉效能測試之後,執行一段時間就發現記憶體分配異常,服務端無法接收請求訊息,系統吞吐量降為0。

1 路由轉發服務程式碼

作為案例示例,對業務服務路由轉發程式碼進行簡化,以方便分析:
public class RouterServerHandler extends ChannelInboundHandlerAdapter {
    static ExecutorService executorService = Executors.newSingleThreadExecutor();
    PooledByteBufAllocator allocator = new PooledByteBufAllocator(false);
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        ByteBuf reqMsg = (ByteBuf)msg;
        byte [] body = new byte[reqMsg.readableBytes()];
        executorService.execute(()->
        {
            //解析請求訊息,做路由轉發,程式碼省略
            //轉發成功,返回響應給客戶端
            ByteBuf respMsg = allocator.heapBuffer(body.length);
            respMsg.writeBytes(body);//作為示例,簡化處理,將請求返回
            ctx.writeAndFlush(respMsg);
        });
    }
  //後續程式碼省略
}

進行一段時間的效能測試之後,日誌中出現異常,程序記憶體不斷飆升,懷疑存在記憶體洩漏問題,如圖1所示。
b70baedf4aace72862edb5f0dc89c1eebc2d02a2
圖1 效能測試異常日誌

2 響應訊息記憶體釋放玄機

對業務ByteBuf申請相關程式碼進行排查,發現響應訊息由業務執行緒建立,但是卻沒有主動釋放,因此懷疑是響應訊息沒有釋放導致的記憶體洩漏。因為響應訊息使用的是PooledHeapByteBuf,如果發生記憶體洩漏,利用堆記憶體監控就可以找到洩漏點,通過Java VisualVM工具觀察堆記憶體佔用趨勢,並沒有發現堆記憶體發生洩漏,如圖2所示。
dfa074c007bf3d050ac40e1a4bb6a6381c6bb4c5
圖2 業務堆記憶體監控資料
對記憶體做快照,檢視在效能壓測過程中響應訊息PooledUnsafeHeapByteBuf的例項個數,如圖3所示,響應訊息物件個數和記憶體佔用都很少,排除記憶體洩漏嫌疑。
c72e6618873655504d7dbfdcffeddccff4acfb7e

圖3 業務堆記憶體快照
業務從記憶體池中申請了ByteBuf,但是卻沒有主動釋放它,最後也沒有發生記憶體洩漏,這究竟是什麼原因呢?通過對Netty原始碼的分析,我們破解了其中的玄機。原來呼叫ctx.writeAndFlush(respMsg)方法時,當訊息傳送完成,Netty框架會主動幫助應用釋放記憶體,記憶體的釋放分為如下兩種場景。
(1)如果是堆記憶體(PooledHeapByteBuf),則將HeapByteBuffer轉換成DirectByteBuffer,並釋放PooledHeapByteBuf到記憶體池,程式碼如下(AbstractNioChannel類):
protected final ByteBuf newDirectBuffer(ByteBuf buf) { 
    final int readableBytes = buf.readableBytes(); 
    if (readableBytes == 0) { 
        ReferenceCountUtil.safeRelease(buf); 
        return Unpooled.EMPTY_BUFFER; 
    } 
    final ByteBufAllocator alloc = alloc(); 
    if (alloc.isDirectBufferPooled()) { 
        ByteBuf directBuf = alloc.directBuffer(readableBytes); 
        directBuf.writeBytes(buf, buf.readerIndex(), readableBytes); 
        ReferenceCountUtil.safeRelease(buf); 
        return directBuf; 
    }    } 
 //後續程式碼省略 
} 

如果訊息完整地被寫到SocketChannel中,則釋放DirectByteBuffer,程式碼如下(ChannelOutboundBuffer):
public boolean remove() { 
    Entry e = flushedEntry; 
    if (e == null) { 
        clearNioBuffers(); 
        return false; 
    } 
    Object msg = e.msg; 
    ChannelPromise promise = e.promise; 
    int size = e.pendingSize; 
    removeEntry(e); 
    if (!e.cancelled) { 
        ReferenceCountUtil.safeRelease(msg); 
        safeSuccess(promise); 
        decrementPendingOutboundBytes(size, false, true); 
    } 
 //後續程式碼省略 
} 

對Netty原始碼進行斷點除錯,驗證上述分析。
斷點1:在響應訊息傳送處設定斷點,獲取到的PooledUnsafeHeapByteBuf例項的ID為1506,如圖4所示。
c4f4f4f6b7cd924e9424f9d80e8622fa22b2f1fd
圖4 在響應訊息傳送處設定斷點
斷點2:在HeapByteBuffer轉換成DirectByteBuffer處設定斷點,發現例項ID為1506的PooledUnsafeHeapByteBuf被釋放,如圖5所示。
ceac649f8b4e64df1f61b5079e2259b711c7e715
圖5 在響應訊息釋放處設定斷點
斷點3:轉換之後待發送的響應訊息PooledUnsafeDirectByteBuf例項的ID為1527,如圖6所示。
e89dd1b5a7ec4c887708db5245b4bc16aec6aded
圖6 在響應訊息轉換處設定斷點
斷點4:在響應訊息傳送完成後,例項ID為1527的PooledUnsafeDirectByteBuf被釋放到記憶體池中,如圖7所示。
1d38ce4f3683a298cc43cb9b026a13d8f56f43ac
圖7 在轉換之後的響應訊息釋放處設定斷點
(2)如果是DirectByteBuffer,則不需要轉換,在訊息傳送完成後,由ChannelOutboundBuffer的remove()負責釋放。
通過原始碼解讀、除錯及堆記憶體的監控分析,可以確認不是響應訊息沒有主動釋放導致的記憶體洩漏,需要Dump記憶體做進一步定位。

3 採集堆記憶體快照分析

執行jmap命令,Dump應用記憶體堆疊,如圖8所示。
42992e82f00806d50010574a5fa59f2fe299cc7f
圖8 Dump應用記憶體堆疊的命令
通過MemoryAnalyzer工具對記憶體堆疊進行分析,尋找記憶體洩漏點,如圖9所示。
從圖9可以看出,記憶體洩漏點是Netty記憶體池物件PoolChunk,由於請求和響應訊息記憶體分配都來自PoolChunk,暫時還不確認是請求還是響應訊息導致的問題。進一步對程式碼進行分析,發現響應訊息使用的是堆記憶體HeapByteBuffer,請求訊息使用的是DirectByteBuffer,由於Dump出來的是堆記憶體,如果是堆記憶體洩漏,Dump出來的記憶體檔案應該包含大量的PooledHeapByteBuf,實際上並沒有,因此可以確認系統發生了堆外記憶體洩漏,即請求訊息沒有被釋放或者沒有被及時釋放導致的記憶體洩漏。 b798d3aa9df79c1ca7b2dcc2d49f0820d01747e7
圖9 尋找記憶體洩漏點
對請求訊息的記憶體分配進行分析,發現在NioByteUnsafe的read方法中申請了記憶體,程式碼如下(NioByteUnsafe):
public final void read() { 
    final ChannelConfig config = config(); 
    if (shouldBreakReadReady(config)) { 
        clearReadPending(); 
        return; 
    } 
    final ChannelPipeline pipeline = pipeline(); 
    final ByteBufAllocator allocator = config.getAllocator(); 
    final RecvByteBufAllocator.Handle allocHandle = recvBufAllocHandle(); 
    allocHandle.reset(config); 
    ByteBuf byteBuf = null; 
    boolean close = false; 
    try { 
        do { 
            byteBuf = allocHandle.allocate(allocator); 
//程式碼省略 

繼續對allocate方法進行分析,發現呼叫的是DefaultMaxMessagesRecvByteBuf- Allocator$MaxMessageHandle的allocate方法,程式碼如下(DefaultMaxMessagesRecvByteBuf- Allocator):
public ByteBuf allocate(ByteBufAllocator alloc) { 
    return alloc.ioBuffer(guess()); 
} 

alloc.ioBuffer方法最終會呼叫PooledByteBufAllocator的newDirectBuffer方法建立PooledDirectByteBuf物件。
請求ByteBuf的建立分析完,繼續分析它的釋放操作,由於業務的RouterServerHandler繼承自ChannelInboundHandlerAdapter,它的channelRead(ChannelHandlerContext ctx, Object msg)方法執行完成,ChannelHandler的執行就結束了,程式碼示例如下:
@Override 
public void channelRead(ChannelHandlerContext ctx, Object msg) { 
    ByteBuf reqMsg = (ByteBuf)msg; 
    byte [] body = new byte[reqMsg.readableBytes()]; 
    executorService.execute(()-> 
    { 
        //解析請求訊息,做路由轉發,程式碼省略 
        //轉發成功,返回響應給客戶端 
        ByteBuf respMsg = allocator.heapBuffer(body.length); 
        respMsg.writeBytes(body);//作為示例,簡化處理,將請求返回 
        ctx.writeAndFlush(respMsg); 
    }); 
//後續程式碼省略 

通過程式碼分析發現,請求ByteBuf被Netty框架申請後竟然沒有被釋放,為了驗證分析,在業務程式碼中呼叫ReferenceCountUtil的release方法進行記憶體釋放操作,程式碼修改如下:
@Override 
public void channelRead(ChannelHandlerContext ctx, Object msg) { 
    ByteBuf reqMsg = (ByteBuf)msg;byte [] body = new byte[reqMsg.readableBytes()]; 
    ReferenceCountUtil.release(reqMsg); 
//後續程式碼省略 

修改之後繼續進行壓測,發現系統執行平穩,沒有發生OOM異常。對記憶體活動物件進行排序,沒有再發現大量的PoolChunk物件,記憶體洩漏問題解決,問題修復之後的記憶體快照如圖10所示。 455c094ad09af9be568ba1412dd4af53c34fd231
圖10 問題修復之後的記憶體快照

4 ByteBuf申請和釋放的理解誤區

有一種說法認為Netty框架分配的ByteBuf框架會自動釋放,業務不需要釋放;業務建立的ByteBuf則需要自己釋放,Netty框架不會釋放。
通過前面的案例分析和驗證,我們可以看出這個觀點是錯誤的。為了在實際專案中更好地管理ByteBuf,下面我們分4種場景進行說明。

1.基於記憶體池的請求ByteBuf

這類ByteBuf主要包括PooledDirectByteBuf和PooledHeapByteBuf,它由Netty的NioEventLoop執行緒在處理Channel的讀操作時分配,需要在業務ChannelInboundHandler處理完請求訊息之後釋放(通常在解碼之後),它的釋放有兩種策略。
策略1 業務ChannelInboundHandler繼承自SimpleChannelInboundHandler,實現它的抽象方法channelRead0(ChannelHandlerContext ctx, I msg),ByteBuf的釋放業務不用關心,由SimpleChannelInboundHandler負責釋放,相關程式碼如下(SimpleChannelInboundHandler):
@Override 
public void channelRead(ChannelHandlerContext ctx, Object msg) throws
Exception { 
    boolean release = true; 
    try { 
        if (acceptInboundMessage(msg)) { 
            I imsg = (I) msg; 
            channelRead0(ctx, imsg); 
        } else { 
            release = false; 
            ctx.fireChannelRead(msg); 
        } 
    } finally { 
        if (autoRelease && release) { 
            ReferenceCountUtil.release(msg); 
        } 
    } 
} 

如果當前業務ChannelInboundHandler需要執行,則呼叫channelRead0之後執行ReferenceCountUtil.release(msg)釋放當前請求訊息。如果沒有匹配上需要繼續執行後續的ChannelInboundHandler,則不釋放當前請求訊息,呼叫ctx.fireChannelRead(msg)驅動ChannelPipeline繼續執行。
對案例中的問題程式碼進行修改,繼承自SimpleChannelInboundHandler,即便業務不釋放請求的ByteBuf物件,依然不會發生記憶體洩漏,修改之後的程式碼如下(RouterServerHandlerV2):
public class RouterServerHandlerV2 extends SimpleChannelInboundHandler <ByteBuf> { 
    //程式碼省略 
    @Override 
    public void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) { 
        byte [] body = new byte[msg.readableBytes()]; 
        executorService.execute(()-> 
        { 
            //解析請求訊息,做路由轉發,程式碼省略
            //轉發成功,返回響應給客戶端 
            ByteBuf respMsg = allocator.heapBuffer(body.length); 
            respMsg.writeBytes(body);//作為示例,簡化處理,將請求返回 
            ctx.writeAndFlush(respMsg); 
        }); 
   } 

對修改之後的程式碼做效能測試,發現記憶體佔用平穩,無記憶體洩漏問題,驗證了之前的分析結論。
策略2 在業務ChannelInboundHandler中呼叫ctx.fireChannelRead(msg)方法,讓請求訊息繼續向後執行,直到呼叫DefaultChannelPipeline的內部類TailContext,由它來負責釋放請求訊息,程式碼如下(TailContext):
protected void onUnhandledInboundMessage(Object msg) { 
    try { 
        logger.debug( 
        "Discarded inbound message {} that reached at the tail of the pipeline. " + 
        "Please check your pipeline configuration.", msg); 
    } finally { 
        ReferenceCountUtil.release(msg); 
    } 
} 

2.基於非記憶體池的請求ByteBuf

如果業務使用非記憶體池模式覆蓋Netty預設的記憶體池模式建立請求ByteBuf,例如通過如下程式碼修改記憶體申請策略為Unpooled:
//程式碼省略 
.childHandler(new ChannelInitializer<SocketChannel>() { 
    @Override 
    public void initChannel(SocketChannel ch) throws Exception { 
        ChannelPipeline p = ch.pipeline(); 
        ch.config().setAllocator(UnpooledByteBufAllocator.DEFAULT);
        p.addLast(new RouterServerHandler()); 
    } 
 }); 
} 

也需要按照記憶體池的方式釋放記憶體。

3.基於記憶體池的響應ByteBuf

根據之前的分析,只要呼叫了writeAndFlush或者flush方法,在訊息傳送完成後都會由Netty框架進行記憶體釋放,業務不需要主動釋放記憶體。

4.基於非記憶體池的響應ByteBuf

無論是基於記憶體池還是非記憶體池分配的ByteBuf,如果是堆記憶體,則將堆記憶體轉換成堆外記憶體,然後釋放HeapByteBuffer,待訊息傳送完成,再釋放轉換後的DirectByteBuf;如果是DirectByteBuffer,則不需要轉換,待訊息傳送完成之後釋放。因此對於需要傳送的響應ByteBuf,由業務建立,但是不需要由業務來釋放。

本文選自 Netty進階之路:跟著案例學Netty 一書,作者李林鋒 ,在書中“Netty記憶體池洩漏疑雲案例”分析中,更詳細介紹了ByteBuf的申請和釋放策略,以及Netty 記憶體池的工作原理。
8746554052f437517ec749ccfc2bc71322f0d718