1. 程式人生 > >追蹤 Netty 異常佔用堆外記憶體的經驗分享

追蹤 Netty 異常佔用堆外記憶體的經驗分享

https://zhuanlan.zhihu.com/p/21741364

本文記述了定位 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 出現的問題主要有以下幾類:

  1. 使用 finalize 的物件在建立和銷燬時效能比正常的物件差;
  2. finalize 執行時間不確定。可能出現 heap 內雖然有很多擁有 finalize 函式的類物件,且這些物件都已死掉(從 GC Root 無法訪問),如果遇到 GC 壓力比較大等原因,這些物件的 finalize 還沒有被觸發,就會導致這些本來該被 GC 但沒有被 GC 的物件大量存在於 Heap 中。

猜想 Netty 這裡使用 finalize 而不是明確提供一個 close 函式,主要是為了使用方便,畢竟 OpenSslContext 在大多數場景下在一個服務中只存在一兩個物件,需要將其銷燬的情況也許也不是很多。以上猜想在這個 issue 中也得到了一定程度的印證,並且 Netty 已經在修改這個問題,讓 OpenSslContext 實現 ReferenceCount 介面,在 finalize 之外又提供了 release 函式專門用於清理 Native 資源。

所以分享這些經驗來讓大家引以為戒,finalize 要儘量少用,看著以為使用 finalize 很合理的地方還是有可能出現問題。