Netty 防止內存泄漏措施

1. 背景

1.1 直播平臺內存泄漏問題

某直播平臺,一些網紅的直播間在業務高峯期,會有 10W+ 的粉絲接入,若是瞬間發生大量客戶端鏈接掉線、或者一些客戶端網絡比較慢,發現基於 Netty 構建的服務端內存會飆升,發生內存泄漏(OOM),致使直播卡頓、或者客戶端接收不到服務端推送的消息,用戶體驗受到很大影響。java

1.2 問題分析

首先對 GC 數據進行分析,發現老年代已滿,發生屢次 Full GC,耗時達 3 分多,系統已經沒法正常運行(示例):算法

圖 1 直播高峯期服務端 GC 統計數據

Dump 內存堆棧進行分析,發現大量的發送任務堆積,致使內存溢出(示例):數組

圖 2 直播高峯期服務端內存 Dump 文件分析

經過以上分析能夠看出,在直播高峯期,服務端向上萬客戶端推送消息時,發生了發送隊列積壓,引發內存泄漏,最終致使服務端頻繁 GC,沒法正常處理業務。promise

1.3 解決策略

服務端在進行消息發送的時候作保護,具體策略以下:安全

  1. 根據可接入的最大用戶數作客戶端併發接入數流控,須要根據內存、CPU 處理能力,以及性能測試結果作綜合評估。bash

  2. 設置消息發送的高低水位,針對消息的平均大小、客戶端併發接入數、JVM 內存大小進行計算,得出一個合理的高水位取值。服務端在推送消息時,對 Channel 的狀態進行判斷,若是達到高水位以後,Channel 的狀態會被 Netty 置爲不可寫,此時服務端不要繼續發送消息,防止發送隊列積壓。網絡

服務端基於上述策略優化了代碼,內存泄漏問題獲得解決。架構

1.4. 總結

儘管 Netty 框架自己作了大量的可靠性設計,可是對於具體的業務場景,仍然須要用戶作針對特定領域和場景的可靠性設計,這樣才能提高應用的可靠性。併發

除了消息發送積壓致使的內存泄漏,Netty 還有其它常見的一些內存泄漏點,本文將針對這些可能致使內存泄漏的功能點進行分析和總結。app

2. 消息收發防內存泄漏策略

2.1. 消息接收

2.1.1 消息讀取

Netty 的消息讀取並不存在消息隊列,可是若是消息解碼策略不當,則可能會發生內存泄漏,主要有以下幾點:

1. 畸形碼流攻擊:若是客戶端按照協議規範,將消息長度值故意僞造的很是大,可能會致使接收方內存溢出。

2. 代碼 BUG:錯誤的將消息長度字段設置或者編碼成一個很是大的值,可能會致使對方內存溢出。

3. 高併發場景:單個消息長度比較大,例如幾十 M 的小視頻,同時併發接入的客戶端過多,會致使全部 Channel 持有的消息接收 ByteBuf 內存總和達到上限,發生 OOM。

避免內存泄漏的策略以下:

  1. 不管採用哪一種解碼器實現,都對消息的最大長度作限制,當超過限制以後,拋出解碼失敗異常,用戶能夠選擇忽略當前已經讀取的消息,或者直接關閉連接。

以 Netty 的 DelimiterBasedFrameDecoder 代碼爲例,建立 DelimiterBasedFrameDecoder 對象實例時,指定一個比較合理的消息最大長度限制,防止內存溢出:

/**
{1}
* Creates a new instance.
{1}
*
{1}
*@parammaxFrameLength the maximum length of the decoded frame.
{1}
* A {@linkTooLongFrameException} is thrown if
{1}
* the length of the frame exceeds this value.
{1}
*@paramstripDelimiter whether the decoded frame should strip out the
{1}
* delimiter or not
{1}
*@paramdelimiter the delimiter
{1}
*/

publicDelimiterBasedFrameDecoder(

intmaxFrameLength,booleanstripDelimiter, ByteBuf delimiter) {

this(maxFrameLength, stripDelimiter,true, delimiter);

}
複製代碼
  1. 須要根據單個 Netty 服務端能夠支持的最大客戶端併發鏈接數、消息的最大長度限制以及當前 JVM 配置的最大內存進行計算,並結合業務場景,合理設置 maxFrameLength 的取值。

2.1.2 ChannelHandler 的併發執行

Netty 的 ChannelHandler 支持串行和異步併發執行兩種策略,在將 ChannelHandler 加入到 ChannelPipeline 時,若是指定了 EventExecutorGroup,則 ChannelHandler 將由 EventExecutorGroup 中的 EventExecutor 異步執行。這樣的好處是能夠實現 Netty I/O 線程與業務 ChannelHandler 邏輯執行的分離,防止 ChannelHandler 中耗時業務邏輯的執行阻塞 I/O 線程。

ChannelHandler 異步執行的流程以下所示:

圖 3 ChannelHandler 異步併發執行流程

若是業務 ChannelHandler 中執行的業務邏輯耗時較長,消息的讀取速度又比較快,很容易發生消息在 EventExecutor 中積壓的問題,若是建立 EventExecutor 時沒有經過 io.netty.eventexecutor.maxPendingTasks 參數指定積壓的最大消息個數,則默認取值爲 0x7fffffff,長時間的積壓將致使內存溢出,相關代碼以下所示(異步執行 ChannelHandler,將消息封裝成 Task 加入到 taskQueue 中):

public void execute(Runnable task) {

if(task==null) {

thrownewNullPointerException("task");

}

boolean inEventLoop =inEventLoop();

if(inEventLoop) {

addTask(task);

}else{

startThread();

addTask(task);

if(isShutdown()&&removeTask(task)) {

reject();

}

}
複製代碼

解決對策:對 EventExecutor 中任務隊列的容量作限制,能夠經過 io.netty.eventexecutor.maxPendingTasks 參數作全局設置,也能夠經過構造方法傳參設置。結合 EventExecutorGroup 中 EventExecutor 的個數來計算 taskQueue 的個數,根據 taskQueue * N * 任務隊列平均大小 * maxPendingTasks < 係數 K(0 < K < 1)* 總內存的公式來進行計算和評估。

2.2. 消息發送

2.2.1 如何防止發送隊列積壓

爲了防止高併發場景下,因爲對方處理慢致使自身消息積壓,除了服務端作流控以外,客戶端也須要作併發保護,防止自身發生消息積壓。

利用 Netty 提供的高低水位機制,能夠實現客戶端更精準的流控,它的工做原理以下:

圖 4 Netty 高水位接口說明

當發送隊列待發送的字節數組達到高水位上限時,對應的 Channel 就變爲不可寫狀態。因爲高水位並不影響業務線程調用 write 方法並把消息加入到待發送隊列中,所以,必需要在消息發送時對 Channel 的狀態進行判斷:當到達高水位時,Channel 的狀態被設置爲不可寫,經過對 Channel 的可寫狀態進行判斷來決定是否發送消息。

在消息發送時設置高低水位並對 Channel 狀態進行判斷,相關代碼示例以下:

public void channelActive(finalChannelHandlerContextctx){

**ctx.channel().config().setWriteBufferHighWaterMark(10 \*1024*1024);**

loadRunner =newRunnable(){

@Override

public 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());

}

}

}

};

newThread(loadRunner,"LoadRunner-Thread").start();

}
複製代碼

對上述代碼作驗證,客戶端代碼中打印隊列積壓相關日誌,說明基於高水位的流控機制生效,日誌以下:

警告: The write queue is busy : 17

經過內存監控,發現內存佔用平穩:

圖 5 進行高低水位保護優化以後內存佔用狀況

在實際項目中,根據業務 QPS 規劃、客戶端處理性能、網絡帶寬、鏈路數、消息平均碼流大小等綜合因素計算並設置高水位(WriteBufferHighWaterMark)閾值,利用高水位作消息發送速率的流控,既能夠保護自身,同時又能減輕服務端的壓力,防止服務端被壓掛。

2.2.2 其它可能致使發送隊列積壓的因素

須要指出的是,並不是只有高併發場景纔會觸發消息積壓,在一些異常場景下,儘管系統流量不大,但仍然可能會致使消息積壓,可能的場景包括:

  1. 網絡瓶頸,發送速率超過網絡連接處理能力時,會致使發送隊列積壓。

  2. 對端讀取速度小於己方發送速度,致使自身 TCP 發送緩衝區滿,頻繁發生 write 0 字節時,待發送消息會在 Netty 發送隊列排隊。

當出現大量排隊時,很容易致使 Netty 的直接內存泄漏,示例以下:

圖 6 消息積壓致使內存泄漏相關堆棧

咱們在設計系統時,須要根據業務的場景、所處的網絡環境等因素進行綜合設計,爲潛在的各類故障作容錯和保護,防止由於外部因素致使自身發生內存泄漏。

3. ByteBuf 的申請和釋放策略

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

有一種說法認爲 Netty 框架分配的 ByteBuf 框架會自動釋放,業務不須要釋放;業務建立的 ByteBuf 則須要本身釋放,Netty 框架不會釋放。

事實上,這種觀點是錯誤的,即使 ByteBuf 是 Netty 建立的,若是使用不當仍然會發生內存泄漏。在實際項目中如何更好的管理 ByteBuf,下面咱們分四種場景進行說明。

3.2 ByteBuf 的釋放策略

3.2.1 基於內存池的請求 ByteBuf

這類 ByteBuf 主要包括 PooledDirectByteBuf 和 PooledHeapByteBuf,它由 Netty 的 NioEventLoop 線程在處理 Channel 的讀操做時分配,須要在業務 ChannelInboundHandler 處理完請求消息以後釋放(一般是解碼以後),它的釋放有 2 種策略:

  1. 策略 1:業務 ChannelInboundHandler 繼承自 SimpleChannelInboundHandler,實現它的抽象方法 channelRead0(ChannelHandlerContext ctx, I msg),ByteBuf 的釋放業務不用關心,由 SimpleChannelInboundHandler 負責釋放,相關代碼以下所示(SimpleChannelInboundHandler):
@Override

public void channelRead(ChannelHandlerContextctx, Objectmsg)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 對象,依然不會發生內存泄漏,相關示例代碼以下所示:

publicclassRouterServerHandlerV2**extendsSimpleChannelInboundHandler<ByteBuf>**{

// 代碼省略...

@Override

publicvoidchannelRead0(ChannelHandlerContext ctx, ByteBuf msg){

byte[] body =newbyte[msg.readableBytes()];

executorService.execute(()->

{

// 解析請求消息,作路由轉發,代碼省略...

// 轉發成功,返回響應給客戶端

ByteBuf respMsg = allocator.heapBuffer(body.length);

respMsg.writeBytes(body);// 做爲示例,簡化處理,將請求返回

ctx.writeAndFlush(respMsg);

});

}
複製代碼

對上述代碼作性能測試,發現內存佔用平穩,無內存泄漏問題,驗證了以前的分析結論。

  1. 策略 2:在業務 ChannelInboundHandler 中調用 ctx.fireChannelRead(msg) 方法,讓請求消息繼續向後執行,直到調用到 DefaultChannelPipeline 的內部類 TailContext,由它來負責釋放請求消息,代碼以下所示(TailContext):
protectedvoidonUnhandledInboundMessage(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);**

**}**

}
複製代碼

3.2.2 基於非內存池的請求 ByteBuf

若是業務使用非內存池模式覆蓋 Netty 默認的內存池模式建立請求 ByteBuf,例如經過以下代碼修改內存申請策略爲 Unpooled:

// 代碼省略...

.childHandler(newChannelInitializer<SocketChannel>() {

@Override

publicvoidinitChannel(SocketChannel ch)throwsException{

ChannelPipeline p = ch.pipeline(); ch.config().setAllocator(UnpooledByteBufAllocator.DEFAULT);

p.addLast(newRouterServerHandler());

}

});

}
複製代碼

也須要按照內存池的方式去釋放內存。

3.2.3 基於內存池的響應 ByteBuf

只要調用了 writeAndFlush 或者 flush 方法,在消息發送完成以後都會由 Netty 框架進行內存釋放,業務不須要主動釋放內存。

它的工做原理以下:

調用 ctx.writeAndFlush(respMsg) 方法,當消息發送完成以後,Netty 框架會主動幫助應用來釋放內存,內存的釋放分爲兩種場景:

  1. 若是是堆內存(PooledHeapByteBuf),則將 HeapByteBuffer 轉換成 DirectByteBuffer,並釋放 PooledHeapByteBuf 到內存池,代碼以下(AbstractNioChannel 類):


protected final ByteBufnewDirectBuffer(ByteBufbuf){

​ finalintreadableBytes = 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();

​ returnfalse;

​ }

​ Object msg = e.msg;

​ ChannelPromise promise = e.promise;

​intsize = e.pendingSize;

​ removeEntry(e);

​if(!e.cancelled) {

​ **ReferenceCountUtil.safeRelease(msg);**

​ safeSuccess(promise);

​ decrementPendingOutboundBytes(size,false,true);

​ }

// 後續代碼省略

}
複製代碼

對 Netty 源碼進行斷點調試,驗證上述分析:

斷點 1:在響應消息發送處打印斷點,獲取到 PooledUnsafeHeapByteBuf 實例 ID 爲 1506。

圖 7 響應發送處斷點調試

斷點 2:在 HeapByteBuffer 轉換成 DirectByteBuffer 處打斷點,發現實例 ID 爲 1506 的 PooledUnsafeHeapByteBuf 被釋放。

圖 8 響應消息釋放處斷點

斷點 3:轉換以後待發送的響應消息 PooledUnsafeDirectByteBuf 實例 ID 爲 1527。

圖 9 響應消息轉換處斷點

斷點 4:響應消息發送完成以後,實例 ID 爲 1527 的 PooledUnsafeDirectByteBuf 被釋放到內存池。

圖 10 轉換以後的響應消息釋放處斷點
  1. 若是是 DirectByteBuffer,則不須要轉換,當消息發送完成以後,由 ChannelOutboundBuffer 的 remove() 負責釋放。

3.2.4 基於非內存池的響應 ByteBuf

不管是基於內存池仍是非內存池分配的 ByteBuf,若是是堆內存,則將堆內存轉換成堆外內存,而後釋放 HeapByteBuffer,待消息發送完成以後,再釋放轉換後的 DirectByteBuf;若是是 DirectByteBuffer,則無需轉換,待消息發送完成以後釋放。所以對於須要發送的響應 ByteBuf,由業務建立,可是不須要業務來釋放。

4. Netty 服務端高併發保護

4.1 高併發場景下的 OOM 問題

在 RPC 調用時,若是客戶端併發鏈接數過多,服務端又沒有針對併發鏈接數的流控機制,一旦服務端處理慢,就很容易發生批量超時和斷連重連問題。

以 Netty HTTPS 服務端爲例,典型的業務組網示例以下所示:

圖 11 Netty HTTPS 組網圖

客戶端採用 HTTP 鏈接池的方式與服務端進行 RPC 調用,單個客戶端鏈接池上限爲 200,客戶端部署了 30 個實例,而服務端只部署了 3 個實例。在業務高峯期,每一個服務端須要處理 6000 個 HTTP 鏈接,當服務端時延增大以後,會致使客戶端批量超時,超時以後客戶端會關閉鏈接從新發起 connect 操做,在某個瞬間,幾千個 HTTPS 鏈接同時發起 SSL 握手操做,因爲服務端此時也處於高負荷運行狀態,就會致使部分鏈接 SSL 握手失敗或者超時,超時以後客戶端會繼續重連,進一步加劇服務端的處理壓力,最終致使服務端來不及釋放客戶端 close 的鏈接,引發 NioSocketChannel 大量積壓,最終 OOM。

經過客戶端的運行日誌能夠看到一些 SSL 握手發生了超時,示例以下:

圖 12 SSL 握手超時日誌

服務端並無對客戶端的鏈接數作限制,這會致使儘管 ESTABLISHED 狀態的鏈接數並不會超過 6000 上限,可是因爲一些 SSL 鏈接握手失敗,再加上積壓在服務端的鏈接並無及時釋放,最終引發了 NioSocketChannel 的大量積壓。

4.2.Netty HTTS 併發鏈接數流控

在服務端增長對客戶端併發鏈接數的控制,原理以下所示:

圖 13 服務端 HTTS 鏈接數流控

基於 Netty 的 Pipeline 機制,能夠對 SSL 握手成功、SSL 鏈接關閉作切面攔截(相似於 Spring 的 AOP 機制,可是沒采用反射機制,性能更高),經過流控切面接口,對 HTTPS 鏈接作計數,根據計數器作流控,服務端的流控算法以下:

  1. 獲取流控閾值。

  2. 從全局上下文中獲取當前的併發鏈接數,與流控閾值對比,若是小於流控閾值,則對當前的計數器作原子自增,容許客戶端鏈接接入。

  3. 若是等於或者大於流控閾值,則拋出流控異常給客戶端。

  4. SSL 鏈接關閉時,獲取上下文中的併發鏈接數,作原子自減。

在實現服務端流控時,須要注意以下幾點:

  1. 流控的 ChannelHandler 聲明爲 @ChannelHandler.Sharable,這樣全局建立一個流控實例,就能夠在全部的 SSL 鏈接中共享。

  2. 經過 userEventTriggered 方法攔截 SslHandshakeCompletionEvent 和 SslCloseCompletionEvent 事件,在 SSL 握手成功和 SSL 鏈接關閉時更新流控計數器。

  3. 流控並非單針對 ESTABLISHED 狀態的 HTTP 鏈接,而是針對全部狀態的鏈接,由於客戶端關閉鏈接,並不意味着服務端也同時關閉了鏈接,只有 SslCloseCompletionEvent 事件觸發時,服務端才真正的關閉了 NioSocketChannel,GC 纔會回收鏈接關聯的內存。

  4. 流控 ChannelHandler 會被多個 NioEventLoop 線程調用,所以對於相關的計數器更新等操做,要保證併發安全性,避免使用全局鎖,能夠經過原子類等提高性能。

5. 總結

5.1. 其它的防內存泄漏措施

5.1.1 NioEventLoop

執行它的 execute(Runnable task) 以及定時任務相關接口時,若是任務執行耗時過長、任務執行頻度太高,可能會致使任務隊列積壓,進而引發 OOM:

圖 14 NioEventLoop 定時任務執行接口

建議業務在使用時,對 NioEventLoop 隊列的積壓狀況進行採集和告警。

5.1.2 客戶端鏈接池

業務在初始化鏈接池時,若是採用每一個客戶端鏈接對應一個 EventLoopGroup 實例的方式,即每建立一個客戶端鏈接,就會同時建立一個 NioEventLoop 線程來處理客戶端鏈接以及後續的網絡讀寫操做,採用的策略是典型的 1 個 TCP 鏈接對應一個 NIO 線程的模式。當系統的鏈接數不少、堆內存又不足時,就會發生內存泄漏或者線程建立失敗異常。問題示意以下:

圖 15 錯誤的客戶端線程模型

優化策略:客戶端建立鏈接池時,EventLoopGroup 能夠重用,優化以後的鏈接池線程模型以下所示:

圖 16 正確的客戶端線程模型

5.2 內存泄漏問題定位

5.2.1 堆內存泄漏

經過 jmap -dump:format=b,file=xx pid 命令 Dump 內存堆棧,而後使用 MemoryAnalyzer 工具對內存佔用進行分析,查找內存泄漏點,而後結合代碼進行分析,定位內存泄漏的具體緣由,示例以下所示:

圖 17 經過 MemoryAnalyzer 工具分析內存堆棧

5.2.2 堆外內存泄漏

建議策略以下:

  1. 排查下業務代碼,看使用堆外內存的地方是否存在忘記釋放問題。

  2. 若是使用到了 Netty 的 TLS/SSL/openssl,建議到 Netty 社區查下 BUG 列表,看是不是 Netty 老版本已知的 BUG,此類 BUG 經過升級 Netty 版本能夠解決。

  3. 若是上述兩個步驟排查沒有結果,則能夠經過 google-perftools 工具協助進行堆外內存分析

歡迎學Java和大數據的朋友們加入java架構交流: 855835163
加羣連接:jq.qq.com/?_wv=1027&a…​​​​​​​羣內提供免費的架構資料還有:Java工程化、高性能及分佈式、高性能、深刻淺出。高架構。性能調優、Spring,MyBatis,Netty源碼分析和大數據等多個知識點高級進階乾貨的免費直播講解 能夠進來一塊兒學習交流哦

相關文章
相關標籤/搜索