1. 程式人生 > >Netty堆外記憶體洩漏排查,這一篇全講清楚了

Netty堆外記憶體洩漏排查,這一篇全講清楚了

上篇文章介紹了Netty記憶體模型原理,由於Netty在使用不當會導致堆外記憶體洩漏,網上關於這方面的資料比較少,所以寫下這篇文章,專門介紹排查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 noClaner策略

介紹noClaner策略之前,需要先理解帶有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:可以呼叫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

  • (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