上篇文章介紹了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 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:可以調用PlatformDependent.useDirectBufferNoCleaner()方法查看當前Netty程序是否使用noCleaner策略
讀到這里,也許有讀者會問,如果Netty基於hasCleaner策略,通過GC觸發Cleaner.clean(),自動回收堆外內存,是不是就可以不用考慮ByteBuf.release()方法的調用,不會內存泄漏?
當然不是,一方面原因是自動觸發不實時:需要ByteBuffer對象被GC線程回收才會觸發,如果ByteBuffer對象進入老年代后才變得可回收,則需要等到發送頻率較低老年代GC才會觸發
另一方面,Netty需要基於ByteBuf.release()方法執行其他操作,例如池化內存釋放回內存池,否則該對象會被內存池一直標記為已使用
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