Netty記憶體池洩漏問題
阿新 • • 發佈:2018-11-27
Netty是Java高效能網路程式設計的明星框架,本文選自
《Netty進階之路:跟著案例學Netty》
一書,書中內容精選自1000多個一線業務實際案例,真正從原理到實踐全景式講解Netty專案實踐。
為了提升訊息接收和傳送效能,Netty針對ByteBuf的申請和釋放採用池化技術,通過PooledByteBufAllocator可以建立基於記憶體池分配的ByteBuf物件,這樣就避免了每次訊息讀寫都申請和釋放ByteBuf。由於ByteBuf涉及byte[]陣列的建立和銷燬,對於效能要求苛刻的系統而言,重用ByteBuf帶來的效能收益是非常可觀的。
記憶體池是一把雙刃劍,如果使用不當,很容易帶來記憶體洩漏和記憶體非法引用等問題,另外,除了記憶體池,Netty同時也支援非池化的ByteBuf,多種型別的ByteBuf功能存在一些差異,使用不當很容易帶來各種問題。
業務路由分發模組使用Netty作為通訊框架,負責協議訊息的接入和路由轉發,在功能測試時沒有發現問題,轉效能測試之後,執行一段時間就發現記憶體分配異常,服務端無法接收請求訊息,系統吞吐量降為0。
作為案例示例,對業務服務路由轉發程式碼進行簡化,以方便分析:
進行一段時間的效能測試之後,日誌中出現異常,程序記憶體不斷飆升,懷疑存在記憶體洩漏問題,如圖1所示。
圖1 效能測試異常日誌
圖2 業務堆記憶體監控資料
對記憶體做快照,檢視在效能壓測過程中響應訊息PooledUnsafeHeapByteBuf的例項個數,如圖3所示,響應訊息物件個數和記憶體佔用都很少,排除記憶體洩漏嫌疑。
圖3 業務堆記憶體快照
業務從記憶體池中申請了ByteBuf,但是卻沒有主動釋放它,最後也沒有發生記憶體洩漏,這究竟是什麼原因呢?通過對Netty原始碼的分析,我們破解了其中的玄機。原來呼叫ctx.writeAndFlush(respMsg)方法時,當訊息傳送完成,Netty框架會主動幫助應用釋放記憶體,記憶體的釋放分為如下兩種場景。
(1)如果是堆記憶體(PooledHeapByteBuf),則將HeapByteBuffer轉換成DirectByteBuffer,並釋放PooledHeapByteBuf到記憶體池,程式碼如下(AbstractNioChannel類):
如果訊息完整地被寫到SocketChannel中,則釋放DirectByteBuffer,程式碼如下(ChannelOutboundBuffer):
對Netty原始碼進行斷點除錯,驗證上述分析。
斷點1:在響應訊息傳送處設定斷點,獲取到的PooledUnsafeHeapByteBuf例項的ID為1506,如圖4所示。
圖4 在響應訊息傳送處設定斷點
斷點2:在HeapByteBuffer轉換成DirectByteBuffer處設定斷點,發現例項ID為1506的PooledUnsafeHeapByteBuf被釋放,如圖5所示。
圖5 在響應訊息釋放處設定斷點
斷點3:轉換之後待發送的響應訊息PooledUnsafeDirectByteBuf例項的ID為1527,如圖6所示。
圖6 在響應訊息轉換處設定斷點
斷點4:在響應訊息傳送完成後,例項ID為1527的PooledUnsafeDirectByteBuf被釋放到記憶體池中,如圖7所示。
圖7 在轉換之後的響應訊息釋放處設定斷點
(2)如果是DirectByteBuffer,則不需要轉換,在訊息傳送完成後,由ChannelOutboundBuffer的remove()負責釋放。
通過原始碼解讀、除錯及堆記憶體的監控分析,可以確認不是響應訊息沒有主動釋放導致的記憶體洩漏,需要Dump記憶體做進一步定位。
圖8 Dump應用記憶體堆疊的命令
通過MemoryAnalyzer工具對記憶體堆疊進行分析,尋找記憶體洩漏點,如圖9所示。
從圖9可以看出,記憶體洩漏點是Netty記憶體池物件PoolChunk,由於請求和響應訊息記憶體分配都來自PoolChunk,暫時還不確認是請求還是響應訊息導致的問題。進一步對程式碼進行分析,發現響應訊息使用的是堆記憶體HeapByteBuffer,請求訊息使用的是DirectByteBuffer,由於Dump出來的是堆記憶體,如果是堆記憶體洩漏,Dump出來的記憶體檔案應該包含大量的PooledHeapByteBuf,實際上並沒有,因此可以確認系統發生了堆外記憶體洩漏,即請求訊息沒有被釋放或者沒有被及時釋放導致的記憶體洩漏。
圖9 尋找記憶體洩漏點
對請求訊息的記憶體分配進行分析,發現在NioByteUnsafe的read方法中申請了記憶體,程式碼如下(NioByteUnsafe):
繼續對allocate方法進行分析,發現呼叫的是DefaultMaxMessagesRecvByteBuf- Allocator$MaxMessageHandle的allocate方法,程式碼如下(DefaultMaxMessagesRecvByteBuf- Allocator):
alloc.ioBuffer方法最終會呼叫PooledByteBufAllocator的newDirectBuffer方法建立PooledDirectByteBuf物件。
請求ByteBuf的建立分析完,繼續分析它的釋放操作,由於業務的RouterServerHandler繼承自ChannelInboundHandlerAdapter,它的channelRead(ChannelHandlerContext ctx, Object msg)方法執行完成,ChannelHandler的執行就結束了,程式碼示例如下:
通過程式碼分析發現,請求ByteBuf被Netty框架申請後竟然沒有被釋放,為了驗證分析,在業務程式碼中呼叫ReferenceCountUtil的release方法進行記憶體釋放操作,程式碼修改如下:
修改之後繼續進行壓測,發現系統執行平穩,沒有發生OOM異常。對記憶體活動物件進行排序,沒有再發現大量的PoolChunk物件,記憶體洩漏問題解決,問題修復之後的記憶體快照如圖10所示。
圖10 問題修復之後的記憶體快照
通過前面的案例分析和驗證,我們可以看出這個觀點是錯誤的。為了在實際專案中更好地管理ByteBuf,下面我們分4種場景進行說明。
策略1 業務ChannelInboundHandler繼承自SimpleChannelInboundHandler,實現它的抽象方法channelRead0(ChannelHandlerContext ctx, I msg),ByteBuf的釋放業務不用關心,由SimpleChannelInboundHandler負責釋放,相關程式碼如下(SimpleChannelInboundHandler):
如果當前業務ChannelInboundHandler需要執行,則呼叫channelRead0之後執行ReferenceCountUtil.release(msg)釋放當前請求訊息。如果沒有匹配上需要繼續執行後續的ChannelInboundHandler,則不釋放當前請求訊息,呼叫ctx.fireChannelRead(msg)驅動ChannelPipeline繼續執行。
對案例中的問題程式碼進行修改,繼承自SimpleChannelInboundHandler,即便業務不釋放請求的ByteBuf物件,依然不會發生記憶體洩漏,修改之後的程式碼如下(RouterServerHandlerV2):
對修改之後的程式碼做效能測試,發現記憶體佔用平穩,無記憶體洩漏問題,驗證了之前的分析結論。
策略2 在業務ChannelInboundHandler中呼叫ctx.fireChannelRead(msg)方法,讓請求訊息繼續向後執行,直到呼叫DefaultChannelPipeline的內部類TailContext,由它來負責釋放請求訊息,程式碼如下(TailContext):
也需要按照記憶體池的方式釋放記憶體。
本文選自 《Netty進階之路:跟著案例學Netty》 一書,作者李林鋒 ,在書中“Netty記憶體池洩漏疑雲案例”分析中,更詳細介紹了ByteBuf的申請和釋放策略,以及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所示。
圖1 效能測試異常日誌
2 響應訊息記憶體釋放玄機
對業務ByteBuf申請相關程式碼進行排查,發現響應訊息由業務執行緒建立,但是卻沒有主動釋放,因此懷疑是響應訊息沒有釋放導致的記憶體洩漏。因為響應訊息使用的是PooledHeapByteBuf,如果發生記憶體洩漏,利用堆記憶體監控就可以找到洩漏點,通過Java VisualVM工具觀察堆記憶體佔用趨勢,並沒有發現堆記憶體發生洩漏,如圖2所示。圖2 業務堆記憶體監控資料
對記憶體做快照,檢視在效能壓測過程中響應訊息PooledUnsafeHeapByteBuf的例項個數,如圖3所示,響應訊息物件個數和記憶體佔用都很少,排除記憶體洩漏嫌疑。
圖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所示。
圖4 在響應訊息傳送處設定斷點
斷點2:在HeapByteBuffer轉換成DirectByteBuffer處設定斷點,發現例項ID為1506的PooledUnsafeHeapByteBuf被釋放,如圖5所示。
圖5 在響應訊息釋放處設定斷點
斷點3:轉換之後待發送的響應訊息PooledUnsafeDirectByteBuf例項的ID為1527,如圖6所示。
圖6 在響應訊息轉換處設定斷點
斷點4:在響應訊息傳送完成後,例項ID為1527的PooledUnsafeDirectByteBuf被釋放到記憶體池中,如圖7所示。
圖7 在轉換之後的響應訊息釋放處設定斷點
(2)如果是DirectByteBuffer,則不需要轉換,在訊息傳送完成後,由ChannelOutboundBuffer的remove()負責釋放。
通過原始碼解讀、除錯及堆記憶體的監控分析,可以確認不是響應訊息沒有主動釋放導致的記憶體洩漏,需要Dump記憶體做進一步定位。
3 採集堆記憶體快照分析
執行jmap命令,Dump應用記憶體堆疊,如圖8所示。圖8 Dump應用記憶體堆疊的命令
通過MemoryAnalyzer工具對記憶體堆疊進行分析,尋找記憶體洩漏點,如圖9所示。
從圖9可以看出,記憶體洩漏點是Netty記憶體池物件PoolChunk,由於請求和響應訊息記憶體分配都來自PoolChunk,暫時還不確認是請求還是響應訊息導致的問題。進一步對程式碼進行分析,發現響應訊息使用的是堆記憶體HeapByteBuffer,請求訊息使用的是DirectByteBuffer,由於Dump出來的是堆記憶體,如果是堆記憶體洩漏,Dump出來的記憶體檔案應該包含大量的PooledHeapByteBuf,實際上並沒有,因此可以確認系統發生了堆外記憶體洩漏,即請求訊息沒有被釋放或者沒有被及時釋放導致的記憶體洩漏。
圖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所示。
圖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 記憶體池的工作原理。