Netty防止内存泄漏措施
謹以此文獻給李林鋒即將新生的愛女。
1. ?背景
1.1 直播平臺內(nèi)存泄漏問題
某直播平臺,一些網(wǎng)紅的直播間在業(yè)務高峰期,會有 10W+ 的粉絲接入,如果瞬間發(fā)生大量客戶端連接掉線、或者一些客戶端網(wǎng)絡比較慢,發(fā)現(xiàn)基于 Netty 構(gòu)建的服務端內(nèi)存會飆升,發(fā)生內(nèi)存泄漏(OOM),導致直播卡頓、或者客戶端接收不到服務端推送的消息,用戶體驗受到很大影響。
1.2 問題分析
首先對 GC 數(shù)據(jù)進行分析,發(fā)現(xiàn)老年代已滿,發(fā)生多次 Full GC,耗時達 3 分多,系統(tǒng)已經(jīng)無法正常運行(示例):
Dump 內(nèi)存堆棧進行分析,發(fā)現(xiàn)大量的發(fā)送任務堆積,導致內(nèi)存溢出(示例):
通過以上分析可以看出,在直播高峰期,服務端向上萬客戶端推送消息時,發(fā)生了發(fā)送隊列積壓,引起內(nèi)存泄漏,最終導致服務端頻繁 GC,無法正常處理業(yè)務。
1.3 解決策略
服務端在進行消息發(fā)送的時候做保護,具體策略如下:
根據(jù)可接入的最大用戶數(shù)做客戶端并發(fā)接入數(shù)流控,需要根據(jù)內(nèi)存、CPU 處理能力,以及性能測試結(jié)果做綜合評估。
設(shè)置消息發(fā)送的高低水位,針對消息的平均大小、客戶端并發(fā)接入數(shù)、JVM 內(nèi)存大小進行計算,得出一個合理的高水位取值。服務端在推送消息時,對 Channel 的狀態(tài)進行判斷,如果達到高水位之后,Channel 的狀態(tài)會被 Netty 置為不可寫,此時服務端不要繼續(xù)發(fā)送消息,防止發(fā)送隊列積壓。
服務端基于上述策略優(yōu)化了代碼,內(nèi)存泄漏問題得到解決。
1.4. 總結(jié)
盡管 Netty 框架本身做了大量的可靠性設(shè)計,但是對于具體的業(yè)務場景,仍然需要用戶做針對特定領(lǐng)域和場景的可靠性設(shè)計,這樣才能提升應用的可靠性。
除了消息發(fā)送積壓導致的內(nèi)存泄漏,Netty 還有其它常見的一些內(nèi)存泄漏點,本文將針對這些可能導致內(nèi)存泄漏的功能點進行分析和總結(jié)。
?
2. ?消息收發(fā)防內(nèi)存泄漏策略
2.1. 消息接收
?2.1.1 消息讀取
Netty 的消息讀取并不存在消息隊列,但是如果消息解碼策略不當,則可能會發(fā)生內(nèi)存泄漏,主要有如下幾點:
畸形碼流攻擊:如果客戶端按照協(xié)議規(guī)范,將消息長度值故意偽造的非常大,可能會導致接收方內(nèi)存溢出。
代碼 BUG:錯誤的將消息長度字段設(shè)置或者編碼成一個非常大的值,可能會導致對方內(nèi)存溢出。
高并發(fā)場景:單個消息長度比較大,例如幾十 M 的小視頻,同時并發(fā)接入的客戶端過多,會導致所有 Channel 持有的消息接收 ByteBuf 內(nèi)存總和達到上限,發(fā)生 OOM。
避免內(nèi)存泄漏的策略如下:
無論采用哪種解碼器實現(xiàn),都對消息的最大長度做限制,當超過限制之后,拋出解碼失敗異常,用戶可以選擇忽略當前已經(jīng)讀取的消息,或者直接關(guān)閉鏈接。
以 Netty 的 DelimiterBasedFrameDecoder 代碼為例,創(chuàng)建 DelimiterBasedFrameDecoder 對象實例時,指定一個比較合理的消息最大長度限制,防止內(nèi)存溢出:
/** * Creates a new instance. * *?@param?maxFrameLength the maximum length of the decoded frame. * A {@link?TooLongFrameException} is thrown if * the length of the frame exceeds this value. *?@param?stripDelimiter whether the decoded frame should strip out the * delimiter or not *?@param?delimiter the delimiter */ public?DelimiterBasedFrameDecoder(int?maxFrameLength,?boolean?stripDelimiter, ByteBuf delimiter)?{this(maxFrameLength, stripDelimiter,?true, delimiter); }需要根據(jù)單個 Netty 服務端可以支持的最大客戶端并發(fā)連接數(shù)、消息的最大長度限制以及當前 JVM 配置的最大內(nèi)存進行計算,并結(jié)合業(yè)務場景,合理設(shè)置 maxFrameLength 的取值。
?2.1.2 ChannelHandler 的并發(fā)執(zhí)行
Netty 的 ChannelHandler 支持串行和異步并發(fā)執(zhí)行兩種策略,在將 ChannelHandler 加入到 ChannelPipeline 時,如果指定了 EventExecutorGroup,則 ChannelHandler 將由 EventExecutorGroup 中的 EventExecutor 異步執(zhí)行。這樣的好處是可以實現(xiàn) Netty I/O 線程與業(yè)務 ChannelHandler 邏輯執(zhí)行的分離,防止 ChannelHandler 中耗時業(yè)務邏輯的執(zhí)行阻塞 I/O 線程。
ChannelHandler 異步執(zhí)行的流程如下所示:
如果業(yè)務 ChannelHandler 中執(zhí)行的業(yè)務邏輯耗時較長,消息的讀取速度又比較快,很容易發(fā)生消息在 EventExecutor 中積壓的問題,如果創(chuàng)建 EventExecutor 時沒有通過 io.netty.eventexecutor.maxPendingTasks 參數(shù)指定積壓的最大消息個數(shù),則默認取值為 0x7fffffff,長時間的積壓將導致內(nèi)存溢出,相關(guān)代碼如下所示(異步執(zhí)行 ChannelHandler,將消息封裝成 Task 加入到 taskQueue 中):
public?void?execute(Runnable task)?{if?(task ==?null) {throw?new?NullPointerException("task");}boolean?inEventLoop = inEventLoop();if?(inEventLoop) {addTask(task);}?else?{startThread();addTask(task);if?(isShutdown() && removeTask(task)) {reject();}} }解決對策:對 EventExecutor 中任務隊列的容量做限制,可以通過 io.netty.eventexecutor.maxPendingTasks 參數(shù)做全局設(shè)置,也可以通過構(gòu)造方法傳參設(shè)置。結(jié)合 EventExecutorGroup 中 EventExecutor 的個數(shù)來計算 taskQueue 的個數(shù),根據(jù) taskQueue * N * 任務隊列平均大小 * maxPendingTasks < 系數(shù)K(0 < K < 1)* 總內(nèi)存的公式來進行計算和評估。
2.2. 消息發(fā)送
?2.2.1 如何防止發(fā)送隊列積壓
為了防止高并發(fā)場景下,由于對方處理慢導致自身消息積壓,除了服務端做流控之外,客戶端也需要做并發(fā)保護,防止自身發(fā)生消息積壓。
利用 Netty 提供的高低水位機制,可以實現(xiàn)客戶端更精準的流控,它的工作原理如下:
當發(fā)送隊列待發(fā)送的字節(jié)數(shù)組達到高水位上限時,對應的 Channel 就變?yōu)椴豢蓪憼顟B(tài)。由于高水位并不影響業(yè)務線程調(diào)用 write 方法并把消息加入到待發(fā)送隊列中,因此,必須要在消息發(fā)送時對 Channel 的狀態(tài)進行判斷:當?shù)竭_高水位時,Channel 的狀態(tài)被設(shè)置為不可寫,通過對 Channel 的可寫狀態(tài)進行判斷來決定是否發(fā)送消息。
在消息發(fā)送時設(shè)置高低水位并對 Channel 狀態(tài)進行判斷,相關(guān)代碼示例如下:
public?void?channelActive(final?ChannelHandlerContext ctx)?{ctx.channel().config().setWriteBufferHighWaterMark(10?\*?1024?*?1024);loadRunner =?new?Runnable() {@Overridepublic?void?run()?{try?{TimeUnit.SECONDS.sleep(30);}?catch?(InterruptedException e) {e.printStackTrace();}ByteBuf msg =?null;while?(true) {if?(ctx.channel().isWritable()) {msg = Unpooled.wrappedBuffer("Netty OOM Example".getBytes());ctx.writeAndFlush(msg);}?else?{LOG.warning("The write queue is busy : "?+ ctx.channel().unsafe().outboundBuffer().nioBufferSize());}}}};new?Thread(loadRunner,?"LoadRunner-Thread").start(); }對上述代碼做驗證,客戶端代碼中打印隊列積壓相關(guān)日志,說明基于高水位的流控機制生效,日志如下:
警告: The write queue is busy : 17
通過內(nèi)存監(jiān)控,發(fā)現(xiàn)內(nèi)存占用平穩(wěn):
在實際項目中,根據(jù)業(yè)務 QPS 規(guī)劃、客戶端處理性能、網(wǎng)絡帶寬、鏈路數(shù)、消息平均碼流大小等綜合因素計算并設(shè)置高水位(WriteBufferHighWaterMark)閾值,利用高水位做消息發(fā)送速率的流控,既可以保護自身,同時又能減輕服務端的壓力,防止服務端被壓掛。
?2.2.2 其它可能導致發(fā)送隊列積壓的因素
需要指出的是,并非只有高并發(fā)場景才會觸發(fā)消息積壓,在一些異常場景下,盡管系統(tǒng)流量不大,但仍然可能會導致消息積壓,可能的場景包括:
網(wǎng)絡瓶頸,發(fā)送速率超過網(wǎng)絡鏈接處理能力時,會導致發(fā)送隊列積壓。
對端讀取速度小于己方發(fā)送速度,導致自身 TCP 發(fā)送緩沖區(qū)滿,頻繁發(fā)生 write 0 字節(jié)時,待發(fā)送消息會在 Netty 發(fā)送隊列排隊。
當出現(xiàn)大量排隊時,很容易導致 Netty 的直接內(nèi)存泄漏,示例如下:
我們在設(shè)計系統(tǒng)時,需要根據(jù)業(yè)務的場景、所處的網(wǎng)絡環(huán)境等因素進行綜合設(shè)計,為潛在的各種故障做容錯和保護,防止因為外部因素導致自身發(fā)生內(nèi)存泄漏。
?
3. ?ByteBuf 的申請和釋放策略
3.1 ByteBuf 申請和釋放的理解誤區(qū)
有一種說法認為 Netty 框架分配的 ByteBuf 框架會自動釋放,業(yè)務不需要釋放;業(yè)務創(chuàng)建的 ByteBuf 則需要自己釋放,Netty 框架不會釋放。
事實上,這種觀點是錯誤的,即便 ByteBuf 是 Netty 創(chuàng)建的,如果使用不當仍然會發(fā)生內(nèi)存泄漏。在實際項目中如何更好的管理 ByteBuf,下面我們分四種場景進行說明。
3.2 ByteBuf 的釋放策略
?3.2.1 基于內(nèi)存池的請求 ByteBuf
這類 ByteBuf 主要包括 PooledDirectByteBuf 和 PooledHeapByteBuf,它由 Netty 的 NioEventLoop 線程在處理 Channel 的讀操作時分配,需要在業(yè)務 ChannelInboundHandler 處理完請求消息之后釋放(通常是解碼之后),它的釋放有 2 種策略:
-
策略 1:業(yè)務 ChannelInboundHandler 繼承自 SimpleChannelInboundHandler,實現(xiàn)它的抽象方法 channelRead0(ChannelHandlerContext ctx, I msg),ByteBuf 的釋放業(yè)務不用關(guān)心,由 SimpleChannelInboundHandler 負責釋放,相關(guān)代碼如下所示(SimpleChannelInboundHandler):
如果當前業(yè)務 ChannelInboundHandler 需要執(zhí)行,則調(diào)用完 channelRead0 之后執(zhí)行 ReferenceCountUtil.release(msg) 釋放當前請求消息。如果沒有匹配上需要繼續(xù)執(zhí)行后續(xù)的 ChannelInboundHandler,則不釋放當前請求消息,調(diào)用 ctx.fireChannelRead(msg) 驅(qū)動 ChannelPipeline 繼續(xù)執(zhí)行。
繼承自 SimpleChannelInboundHandler,即便業(yè)務不釋放請求 ByteBuf 對象,依然不會發(fā)生內(nèi)存泄漏,相關(guān)示例代碼如下所示:
?
對上述代碼做性能測試,發(fā)現(xiàn)內(nèi)存占用平穩(wěn),無內(nèi)存泄漏問題,驗證了之前的分析結(jié)論。
-
策略 2:在業(yè)務 ChannelInboundHandler 中調(diào)用 ctx.fireChannelRead(msg) 方法,讓請求消息繼續(xù)向后執(zhí)行,直到調(diào)用到 DefaultChannelPipeline 的內(nèi)部類 TailContext,由它來負責釋放請求消息,代碼如下所示(TailContext):
?3.2.2 基于非內(nèi)存池的請求 ByteBuf
如果業(yè)務使用非內(nèi)存池模式覆蓋 Netty 默認的內(nèi)存池模式創(chuàng)建請求 ByteBuf,例如通過如下代碼修改內(nèi)存申請策略為 Unpooled:
// 代碼省略...? .childHandler(new?ChannelInitializer<SocketChannel>() {@Overridepublic?void?initChannel(SocketChannel ch)?throws?Exception?{ChannelPipeline p = ch.pipeline(); ch.config().setAllocator(UnpooledByteBufAllocator.DEFAULT);p.addLast(new?RouterServerHandler());} });?}也需要按照內(nèi)存池的方式去釋放內(nèi)存。
?3.2.3 基于內(nèi)存池的響應 ByteBuf
只要調(diào)用了 writeAndFlush 或者 flush 方法,在消息發(fā)送完成之后都會由 Netty 框架進行內(nèi)存釋放,業(yè)務不需要主動釋放內(nèi)存。
它的工作原理如下:
調(diào)用 ctx.writeAndFlush(respMsg) 方法,當消息發(fā)送完成之后,Netty 框架會主動幫助應用來釋放內(nèi)存,內(nèi)存的釋放分為兩種場景:
-
如果是堆內(nèi)存(PooledHeapByteBuf),則將 HeapByteBuffer 轉(zhuǎn)換成 DirectByteBuffer,并釋放 PooledHeapByteBuf 到內(nèi)存池,代碼如下(AbstractNioChannel 類):
如果消息完整的被寫到 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);}? // 后續(xù)代碼省略 }對 Netty 源碼進行斷點調(diào)試,驗證上述分析:
斷點 1:在響應消息發(fā)送處打印斷點,獲取到 PooledUnsafeHeapByteBuf 實例 ID 為 1506。
斷點 2:在 HeapByteBuffer 轉(zhuǎn)換成 DirectByteBuffer 處打斷點,發(fā)現(xiàn)實例 ID 為 1506 的 PooledUnsafeHeapByteBuf 被釋放。
斷點 3:轉(zhuǎn)換之后待發(fā)送的響應消息 PooledUnsafeDirectByteBuf 實例 ID 為 1527。
斷點 4:響應消息發(fā)送完成之后,實例 ID 為 1527 的 PooledUnsafeDirectByteBuf 被釋放到內(nèi)存池。
如果是 DirectByteBuffer,則不需要轉(zhuǎn)換,當消息發(fā)送完成之后,由 ChannelOutboundBuffer 的 remove() 負責釋放。
?3.2.4 基于非內(nèi)存池的響應 ByteBuf
無論是基于內(nèi)存池還是非內(nèi)存池分配的 ByteBuf,如果是堆內(nèi)存,則將堆內(nèi)存轉(zhuǎn)換成堆外內(nèi)存,然后釋放 HeapByteBuffer,待消息發(fā)送完成之后,再釋放轉(zhuǎn)換后的 DirectByteBuf;如果是 DirectByteBuffer,則無需轉(zhuǎn)換,待消息發(fā)送完成之后釋放。因此對于需要發(fā)送的響應 ByteBuf,由業(yè)務創(chuàng)建,但是不需要業(yè)務來釋放。
?
4. ?Netty 服務端高并發(fā)保護
4.1 高并發(fā)場景下的 OOM 問題
在 RPC 調(diào)用時,如果客戶端并發(fā)連接數(shù)過多,服務端又沒有針對并發(fā)連接數(shù)的流控機制,一旦服務端處理慢,就很容易發(fā)生批量超時和斷連重連問題。
以 Netty HTTPS 服務端為例,典型的業(yè)務組網(wǎng)示例如下所示:
客戶端采用 HTTP 連接池的方式與服務端進行 RPC 調(diào)用,單個客戶端連接池上限為 200,客戶端部署了 30 個實例,而服務端只部署了 3 個實例。在業(yè)務高峰期,每個服務端需要處理 6000 個 HTTP 連接,當服務端時延增大之后,會導致客戶端批量超時,超時之后客戶端會關(guān)閉連接重新發(fā)起 connect 操作,在某個瞬間,幾千個 HTTPS 連接同時發(fā)起 SSL 握手操作,由于服務端此時也處于高負荷運行狀態(tài),就會導致部分連接 SSL 握手失敗或者超時,超時之后客戶端會繼續(xù)重連,進一步加重服務端的處理壓力,最終導致服務端來不及釋放客戶端 close 的連接,引起 NioSocketChannel 大量積壓,最終 OOM。
通過客戶端的運行日志可以看到一些 SSL 握手發(fā)生了超時,示例如下:
服務端并沒有對客戶端的連接數(shù)做限制,這會導致盡管 ESTABLISHED 狀態(tài)的連接數(shù)并不會超過 6000 上限,但是由于一些 SSL 連接握手失敗,再加上積壓在服務端的連接并沒有及時釋放,最終引起了 NioSocketChannel 的大量積壓。
4.2.Netty HTTS 并發(fā)連接數(shù)流控
在服務端增加對客戶端并發(fā)連接數(shù)的控制,原理如下所示:
基于 Netty 的 Pipeline 機制,可以對 SSL 握手成功、SSL 連接關(guān)閉做切面攔截(類似于 Spring 的 AOP 機制,但是沒采用反射機制,性能更高),通過流控切面接口,對 HTTPS 連接做計數(shù),根據(jù)計數(shù)器做流控,服務端的流控算法如下:
獲取流控閾值。
從全局上下文中獲取當前的并發(fā)連接數(shù),與流控閾值對比,如果小于流控閾值,則對當前的計數(shù)器做原子自增,允許客戶端連接接入。
如果等于或者大于流控閾值,則拋出流控異常給客戶端。
SSL 連接關(guān)閉時,獲取上下文中的并發(fā)連接數(shù),做原子自減。
在實現(xiàn)服務端流控時,需要注意如下幾點:
流控的 ChannelHandler 聲明為 @ChannelHandler.Sharable,這樣全局創(chuàng)建一個流控實例,就可以在所有的 SSL 連接中共享。
通過 userEventTriggered 方法攔截 SslHandshakeCompletionEvent 和 SslCloseCompletionEvent 事件,在 SSL 握手成功和 SSL 連接關(guān)閉時更新流控計數(shù)器。
流控并不是單針對 ESTABLISHED 狀態(tài)的 HTTP 連接,而是針對所有狀態(tài)的連接,因為客戶端關(guān)閉連接,并不意味著服務端也同時關(guān)閉了連接,只有 SslCloseCompletionEvent 事件觸發(fā)時,服務端才真正的關(guān)閉了 NioSocketChannel,GC 才會回收連接關(guān)聯(lián)的內(nèi)存。
流控 ChannelHandler 會被多個 NioEventLoop 線程調(diào)用,因此對于相關(guān)的計數(shù)器更新等操作,要保證并發(fā)安全性,避免使用全局鎖,可以通過原子類等提升性能。
?
5. ?總結(jié)
5.1. 其它的防內(nèi)存泄漏措施
?5.1.1 NioEventLoop
執(zhí)行它的 execute(Runnable task) 以及定時任務相關(guān)接口時,如果任務執(zhí)行耗時過長、任務執(zhí)行頻度過高,可能會導致任務隊列積壓,進而引起 OOM:
建議業(yè)務在使用時,對 NioEventLoop 隊列的積壓情況進行采集和告警。
?5.1.2 ?客戶端連接池
業(yè)務在初始化連接池時,如果采用每個客戶端連接對應一個 EventLoopGroup 實例的方式,即每創(chuàng)建一個客戶端連接,就會同時創(chuàng)建一個 NioEventLoop 線程來處理客戶端連接以及后續(xù)的網(wǎng)絡讀寫操作,采用的策略是典型的 1 個 TCP 連接對應一個 NIO 線程的模式。當系統(tǒng)的連接數(shù)很多、堆內(nèi)存又不足時,就會發(fā)生內(nèi)存泄漏或者線程創(chuàng)建失敗異常。問題示意如下:
優(yōu)化策略:客戶端創(chuàng)建連接池時,EventLoopGroup 可以重用,優(yōu)化之后的連接池線程模型如下所示:
5.2 內(nèi)存泄漏問題定位
?5.2.1 堆內(nèi)存泄漏
通過 jmap -dump:format=b,file=xx pid 命令 Dump 內(nèi)存堆棧,然后使用 MemoryAnalyzer 工具對內(nèi)存占用進行分析,查找內(nèi)存泄漏點,然后結(jié)合代碼進行分析,定位內(nèi)存泄漏的具體原因,示例如下所示:
?5.2.2 堆外內(nèi)存泄漏
建議策略如下:
排查下業(yè)務代碼,看使用堆外內(nèi)存的地方是否存在忘記釋放問題。
如果使用到了 Netty 的 TLS/SSL/openssl,建議到 Netty 社區(qū)查下 BUG 列表,看是否是 Netty 老版本已知的 BUG,此類 BUG 通過升級 Netty 版本可以解決。
如果上述兩個步驟排查沒有結(jié)果,則可以通過 google-perftools 工具協(xié)助進行堆外內(nèi)存分析。
?
6. ?作者簡介
李林鋒,10 年 Java NIO、平臺中間件設(shè)計和開發(fā)經(jīng)驗,精通 Netty、Mina、分布式服務框架、API Gateway、PaaS 等,《Netty 進階之路》、《分布式服務框架原理與實踐》作者。目前在華為終端應用市場負責業(yè)務微服務化、云化、全球化等相關(guān)設(shè)計和開發(fā)工作。
聯(lián)系方式:新浪微博 Nettying 微信:Nettying
Email:neu_lilinfeng@sina.com
總結(jié)
以上是生活随笔為你收集整理的Netty防止内存泄漏措施的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 我是如何拿到蚂蚁金服 offer ?
- 下一篇: 一份超详细的 Java 问题排查工具单