Netty堆外內存泄漏排查,這一篇全講清楚了

上篇文章介紹了Netty內存模型原理,由於Netty使用不當會導致堆外內存泄漏,網上關於這方面的資料比較少,所以寫下這篇文章,基於Netty4.1.43.Final,專門介紹排查Netty堆外內存相關的知識點,診斷工具,以及排查思路

現象

堆外內存泄漏的現象主要是,進程佔用的內存較高(Linux下可以用top命令查看),但Java堆內存佔用並不高(jmap命令查看),常見的使用堆外內存除了Netty,還有基於java.nio下相關接口申請堆外內存,JNI調用等,下面側重介紹Netty堆外內存泄漏問題排查

堆外內存釋放底層實現

1 java.nio堆外內存釋放

Netty堆外內存是基於原生java.nio的DirectByteBuffer對象的基礎上實現的,所以有必要先了解下它的釋放原理

java.nio提供的DirectByteBuffer提供了sun.misc.Cleaner類的clean()方法,進行系統調用釋放堆外內存,觸發clean()方法的情況有2種

  • (1) 應用程序主動調用

ByteBuffer buf = ByteBuffer.allocateDirect(1);
((DirectBuffer) byteBuffer).cleaner().clean();
  • (2) 基於GC回收

Cleaner類繼承了java.lang.ref.Reference,GC線程會通過設置Reference的內部變量(pending變量爲鏈表頭部節點,discovered變量爲下一個鏈表節點),將可被回收的不可達的Reference對象以鏈表的方式組織起來

Reference的內部守護線程從鏈表的頭部(head)消費數據,如果消費到的Reference對象同時也是Cleaner類型,線程會調用clean()方法(Reference#tryHandlePending())

2 Netty noCleaner策略

介紹noCleaner策略之前,需要先理解帶有Cleaner對象的DirectByteBuffer在初始化時做了哪些事情:

只有在DirectByteBuffer(int cap)構造方法中才會初始化Cleaner對象,方法中檢查當前內存是否超過允許的最大堆外內存(可由-XX:MaxDirectMemorySize配置)

如果超出,則會先嚐試將不可達的Reference對象加入Reference鏈表中,依賴Reference的內部守護線程觸發可以被回收DirectByteBuffer關聯的Cleaner的run()方法

如果內存還是不足, 則執行 System.gc(),觸發full gc,來回收堆內存中的DirectByteBuffer對象來觸發堆外內存回收,如果還是超過限制,則拋出java.lang.OutOfMemoryError(代碼位於java.nio.Bits#reserveMemory()方法)

而Netty在4.1引入可以noCleaner策略:創建不帶Cleaner的DirectByteBuffer對象,這樣做的好處是繞開帶Cleaner的DirectByteBuffer執行構造方法和執行Cleaner的clean()方法中一些額外開銷,當堆外內存不夠的時候,不會觸發System.gc(),提高性能

hasCleaner的DirectByteBuffer和noCleaner的DirectByteBuffer主要區別如下:

  • 構造器方式不同:noCleaner對象:由反射調用 private DirectByteBuffer(long addr, int cap)創建 hasCleaner對象:由 new DirectByteBuffer(int cap)創建

  • 釋放內存的方式不同 noCleaner對象:使用 UnSafe.freeMemory(address); hasCleaner對象:使用 DirectByteBuffer 的 Cleaner 的 clean() 方法

note:Unsafe是位於sun.misc包下的一個類,可以提供內存操作、對象操作、線程調度等本地方法,這些方法在提升Java運行效率、增強Java語言底層資源操作能力方面起到了很大的作用,但不正確使用Unsafe類會使得程序出錯的概率變大,程序不再“安全”,因此官方不推薦使用,並可能在未來的jdk版本移除

Netty在啓動時需要判斷檢查當前環境、環境配置參數是否允許noCleaner策略(具體邏輯位於PlatformDependent的static代碼塊),例如運行在Android下時,是沒有Unsafe類的,不允許使用noCleaner策略,如果不允許,則使用hasCleaner策略

note:可以通過調用Netty的PlatformDependent.useDirectBufferNoCleaner()方法查看當前Netty程序是否使用noCleaner策略

ByteBuf.release()的觸發

業界有一種誤解認爲 Netty 框架分配的 ByteBuf,框架會自動釋放,業務不需要釋放;業務創建的 ByteBuf 則需要自己釋放,Netty 框架不會釋放

產生這種誤解是有原因的,Netty框架是會在一些場景調用ByteBuf.release()方法:

1 入站消息處理

當處理入站消息時,Netty會創建ByteBuf讀取channel上的消息,並觸發調用pipeline上的ChannelHandler處理,應用程序定義的使用ByteBuf的ChannelHandler需要負責release()

public void channelRead(ChannelHandlerContext ctx, Object msg) {
  ByteBuf buf = (ByteBuf) msg;
  try {
    ...
  } finally {
    buf.release();
  }
}

如果該ByteBuf不由當前ChannelHandler處理,則傳遞給pipeline上下一個handler:

public void channelRead(ChannelHandlerContext ctx, Object msg) {
  ByteBuf buf = (ByteBuf) msg;
  ...
  ctx.fireChannelRead(buf);
}

常用的我們會通過繼承ChannelInboundHandlerAdapter定義入站消息處理的handler,這種情況下如果所有程序的hanler都沒有調用release()方法,該入站消息Netty最後並不會release(),會導致內存泄漏

當在pipeline的handler處理中拋出異常之後,最後Netty框架是會捕捉該異常進行ByteBuf.release()的;完整流程位於AbstractNioByteChannel.NioByteUnsafe#read(),下面抽取關鍵片段:

try {
  do {
    byteBuf = allocHandle.allocate(allocator);
    allocHandle.lastBytesRead(doReadBytes(byteBuf));
    // 入站消息已讀完
    if (allocHandle.lastBytesRead() <= 0) {
      // ...
      break;
    }
    // 觸發pipline上handler進行處理
    pipeline.fireChannelRead(byteBuf);
    byteBuf = null;
  } while (allocHandle.continueReading());
  // ...
} catch (Throwable t) {
  // 異常處理中包括調用 byteBuf.release()
  handleReadException(pipeline, byteBuf, t, close, allocHandle);
}

不過,常用的還有通過繼承SimpleChannelInboundHandler定義入站消息處理,在該類會保證消息最終被release:

@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
  boolean release = true;
  try {
    // 該消息由當前handler處理
    if (acceptInboundMessage(msg)) {
      I imsg = (I) msg;
      channelRead0(ctx, imsg);
    } else {
      // 不由當前handler處理,傳遞給pipeline上下一個handler
      release = false;
      ctx.fireChannelRead(msg);
    }
  } finally {
    // 觸發release
    if (autoRelease && release) {
      ReferenceCountUtil.release(msg);
    }
  }
}

2 出站消息處理

不同於入站消息是由Netty框架自動創建的,出站消息通常由應用程序創建,然後調用基於channel的write()方法或writeAndFlush()方法,這些方法內部會負責調用傳入的byteBuf的release()方法

note: write()方法在netty-4.0.0.CR2前的版本存在問題,不會調用ByteBuf.release()

3 release()注意事項

  • (1) 引用計數

還有一種常見的誤解就是,只要調用了ByteBuf的release()方法,或者調用ReferenceCountUtil.release()方法,對象的內存就保證釋放了,其實不是

因爲Netty的ByteBuf引用計數來管理ByteBuf對象的生命週期,ByteBuf繼承了ReferenceCounted接口,對外提供retain()和release()方法,用於增加或減少引用計數值,當調用release()方法時,內部計數值被減爲0纔會觸發內存回收動作

  • (2) derived ByteBuf

derived,派生的意思,ByteBuf.duplicate(), ByteBuf.slice() 和 ByteBuf.order(ByteOrder) 等方法會創建出derived  ByteBuf,創建出來的ByteBuf與原有ByteBuf是共享引用計數的,原有ByteBuf的release()方法調用,也會導致這些對象內存回收

相反ByteBuf.copy() 和 ByteBuf.readBytes(int)方法創建出來的對象並不是derived ByteBuf,這些對象與原有ByteBuf不是共享引用計數的,原有ByteBuf的release()方法調用不會導致這些對象內存回收

堆外內存大小控制參數

配置堆外內存大小的參數有JVM的-XX:MaxDirectMemorySize和Netty的-Dio.netty.maxDirectMemory,這2個參數有什麼區別?

  • -XX:MaxDirectMemorySize

    用於限制Netty中hasCleaner策略的DirectByteBuffer堆外內存的大小,默認值是JVM能從操作系統申請的最大內存,如果內存本身沒限制,則值爲Long.MAX_VALUE個字節(默認值由Runtime.getRuntime().maxMemory()返回),代碼位於java.nio.Bits#reserveMemory()方法中

note:-XX:MaxDirectMemorySize無法限制Netty中noCleaner策略的DirectByteBuffer堆外內存的大小

  • -Dio.netty.maxDirectMemory

    用於限制noCleaner策略下Netty的DirectByteBuffer分配的最大堆外內存的大小,如果該值爲0,則使用hasCleaner策略,代碼位於PlatformDependent#incrementMemoryCounter()方法中

堆外內存監控

如何獲取堆外內存的使用情況?

1 代碼工具

  • (1) hasCleaner的DirectByteBuffer監控

    對於hasCleaner策略的DirectByteBuffer,java.nio.Bits類是有記錄堆外內存的使用情況,但是該類是包級別的訪問權限,不能直接獲取,可以通過MXBean來獲取

note:MXBean,Java提供的一系列用於監控統計的特殊Bean,通過不同類型的MXBean可以獲取JVM進程的內存,線程、類加載信息等監控指標

List<BufferPoolMXBean> bufferPoolMXBeans = ManagementFactoryHelper.getBufferPoolMXBeans();
BufferPoolMXBean directBufferMXBean = bufferPoolMXBeans.get(0);
// hasCleaner的DirectBuffer的數量
long count = directBufferMXBean.getCount();
// hasCleaner的DirectBuffer的堆外內存佔用大小,單位字節
long memoryUsed = directBufferMXBean.getMemoryUsed();

note: MappedByteBuffer:是基於FileChannelImpl.map進行進行mmap內存映射(零拷貝的一種實現)得到的另外一種堆外內存的ByteBuffer,可以通過ManagementFactoryHelper.getBufferPoolMXBeans().get(1)獲取到該堆外內存的監控指標

  • (2) noCleaner的DirectByteBuffer監控

    Netty中noCleaner的DirectByteBuffer的監控比較簡單,直接通過PlatformDependent.usedDirectMemory()訪問即可

2 Netty自帶內存泄漏檢測工具

Netty也自帶了內存泄漏檢測工具,可用於檢測出ByteBuf對象被GC回收,但ByteBuf管理的內存沒有釋放的情況,但不適用ByteBuf對象還沒被GC回收內存泄漏的情況,例如任務隊列積壓

爲了便於用戶發現內存泄露,Netty提供4個檢測級別:

  • disabled 完全關閉內存泄露檢測

  • simple  以約1%的抽樣率檢測是否泄露,默認級別

  • advanced  抽樣率同simple,但顯示詳細的泄露報告

  • paranoid 抽樣率爲100%,顯示報告信息同advanced

使用方法是在命令行參數設置:

-Dio.netty.leakDetectionLevel=[檢測級別]

示例程序如下,設置檢測級別爲paranoid :

// -Dio.netty.leakDetectionLevel=paranoid
public static void main(String[] args) {
  for (int i = 0; i < 500000; ++i) {
  ByteBuf byteBuf = UnpooledByteBufAllocator.DEFAULT.buffer(1024);
    byteBuf = null;	
  }
  System.gc();
}

可以看到控制檯輸出泄漏報告:

十二月 27, 2019 8:37:04 上午 io.netty.util.ResourceLeakDetector reportTracedLeak
嚴重: 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.UnpooledByteBufAllocator.newDirectBuffer(UnpooledByteBufAllocator.java:96)
    io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:187)
    io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:178)
    io.netty.buffer.AbstractByteBufAllocator.buffer(AbstractByteBufAllocator.java:115)
    org.caison.netty.demo.memory.BufferLeaksDemo.main(BufferLeaksDemo.java:15)

內存泄漏的原理是利用弱引用,弱引用(WeakReference)創建時需要指定引用隊列(refQueue),通過將ByteBuf對象用弱引用包裝起來(代碼入口位於AbstractByteBufAllocator#toLeakAwareBuffer()方法)

當發生GC時,如果GC線程檢測到ByteBuf對象只被弱引用對象關聯,會將該WeakReference加入refQueue

當ByteBuf內存被正常釋放,會調用WeakReference的clear()方法解除對ByteBuf的引用,後續GC線程不會再將該WeakReference加入refQueue;

Netty在每次創建ByteBuf時,基於抽樣率,抽樣命中時會輪詢(poll)refQueue中的WeakReference對象,輪詢返回的非null的WeakReference關聯的ByteBuf即爲泄漏的堆外內存(具體代碼實現入口位於ResourceLeakDetector#track()方法)

3 圖形化工具

在代碼獲取堆外內存的基礎上,通過自定義接入一些監控工具定時檢測獲取,繪製圖形即可,例如比較流行的Prometheus或者Zabbix

也可以通過jdk自帶的Visualvm獲取,需要安裝Buffer Pools插件,底層原理是訪問MXBean中的監控指標,只能獲取hasCleaner的DirectByteBuffer的使用情況

此外,對於JNI調用產生的堆外內存分配,可以使用google-perftools進行監控

堆外內存泄漏診斷

堆外內存泄漏的具體原因比較多,先介紹任務隊列堆積的監控,再介紹通用堆外內存泄漏診斷思路

1 任務隊列堆積

這裏的任務隊列是值NioEventLoop中的QueuetaskQueue,提交到該任務隊列的場景有:

  • (1) 用戶自定義普通任務

ctx.channel().eventLoop().execute(runnable);
  • (2) 對channel進行寫入

channel.write(...)
channel.writeAndFlush(...)
  • (3)  用戶自定義定時任務

ctx.channel().eventLoop().schedule(runnable, 60, TimeUnit.SECONDS);

當隊列中積壓任務過多,導致消息不能對對channel進行寫入然後進行釋放,會導致內存泄漏

診斷思路是對任務隊列中的任務數、積壓的ByteBuf大小、任務類信息進行監控,具體監控程序如下(代碼地址 https://github.com/caison/caison-blog-demo/tree/master/netty-demo):

public void channelActive(ChannelHandlerContext ctx) throws NoSuchFieldException, IllegalAccessException {
  monitorPendingTaskCount(ctx);
  monitorQueueFirstTask(ctx);
  monitorOutboundBufSize(ctx);
}
/** 監控任務隊列堆積任務數,任務隊列中的任務包括io讀寫任務,業務程序提交任務 */
public void monitorPendingTaskCount(ChannelHandlerContext ctx) {
  int totalPendingSize = 0;
  for (EventExecutor eventExecutor : ctx.executor().parent()) {
    SingleThreadEventExecutor executor = (SingleThreadEventExecutor) eventExecutor;
    // 注意,Netty4.1.29以下版本本pendingTasks()方法存在bug,導致線程阻塞問題
    // 參考 https://github.com/netty/netty/issues/8196
    totalPendingSize += executor.pendingTasks();
  }
  System.out.println("任務隊列中總任務數 = " + totalPendingSize);
}
/** 監控各個堆積的任務隊列中第一個任務的類信息 */
public void monitorQueueFirstTask(ChannelHandlerContext ctx) throws NoSuchFieldException, IllegalAccessException {
  Field singleThreadField = SingleThreadEventExecutor.class.getDeclaredField("taskQueue");
  singleThreadField.setAccessible(true);
  for (EventExecutor eventExecutor : ctx.executor().parent()) {
    SingleThreadEventExecutor executor = (SingleThreadEventExecutor) eventExecutor;
    Runnable task = ((Queue<Runnable>) singleThreadField.get(executor)).peek();
    if (null != task) {
      System.out.println("任務隊列中第一個任務信息:" + task.getClass().getName());
    }
  }
}
/** 監控出站消息的隊列積壓的byteBuf大小 */
public void monitorOutboundBufSize(ChannelHandlerContext ctx) {
  long outBoundBufSize = ((NioSocketChannel) ctx.channel()).unsafe().outboundBuffer().totalPendingWriteBytes();
  System.out.println("出站消息隊列中積壓的buf大小" + outBoundBufSize);
}

note: 上面程序至少需要基於Netty4.1.29版本才能使用,否則有性能問題

實際基於Netty進行業務開發,耗時的業務邏輯代碼應該如何處理?

先說結論,建議自定義一組新的業務線程池,將耗時業務提交業務線程池

Netty的worker線程(NioEventLoop),除了作爲NIO線程處理連接數據讀取,執行pipeline上channelHandler邏輯,另外還有消費taskQueue中提交的任務,包括channel的write操作

如果將耗時任務提交到taskQueue,也會影響NIO線程的處理還有taskQueue中的任務,因此建議在單獨的業務線程池進行隔離處理

2 通用診斷思路

Netty堆外內存泄漏的原因多種多樣,例如代碼漏了寫調用release();通過retain()增加了ByteBuf的引用計數值而在調用release()時引用計數值未清空;因爲Exception導致未能release();ByteBuf引用對象提前被GC,而關聯的堆外內存未能回收等等,這裏無法全部列舉,所以嘗試提供一套通用的診斷思路提供參考

首先,需要能復現問題,爲了不影響線上服務的運行,儘量在測試環境或者本地環境進行模擬。但這些環境通常沒有線上那麼大的併發量,可以通過壓測工具來模擬請求

對於有些無法模擬的場景,可以通過Linux流量複製工具將線上真實的流量複製到到測試環境,同時不影響線上的業務,類似工具有Gor、tcpreplay、tcpcopy等

能復現之後,接下來就要定位問題所在,先通過前面介紹的監控手段、日誌信息試試能不能直接找到問題所在;如果找不到,就需要定位出堆外內存泄漏的觸發條件,但有時應用程序比較龐大,對外提供的流量入口很多,無法逐一排查。

在非線上環境的話,可以將流量入口註釋掉,每次註釋掉一半,然後再運行檢查問題還是否還存在,如果存在,繼續再註釋掉剩下的一半,通過這種二分法的策略通過幾次嘗試可以很快定位出觸發問題觸發條件

定位出觸發條件之後,再檢查程序中在該觸發條件處理邏輯,如果該處理程序很複雜,無法直接看出來,還可以繼續註釋掉部分代碼,二分法排查,直到最後找出具體的問題代碼塊

整套思路的核心在於,問題復現、監控、排除法,也可以用於排查其他問題,例如堆內內存泄漏、CPU 100%,服務進程掛掉等

總結

整篇文章側重於介紹知識點和理論,缺少實戰環節,這裏分享一些優質博客文章:

《netty 堆外內存泄露排查盛宴》 閃電俠手把手帶如何debug堆外內存泄漏

https://www.jianshu.com/p/4e96beb37935

《Netty防止內存泄漏措施》,Netty權威指南作者,華爲李林峯內存泄漏知識分享 

https://mp.weixin.qq.com/s/IusIvjrth_bzvodhOMfMPQ

《疑案追蹤:Spring Boot內存泄露排查記》,美團技術團隊紀兵的案例分享

https://mp.weixin.qq.com/s/aYwIH0TN3nSzNaMR2FN0AA

《Netty入門與實戰:仿寫微信 IM 即時通訊系統》,閃電俠的掘金小冊(付費),個人就是學這個專欄入門Netty

https://juejin.im/book/5b4bc28bf265da0f60130116?referrer=598ff735f265da3e1c0f9643

 更多精彩,歡迎關注公衆號 分佈式系統架構

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章