Java的堆外記憶體本來是高貴而神祕的東西,只在一些快取方案的收費企業版裡出現。但自從用了Netty,就變成了天天打交道的事情,畢竟堆外記憶體能減少IO時的記憶體複製,不需要堆記憶體Buffer拷貝一份到直接記憶體中,然後才寫入Socket中;而且也沒了煩人的GC。
好在,Netty所用的堆外記憶體只是Java NIO的 DirectByteBuffer類,通讀一次很快。還有一些sun.misc.*的類木有原始碼,要自己跑去OpenJdk那看個明白。
1. 堆外記憶體的建立
在DirectByteBuffer中,首先向Bits類申請額度,Bits類有一個全域性的 totalCapacity變數,記錄著全部DirectByteBuffer的總大小,每次申請,都先看看是否超限 -- 堆外記憶體的限額預設與堆內記憶體(由-XMX 設定)相仿,可用 -XX:MaxDirectMemorySize 重新設定。
如果已經超限,會主動執行Sytem.gc(),期待能主動回收一點堆外記憶體。然後休眠一百毫秒,看看totalCapacity降下來沒有,如果記憶體還是不足,就丟擲大家最頭痛的OOM異常。
如果額度被批准,就呼叫大名鼎鼎的sun.misc.Unsafe去分配記憶體,返回記憶體基地址,Unsafe的C++實現在此,標準的malloc。然後再調一次Unsafe把這段記憶體給清零。跑個題,Unsafe的名字是提醒大家這個類只給Sun自家用的,你們別用,不然哪天Sun把它藏起來了你們就哭死。果然,JDK9裡就Oracle可能動手哦。
JDK7開始,DirectByteBuffer分配記憶體時預設已不做分頁對齊,不會再每次分配並清零 實際需要+分頁大小(4k)的記憶體,這對效能應有較大提升,所以Oracle專門寫在了Enhancements in Java I/O裡。
最後,建立一個Cleaner,並把代表清理動作的Deallocator類繫結 -- 降低Bits裡的totalCapacity,並呼叫Unsafe調free去釋放記憶體。Cleaner的觸發機制後面再說。
2. 堆外記憶體基於GC的回收
存在於堆內的DirectByteBuffer物件很小,只存著基地址和大小等幾個屬性,和一個Cleaner,但它代表著後面所分配的一大段記憶體,是所謂的冰山物件。通過前面說的Cleaner,堆內的DirectByteBuffer物件被GC時,它背後的堆外記憶體也會被回收。
快速回顧一下堆內的GC機制,當新生代滿了,就會發生young gc;如果此時物件還沒失效,就不會被回收;撐過幾次young gc後,物件被遷移到老生代;當老生代也滿了,就會發生full gc。
這裡可以看到一種尷尬的情況,因為DirectByteBuffer本身的個頭很小,只要熬過了young gc,即使已經失效了也能在老生代裡舒服的呆著,不容易把老生代撐爆觸發full gc,如果沒有別的大塊頭進入老生代觸發full gc,就一直在那耗著,佔著一大片堆外記憶體不釋放。
這時,就只能靠前面提到的申請額度超限時觸發的system.gc()來救場了。但這道最後的保險其實也不很好,首先它會中斷整個程式,然後它讓當前執行緒睡了整整一百毫秒,而且如果gc沒在一百毫秒內完成,它仍然會無情的丟擲OOM異常。還有,萬一,萬一大家迷信某個調優指南設定了-DisableExplicitGC禁止了system.gc(),那就不好玩了。
所以,堆外記憶體還是自己主動點回收更好,比如Netty就是這麼做的。
3. 堆外記憶體的主動回收
對於Sun的JDK這其實很簡單,只要從DirectByteBuffer裡取出那個sun.misc.Cleaner,然後呼叫它的clean()就行。
前面說的,clean()執行時實際呼叫的是被繫結的Deallocator類,這個類可被重複執行,釋放過了就不再釋放。所以GC時再被動執行一次clean()也沒所謂。
在Netty裡,因為不確定跑在Sun的JDK裡(比如安卓),所以多廢了些功夫來確定Cleaner的存在。
4. Cleaner如何與GC相關聯?
漲知識的時間到了,原來JDK除了StrongReference,SoftReference 和 WeakReference之外,還有一種PhantomReference,Phantom是幻影的意思,Cleaner就是PhantomReference的子類。
當GC時發現它除了PhantomReference外已不可達(持有它的DirectByteBuffer失效了),就會把它放進 Reference類pending list靜態變數裡。然後另有一條ReferenceHandler執行緒,名字叫 "Reference Handler"的,關注著這個pending list,如果看到有物件型別是Cleaner,就會執行它的clean(),其他型別就放入應用構造Reference時傳入的ReferenceQueue中,這樣應用的程式碼可以從Queue裡拖出這些理論上已死的物件,做愛做的事情——這是一種比finalizer更輕量更好的機制。
5. 其實
專家們說,OpenJDK沒有接受jemalloc(redis們在用)的補丁,直接用malloc在OS裡申請一段記憶體,比在已申請好的JVM堆內記憶體裡劃一塊出來要慢,所以我們在Netty一般用池化的 PooledDirectByteBuf 對DirectByteBuffer進行重用 ,《Netty權威指南》說效能提升了23倍,所以基本不需要頭痛堆外記憶體的釋放,順便還告別了大資料流量下的頻繁GC。
片末招聘廣告:唯品會廣州總部的基礎架構部招人!! 如果你喜歡純技術的工作,對大型網際網路企業的服務化平臺有興趣,願意在架構的成長期還可以大展拳腳的時候加盟,請電郵 calvin.xiao@vipshop.com