追蹤 Netty 異常佔用堆外內存的經驗分享
本文記述了定位 Netty 的一處漏洞的全過程。事情的起因是我們一個使用了 Netty 的服務,隨著運行時間的增長,其佔用的堆外內存會逐步攀升,大概持續運行三四天左右,堆外內存會被全部佔滿,然後只能重啟來解決問題。好在服務是冗餘配置的,並且可以自動進行 Load Balance,所以每次重啟不會帶來什麼損失。
從現象上分析,我們能確定一定是服務內部有地方出現了內存泄露。在這個出問題的服務上有大量的網路 IO 操作,為了優化性能,我們使用了 PooledByteBufAllocator 來分配 PooledDirectByteBuf。因為是堆外內存泄露,所以第一種可能就是我們在某個地方分配了內存但忘記了釋放。我們仔細檢查了與業務相關的 ChannelHandler 但並未發現問題,於是又將 Netty 的 io.netty.leakDetectionLevel 設置到 Advanced 級別,放在 Beta 環境上進行測試。在服務連續運行了幾天並即將因內存不足再次重啟之前,我們從日誌中也沒有發現任何由 Netty 打出來的內存泄露的報警信息。隨後我們又將 io.netty.leakDetectionLevel 設置到 Paranoid 來重新測試,但依然沒有發現有關 Netty 內存泄露的相關日誌。
在排查過程中,我們也發現雖然引起服務重啟的原因是堆外內存不足,但實際堆內內存也有小幅度攀升。起初我們以為這是正常現象,因為有使用 PooledByteBufAllocator,這種 Allocator 為了減少堆外內存的重複分配,會在服務內部建立一個堆外內存池,每次分配內存優先從內存池分配,只有內存池沒有足夠內存時候,才會去堆外分配新內存。內存池上的內存雖然在堆外,但維護內存池的數據結構卻是在堆上。隨著堆外內存分配的增多,內部維護內存池的數據結構也會相應增大,堆內內存也會有所升高。為了驗證這個猜想,我們將 io.netty.allocator.type 設置為 unpooled 再去測試,幾天後發現堆內內存依舊會小幅度攀升,從而判定內存泄露並不是由內存池而導致。
順藤摸瓜
不是內存池出現泄露,而且堆內堆外一起泄露,能同時佔用堆內堆外內存的對象一般不多,不過一時也想不出到底有哪些,於是隨手 dump 了一份堆內存快照開始分析,果不其然從中還真看出了些端倪。一般通過 dump 排查內存泄露都使用 Eclipse Memory Analyzer Tool(簡稱 MAT)去檢查 dominator tree,從中找出哪個類的對象不正常地佔用了大量內存。但這次的 dominator tree 看不出有什麼問題。因為出現泄露的對象在堆上佔用的總內存並不是很多,它在 dominator tree 上根本排不到前列,很難被關注到,但是在 Histogram(如下圖)中就有它的身影了。
出現泄露的就是上圖中被圈出來的 OpenSslClientContext。
從 OpenSslClientContext 的使用也能看出來,這個出問題的服務是作為 client 一端,使用 OpenSsl 去連接另一個服務。一般正常使用的情況下,一個 SSL 證書會只對應一個 OpenSslClientContext。對於大多數場景來說,整個服務可能只會使用一種證書,所以只會有一個 OpenSslClientContext 保留在內存中。但我們這個服務有些特殊,會使用很多不同的證書去建立 SSL 連接,只是服務在內部做了限制,將同一時刻不同證書建立的 SSL 連接數量控制在幾十個左右,並且在一個 SSL 證書使用完畢之後,指向該 SSL 證書對應 OpenSslClientContext 的引用會被清理掉。之後按正常邏輯來說 OpenSslClientContext 會被 GC 掉,不會在內存中長久停留。但是上圖顯示同一時間並存的 OpenSslClientContext 有 27472 個之多,遠遠超過了原本服務內部在同一時間允許並存的 OpenSslClientContext 的數量限制,這就意味著這個 OpenSslClientContext 發生了泄露。從 dump 中我們還發現,維護 OpenSslClientContext 的業務對象沒有產生泄露,並被正常 GC。這說明我們的業務代碼可以正確清理指向 OpenSslClientContext 對象的引用。那這個 OpenSslClientContext 是怎麼被 GC Root 引用到的呢?
水落石出
依然使用 MAT,分析指向泄露的 OpenSslClientContext 對象的引用路徑後得到如下圖:
可以看出 OpenSslClientContext 對象被兩個引用指向,一個是 Finalizer 上的引用,一個是 Native Stack 上的引用,這表明我們的業務對象已經正確地釋放了對 OpenSslClientContext 的引用。
Finalizer 引用的存在是因為 finalize method 被 OpenSslClientContext 所 overide 了(實際是 OpenSslClientContext 的父類 OpenSslContext 來進行 overide),這樣 JVM 會為這類對象自動加上 Finalizer 引用,從而在該對象被 GC 的時候調用對象的 finalize method。但這個 Finalizer 引用不會阻礙對象被 GC,所以內存泄露與它沒有直接的關係。
而 Native Stack 就是 GC Root,被其引用的對象是不能被 GC 的,這也就是 OpenSslClientContext 泄露的源頭。從這個 Native Stack 指向的對象的類名 OpenSslClientContext$1 能看出,這是一個 OpenSslClientContext 上的匿名類。
查看這個匿名類的對象的屬性:
一方面它包含有指向外部 OpenSslClientContext 的引用 this$0,還包含一個叫做 val$extendedManager 的引用指向了對象 sun.security.ssl.X509TrustManagerImpl。這時候去翻看 Netty 4.1.1-Final 的 OpenSslClientContext 第 240 ~ 268 行代碼如下(注意現在的 Netty 4.1 分支已經將這個 bug 修復,所以不能直接看到下面的代碼了):
對比上面 MAT 中看到的 val$extendedManager 引用信息我們會知道,上述代碼 14 ~ 20 行設置的這個 callback 就是之前說的出現泄露的匿名類。匿名類有指向外部對象 OpenSslClientContext 的引用,也有個指向外部 extendedManager 的引用。這段邏輯是在 OpenSslClientContext 的構造函數中的,而且 12 ~ 29 行的這個 if 語句無論走哪個分支,都會設置一個匿名的 verifier 到 SSLContext.setCertVerifyCallback,也就是說只要 new 一個 OpenSslClientContext 對象,就一定會設置一個 verifier 到 Native Stack 上。
找到 SSLContext.setCertVerifyCallback 的代碼。在我們使用的 netty-tcnative-1.1.33.Fork17 中,SSLContext.setCertVerifyCallback 函數聲明如下:
從注釋上能看出來,這個函數是用來讓用戶自定義證書檢查函數,好在 SSL Handshake 過程中來使用去校驗證書。
函數聲明上的「native」關鍵字也表明它是通過調用本地 C 代碼實現的。結合之前的分析,能推理出一定是這個 Native 代碼將 verifier callback 存入了 Native Stack,並且在 OpenSslClientContext 沒有其他引用指向時沒能將這個 callback 正確清理,從而讓 OpenSslClientContext 對象有了從 GC Root 過來的引用指向,所以不能被 GC 掉,造成了泄露。
有了指導路線,我們繼續追蹤問題。在 netty-tcnative-1.1.33.Fork17 的 sslcontext.c 文件下找到 setCertVerifyCallback 函數對應的 Native 代碼如下:
這裡函數的 verifier 參數就對應著 SSLContext.setCertVerifyCallback 上傳入的 verifier。這裡也不需要完全理解上面代碼的含義,主要是看到第 21 行,創建了個引用從 *e 指向了 verifier。這個 *e 是個 JNIEnv struct,NewGlobalRef(e, verifier) 相當於將 verifier 保存在一個全局的變數當中,必須通過對應的 DeleteGlobalRef 才能銷毀。
在搜索 sslcontext.c 的代碼後發現在正常的邏輯下,要對 verifier 調用 DeleteGlobalRef 將其清理,必須調用 SSLContext.free 函數才能實現。SSLContext.free 聲明如下:
它還有個對應的 make 函數,合併起來用於負責 OpenSslClientContext 分配和回收一些 Native 的資源。OpenSslClientContext 在構造函數中必須調用一次 SSLContext.make,在對象被銷毀時需要調用 SSLContext.free。「在對象被銷毀時調用」聽上去有點析構函數的意思,但 Java 中沒有析構函數的概念,看上去 Netty 也沒有好的方法來實現這種類似析構函數的功能,雖然所有講到 finalize 的地方都在諄諄告誡開發者只是知曉它的存在就好但永遠不要去使用,Netty 還是「被逼無奈」地將用於資源回收的 SSLContext.free 調用放在了 OpenSslClientContext 的 finalize(繼承自 OpenSslContext)函數中。
分析到這裡基本就能得到 OpenSslClientContext 泄露的原因了。因為 OpenSslClientContext 在構造時會將一個匿名的 AbstractCertificateVerifier 子類對象作為證書的校驗函數(簡稱為 verifier),通過調用 SSLContext.setCertVerifyCallback 存儲到 Native Stack 上,必須在 OpenSslClientContext 銷毀時主動調用 SSLContext.free 才能將這個 verifier 從 Native Stack 清除。而 SSLContext.free 是在 OpenSslClientContext 的 finalize 內,必須等到 OpenSslClientContext 被 GC 掉之後才會被調用。由於 verifier 是個匿名類,它含有隱含的指向了其所屬 OpenSslClientContext 的引用,導致當 verifier 不被銷毀時,其所在 OpenSslClientContext 也無法銷毀,從而產生依賴環,verifier 的清理依賴 OpenSslClientContext 的清理,OpenSslClientContext 的清理又依賴 verifier 的清理。這種依賴環如果都是在堆內,JVM GC 的時候會將相互依賴的兩個對象全部 GC 掉。但這裡 verifier 比較特殊,它是直接存儲在 Native Stack 上作為 GC Root 的,JVM GC 拿它沒有辦法。JVM GC 的管轄範圍只有堆,Native Stack 可以理解為是它的上級,它無權過問。
另外補充一點,上述問題雖然是在 OpenSslClientContext 中發現,但 OpenSslServerContext 中也有相同問題。
修復
Bug 找到了,具體的 PR 請參考這裡。修復辦法就是將導致泄露的匿名 AbstractCertificateVerifier 子類對象修改為 static 的內部類,這樣它不會包含指向其所在外部類的引用(即 OpenSslClientContext),從而不會阻礙外部類的 GC,也就避免了泄露的發生。
問題是解決了,但究其根本原因是不是可以歸結到 finalize 函數的使用呢?如果 OpenSslClientContext 沒有使用 finalize,而是暴露一個類似 close 的介面,要求 OpenSslClientContext 的使用者主動調用 close,finalize 內只是列印日誌,提醒使用者沒有調用 close,這個問題是不是從一開始就不會存在了呢?
一般來說 finalize 出現的問題主要有以下幾類:
- 使用 finalize 的對象在創建和銷毀時性能比正常的對象差;
- finalize 執行時間不確定。可能出現 heap 內雖然有很多擁有 finalize 函數的類對象,且這些對象都已死掉(從 GC Root 無法訪問),如果遇到 GC 壓力比較大等原因,這些對象的 finalize 還沒有被觸發,就會導致這些本來該被 GC 但沒有被 GC 的對象大量存在於 Heap 中。
猜想 Netty 這裡使用 finalize 而不是明確提供一個 close 函數,主要是為了使用方便,畢竟 OpenSslContext 在大多數場景下在一個服務中只存在一兩個對象,需要將其銷毀的情況也許也不是很多。以上猜想在這個 issue 中也得到了一定程度的印證,並且 Netty 已經在修改這個問題,讓 OpenSslContext 實現 ReferenceCount 介面,在 finalize 之外又提供了 release 函數專門用於清理 Native 資源。
所以分享這些經驗來讓大家引以為戒,finalize 要盡量少用,看著以為使用 finalize 很合理的地方還是有可能出現問題。
頭圖:http://Netty.io
推薦閱讀:
※線程之間傳遞 ThreadLocal 對象
※在 LeanCloud 中使用 GraphQL
※最近要用到IM,比較了下LeanCloud、融雲、環信,請問LeanCloud有什麼優勢?