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

上篇文章介紹了Netty內存模型原理,因爲Netty在使用不當會致使堆外內存泄漏,網上關於這方面的資料比較少,因此寫下這篇文章,專門介紹排查Netty堆外內存相關的知識點,診斷工具,以及排查思路提供參考html

現象

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

堆外內存釋放底層實現

1 java.nio堆外內存釋放

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

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

  • (1) 應用程序主動調用
ByteBuffer buf = ByteBuffer.allocateDirect(1);
((DirectBuffer) byteBuffer).cleaner().clean();
  • (2) 基於GC回收

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

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

2 Netty noClaner策略

介紹noClaner策略以前,須要先理解帶有Cleaner對象的DirectByteBuffer在初始化時作了哪些事情:微信

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

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

若是內存仍是不足, 則執行 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:能夠調用PlatformDependent.useDirectBufferNoCleaner()方法查看當前Netty程序是否使用noClaner策略

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()方法調用不會致使這些對象內存回收

堆外內存大小控制參數

配置堆外內存大小的參數有-XX:MaxDirectMemorySize和-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中的Queue<Runnable> taskQueue,提交到該任務隊列的場景有:

  • (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/cai...​):

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/4e9...

《Netty防止內存泄漏措施》,Netty權威指南做者,華爲李林峯內存泄漏知識分享
https://mp.weixin.qq.com/s/Iu...

《疑案追蹤:Spring Boot內存泄露排查記》,美團技術團隊紀兵的案例分享
https://mp.weixin.qq.com/s/aY...

《Netty入門與實戰:仿寫微信 IM 即時通信系統》,閃電俠的掘金小冊(付費),我的就是學這個專欄入門Netty的
https://juejin.im/book/5b4bc2...

相關文章
相關標籤/搜索