1. 程式人生 > >Netty之有效規避記憶體洩漏

Netty之有效規避記憶體洩漏

有過痛苦的經歷,特別能寫出深刻的文章 —— 凱爾文. 肖

直接記憶體是IO框架的絕配,但直接記憶體的分配銷燬不易,所以使用記憶體池能大幅提高效能。但,要重新培養被Java的自動垃圾回收慣壞了的惰性。

Netty有一篇必讀的文件 官方文件翻譯:引用計數物件,在此基礎上補充一些自己的理解和細節。

1.為什麼要有引用計數器

Netty裡四種主力的ByteBuf,
其中UnpooledHeapByteBuf 底下的byte[]能夠依賴JVM GC自然回收;而UnpooledDirectByteBuf底下是DirectByteBuffer,如Java堆外記憶體掃盲貼所述,除了等JVM GC,最好也能主動進行回收;而PooledHeapByteBuf 和 PooledDirectByteBuf,則必須要主動將用完的byte[]/ByteBuffer放回池裡,否則記憶體就要爆掉。所以,Netty ByteBuf需要在JVM的GC機制之外,有自己的引用計數器和回收過程。


一下又回到了C的冰冷時代,自己malloc物件要自己free。 但和C時代又不完全一樣,內有引用計數器,外有JVM的GC,情況更為複雜。

2. 引用計數器常識
  • 計數器基於 AtomicIntegerFieldUpdater,為什麼不直接用AtomicInteger?因為ByteBuf物件很多,如果都把int包一層AtomicInteger花銷較大,而AtomicIntegerFieldUpdater只需要一個全域性的靜態變數。
  • 所有ByteBuf的引用計數器初始值為1。
  • 呼叫release(),將計數器減1,等於零時, deallocate()被呼叫,各種回收。
  • 呼叫retain(),將計數器加1,即使ByteBuf在別的地方被人release()了,在本Class沒喊cut之前,不要把它釋放掉。
  • 由duplicate(), slice()和order(ByteOrder)所建立的ByteBuf,與原物件共享底下的buffer,也共享引用計數器,所以它們經常需要呼叫retain()來顯示自己的存在。
  • 當引用計數器為0,底下的buffer已被回收,即使ByteBuf物件還在,對它的各種訪問操作都會丟擲異常。

3.誰來負責Release

在C時代,我們喜歡讓malloc和free成對出現,而在Netty裡,因為Handler鏈的存在,ByteBuf經常要傳遞到下一個Hanlder去而不復還,所以規則變成了誰是最後使用者,誰負責釋放。

另外,更要注意的是各種異常情況,ByteBuf沒有成功傳遞到下一個Hanlder,還在自己地界裡的話,一定要進行釋放。


3.1 InBound Message
在AbstractNioByteChannel.NioByteUnsafe.read() 處,配置好的ByteBufAllocator建立相應ByteBuf並呼叫 pipeline.fireChannelRead(byteBuf) 送入Handler鏈。

根據上面的誰最後誰負責原則,每一個Handler對訊息可能有三種處理方式

對原訊息不做處理,呼叫 ctx.fireChannelRead(msg)把原訊息往下傳,那不用做什麼釋放。
將原訊息轉化為新的訊息並呼叫 ctx.fireChannelRead(newMsg)往下傳,那必須把原訊息release掉。
如果已經不再呼叫ctx.fireChannelRead(msg)傳遞任何訊息,那更要把原訊息release掉。
假設每一個Handler都把訊息往下傳,Handler並也不知道誰是啟動Netty時所設定的Handler鏈的最後一員,所以Netty會在Handler鏈的最末補一個TailHandler,如果此時訊息仍然是ReferenceCounted型別就會被release掉。
不過如果我們的業務Hanlder不再把訊息往下傳了,這個TailHandler就派不上用場。
3.2 OutBound Message
要傳送的訊息通常由應用所建立,並呼叫 ctx.writeAndFlush(msg) 進入Handler鏈。在每一個Handler中的處理類似InBound Message,最後訊息會來到HeadHandler,再經過一輪複雜的呼叫,在flush完成後終將被release掉。

3.3 異常發生時的釋放
多層的異常處理機制,有些異常處理的地方不一定準確知道ByteBuf之前釋放了沒有,可以在釋放前加上引用計數大於0的判斷避免異常;

有時候不清楚ByteBuf被引用了多少次,但又必須在此進行徹底的釋放,可以迴圈呼叫reelase()直到返回true。

4. 記憶體洩漏檢測

所謂記憶體洩漏,主要是針對池化的ByteBuf。ByteBuf物件被JVM GC掉之前,沒有呼叫release()去把底下的DirectByteBuffer或byte[]歸還到池裡,會導致池越來越大。而非池化的ByteBuf,即使像DirectByteBuf那樣可能會用到System.gc(),但終歸會被release掉的,不會出大事。

Netty擔心大家一定會不小心就搞出個大新聞來,因此提供了記憶體洩漏的監測機制。

Netty預設就會從分配的ByteBuf裡抽樣出大約1%的來進行跟蹤。如果洩漏,會有如下語句列印:
引用
LEAK: ByteBuf.release() was not called before it's garbage-collected. Enable advanced leak reporting to find out where the leak occurred. To enable advanced leak reporting, specify the JVM option '-Dio.netty.leakDetectionLevel=advanced' or call ResourceLeakDetector.setLevel()

這句話報告有洩漏的發生,提示你用-D引數,把防漏等級從預設的simple升到advanced,具體看到被洩漏的ByteBuf建立的地方和被訪問的地方。
  • 禁用(DISABLED) - 完全禁止洩露檢測,省點消耗。
  • 簡單(SIMPLE) - 預設等級,告訴我們取樣的1%的ByteBuf是否發生了洩露,但總共一次只打印一次,看不到就沒有了。
  • 高階(ADVANCED) - 告訴我們取樣的1%的ByteBuf發生洩露的地方。每種型別的洩漏(建立的地方與訪問路徑一致)只打印一次。
  • 偏執(PARANOID) - 跟高階選項類似,但此選項檢測所有ByteBuf,而不僅僅是取樣的那1%。在高壓力測試時,對效能有明顯影響。


實現細節
每當各種ByteBufAllocator 建立ByteBuf時,都會問問是否需要取樣,Simple和Advanced級別下,就是以113這個素數來取模(害我看文件的時候還在瞎擔心,1%,萬一洩漏的地方有所規律,剛好躲過了100這個數字呢,比如都是3倍數的),命中了就建立一個Java堆外記憶體掃盲貼裡說的PhantomReference。然後建立一個Wrapper,包住ByteBuf和Reference。

Simple級別下,wrapper只在執行release()時呼叫Reference.clear()把Reference清理掉,Advanced級別下則會記錄每一個建立和訪問的動作。

當GC發生,還沒有被clear()的Reference就會被JVM放入到之前設定的ReferenceQueue裡。

在每次建立PhantomReference時,都會順便看看有沒有因為忘記執行release()把Reference給clear掉,在GC時被放進了ReferenceQueue的物件,有則以 "io.netty.util.ResourceLeakDetector”為logger name,寫出前面例子裡的Error級別的日日誌。順便說一句,Netty能自動匹配日誌框架,先找Slf4j,再找Log4j,最後找JDK logger。

值得說三遍的事
一定要盯緊log裡有沒有出現 "LEAK: "字樣,因為Simple級別下它只會出現一次,所以不要依賴自己的眼睛,要依賴grep。如果出現了,而且你用的是PooledBuf,那一定是問題,不要有任何的僥倖,立刻用"-Dio.netty.leakDetectionLevel=advanced" 再跑一次,看清楚它建立和最後訪問的地方。

功能測試時,最好開著"-Dio.netty.leakDetectionLevel=paranoid"

但是,怎麼測試都可能有沒覆蓋到的分支,如果記憶體尚夠,可以適當把-XX:MaxDirectMemorySize 調大,反正只是max,平時也不會真用了你的。然後監控其使用量,及時報警。

本文轉自:花錢的年華