本文通過一類 Android 機型上相機拍攝過程中的 native 內存 OOM 的問題展開,借助內存快照裁剪回撈和 Native 內存監控工具的賦能,來深入剖析此類問題。
背景
Raphael 是西瓜視頻 Android 團隊開發的一款 native 內存監控工具,在字節跳動內部產品(如西瓜、抖音、頭條等)上廣泛用于監控 native 內存泄漏問題。在抖音 7.8.0-8.3.0 上搜集到大量因虛擬內存觸頂而 crash 的內存日志現場(如 pthread_create、GL error、EGL_BAD_ALLOC),其中 60%以上都是 camera 相關的內存泄漏,占整體 crash 的 15%以上(JAVA & Native)。同時也收到 OPPO 等廠商反饋抖音 App 在其新機型上 native crash 比其他機型高了 3 倍以上,分析廠商提供的日志發現基本都是虛擬內存觸頂導致的 carsh,這其中 80%以上都有 camera 相關的內存分配失敗的日志。
問題
通過對 native 內存監控搜集到的日志進行堆棧聚合和 so 級的內存占用統計,可以發現截止到 OOM 時工具攔截到的 native 內存總量已經達到了 1.3G 左右(32 位下應用可直接使用的 native 內存上限約 2G),這其中占比最大的是 CameraMetaData 對象間接引用的內存,native 內存泄漏十分嚴重。

由于 native 內存分配的頻率過高,獲取 Java 層堆棧又比較耗時,在攔截 native 內存分配時并不適合直接頻繁抓取 Java 堆棧。Native 內存不同于 Java 內存,單從攔截到的數據很難直觀給出結論。通常對于內存等資源不合理使用導致的資源不足而引發的問題都很難歸因,從攔截到的數據來看,CameraMetaData 所引用的內存最大,嫌疑也最大,基于此決定剖析一下這個問題
初步分析
分析 native 內存的分配和釋放
通過攔截到的堆棧可以看出,CameraMetaData 的創建堆棧的上層是 Java 調用,最終在 native 層進行的內存分配(boot-framework.oat & libandroid_runtime.so)。CameraMetaData 對象有兩部分內存,對象本身 & mBuffer 指向的 camera_metadata_t 所引用的內存;通過源碼可知,每個 CameraMetadata 對象的 mBuffer 所指向的 camera_metadata_t 是獨立的,彼此是不重疊的。


既然工具能攔截到這么多的未釋放的內存分配,一定是因為這些內存的釋放邏輯出問題導致的,我們需要優先調查清楚 CameraMetadata.mBuffer 的釋放邏輯。通過分析 CameraMetadata.cpp 的源碼可知,CameraMetadata::release()并未釋放 mBuffer 所指向的內存,而是把 mBuffer 所指向的內存賦值給了另一個 CameraMetadata 對象;CameraMetadata::clear()是真釋放,而 clear 的調用有兩個場景:一個是在 camera_metadata_t 復用時,另一個是 CameraMetadata 對象析構時。

前述結論可知 CameraMetadata.mBuffer 所指向的 camera_metadata_t 是彼此獨立的。通過工具攔截到的堆棧和分配數量猜測,Native OOM 時內存中一定存在大量的 CameraMetadata 實例。C++對象的析構通常是調用 delete 來實現的,AOSP 里想搜索哪里 delete 了一個 CameraMetaData 對象是很難的,因為很難知道 delete 時的變量名。根據一個基本的 C++編程規范,內存通常在哪里創建的,應該就在那里釋放,我們全局搜索 new CameraMetaData 字符串就可以很輕松的發現 CameraMetaData 對象的創建和釋放均是在/frameworks/base/core/jni/android_hardware_camera2_CameraMetadata.cpp里實現的。



通過 android_hardware_camera2_CameraMetadata.cpp 里的注冊清單可以看到與這些函數關聯的 Java 層 class 是android/hardware/camera2/impl/CameraMetadataNative,CameraMetadata_close 函數在 Java 對應的是 nativeClose 函數。可以進一步發現 CameraMetaDataNative 里 nativeClose 函數是在 close 函數里調用的,而 close 函數又是在 finalize 函數調用的。


通過上述分析可知只有在 CameraMetaDataNative 對象執行 finalize 方法時才會回收與之對應的 native 內存,而 finalize 方法又是在 FinalizerDaemon 線程里執行的,猜測到如果發生了上述堆棧的 native OOM,Java 層一定存在大量還沒有執行 finalize 方法的 CameraMetaDataNative 對象。
排查 Java 堆現場
幸運的是我們通過內存快照裁剪工具(Tailor)輕松拿到了大量這類 native OOM 時對應的 Java 堆內存快照文件。這些內存快照文件完美證實了之前的猜想,當發生這類 native OOM 時 Java 層的確存在大量的 CameraMetadataNative 對象。以下圖為例,這些 CameraMetadataNative 對象里除 6 個被其他代碼引用外,其余對象全部在 FinalizerDaemon 線程的隊列里,等待執行 finalize 方法。同時,快照里有 6658 個對象,只有大約 600+對象的 mMetadataPtr 是等于 0 的,說明這部分對象對應的 Native 內存需要在 finalize 時釋放,這跟工具攔截的數據是完全匹配的,也間接驗證了 Native 內存監控的正確性和可靠性

深入分析
排查 Finalize 執行
雖然上述分析驗證了問題,也證實了之前的猜想,但仍未找到導致此類問題的深層次原因,對于最終解決此類問題也仍然束手無策。為什么會有這么多的 CameraMetadataNative 對象等待執行 finalize 方法或許是下一步的調查方向。做過 Java 穩定性治理的同學應該都知道一類很有名的 TimeoutException 異常,這類異常的根本原因是 finalize 執行超時導致的,這個 case 會不會是某個對象的 finalize 執行超時導致的?

結合 FinalizerDaemon 的源碼可以看到,每執行一個對象的 finalize 方法時,都會通過finalizingObject屬性記錄當前的對象。如果真的是 finalize 超時導致的,一定存在 finalizingObject 屬性不為空的現場。我們在遍歷完所有相關內存快照里的 FinalizerDaemon 線程狀態后發現,這些現場的 finalizingObject 屬性均為空。這個結果很意外,似乎并不是某個對象的 finalize 方法執行超時導致的。

通過分析finalizingReference = (FinalizerReference<?>)queue.remove() 發現這行代碼后面的邏輯并沒有對 finalizingReference 判空,說明這個地方一定不會返回空。既然不為空, queue.remove() 只能 block 等待,這個 ReferenceQueue.java 的源碼也證實了猜想。

源碼顯示 goToSleep 是個同步方法,可能會 block。但遍歷所有相關快照發現所有的 needToWork 屬性均是 false,證明已經走過(只有FinalizerWatchdogDaemon.INSTANCE.goToSleep() 會置為 false,而且這個函數是 private 的,只在 FinalizerDaemon 線程里調用),所以 block 在這里的可能性幾乎沒有。


其實 block 在這里的原因通常是因為只有在 GC 時才會將需要執行 finalize 的對象加入到 FinalizerDaemon 的隊列里。如果一段時間內沒有 GC,且隊列就為空時,上面的 remove 會一直 block,直到 GC 后才有對象加入到這個隊列里。巧合的是我們在發生這類 native OOM 時會通過 Tailor 主動 dump Java 堆的內存快照,而 dump 快照時會觸發 GC & suspend,這個最終導致大量的 CameraMetadataNative 對象被同時加入到 FinalizerDaemon.queue 的隊列里。
分析 GC 策略
通過上述分析可知如果不是 GC,這些對象是不會被被加入到 FinalizerDaemon.queue 里的,這說明這類 native OOM 發生前的一段時間內一直沒有 GC,才導致大量 CameraMetadataNative 對象沒有及時執行 finalize,進而發生 native OOM。以上分析也在線下進入到拍攝頁后靜置觀察實驗中得到驗證,這其中大概每隔 30s-40s 甚至更長時間 Java 堆才會主動觸發一次 GC,在這期間 native 內存會不斷增長,直到 GC 后才會大幅下降,Java & Native 內存才會恢復到正常水平。雖然問題不是 block 在 finalize 環節,但最終這個問題的原因被鎖定在了 GC 邏輯上!


了解 GC 的同學可能會知道 ART 虛擬機的 GC cause 有很多種,kGcCauseForAlloc/kGcCauseBackground 是虛擬機最易頻繁觸發的。當停留在拍攝頁不做任何操作時,程序邏輯相對簡單,這期間只有相機服務周期(>=30 次/s)地通過 binder 在應用端觸發創建 CameraMetadataNative 對象,并在拍攝頁顯示一張相機采集到的圖像。這個過程 Java 堆只有 CameraMetadataNative 對象創建,而 CameraMetadataNative 自身占用內存比較小,一次 GC 之后 Java 堆內存比較富裕的情況下,虛擬機很長一段時間內不會主動觸發 GC。如果這期間 native 內存的增幅過大,在下次 GC 之前觸頂就發生 native OOM

綜上,這類 native OOM 的根本原因是:當應用自身的 native 內存本身已處于高水位時,開啟相機后,相機服務會持續通過 binder 通信在應用側創建 CameraMetadataNative 對象,創建 CameraMetadataNative 對象的同時也會在應用側通過 jni 接口在 native 層創建/復用一塊存放 camera_metadata_t 的相對比較大的內存。由于 Java 層的 CameraMetadataNative 對象本身比較小,這種連續創建小對象的行為一定時間內很難觸發 Java 層的 GC,導致其間接引用的 native 內存不斷上漲,最終觸發虛擬內存上限而 crash。
解決思路
問題的原因雖然相對比較簡單,但如何解決這類問題還是比較難抉擇的。既然是 GC 不及時導致的,一種簡單的方案就是在拍攝頁周期性觸發 GC。但如果 GC 間隔比較小,GC 畢竟是耗時的,GC 過于頻繁會嚴重影響拍攝體驗;如果 GC 間隔時間比較長,還是會有大概率重蹈這類 native OOM 的覆轍。
主動觸發 GC 的方案很難平衡對性能的影響。其實問題的重點不是 Java 層,而是 Java 對象引用的 native 內存,如果及時主動釋放這部分內存就可以從根本上徹底解決此類問題。通過前面的分析可以知道,這部分內存原本是在 GC 時的 finalize 環節回收,但如果提前發現 CameraMetadataNative 不再使用時,主動觸發來釋放這部分內存就可以一勞永逸。通過分析源碼可以發現 CameraMetadataNative 傳遞到應用層之后后續并未再使用,在應用層使用完 CameraMetadataNative 對象之后,通過反射調用 close 函數即可釋放其所引用的 native 內存。

線下實驗也可以發現,開啟主動回收策略后,Native 內存的增長速度比之前大幅降低。這期間 Java 堆& native 層仍有持續增加的小對象,但 native 的增長速度遠小于 Java 層了,這種場景下 Java 內存會在 native 內存觸頂之前先觸發 GC,而大幅降低了發生 native OOM 的可能

最終該方案上線后,效果十分明顯,此類 crash(Java & Native 總占比>15%)基本清零。后續搜集到的內存監控日志里 CameraMetadata 相關的內存基本都在 2M 以內,效果立竿見影!
總結
此類問題存在時間很久,至少從 Android 4.4 開始都是通過 CameraMetadataNative 的 finalize 函數來釋放 native 內存。過去拍攝的需求比較簡單,絕大多數時候都是使用 ROM 自帶的相機應用來拍照,因為這類 app 比較簡單,native 內存水位本身很低,很難觸發到虛擬內存的上限,所以此類問題并沒暴露出來。隨著小視頻等 app 的興起,拍攝需求越來越重(特效&美顏等),app 也越來越復雜,應用自身的 native 內存水位不斷上漲,加上 native 內存泄漏等原因,當長時間停留在拍攝頁時,這類問題就很容易觸發。
此外,CameraMetadata 的內存分配失敗時,并不會直接 crash,這個時候有其他內存分配請求時才會觸發 crash(如線程創建、GL 內存分配等),這也是很多拍攝過程中相機黑屏問題的根本原因。該方案也不經意間解決了長期存在的拍攝時相機黑屏的疑難問題。
這類問題既有應用自身的原因,也有內存回收策略設計的原因。應用在盡可能減少泄漏的同時,也應該努力降低自身 native 內存水位。AOSP 里利用 Java 的 finalize 方法來釋放其間接引用的 native 內存是個偷懶挖坑的設計,類似的案例在 AOSP 里比比皆是。我們在實際開發中,類似內存這種有限的資源應及時回收,甚至可以主動限定對象的生命周期,一旦完成使命就主動回收其占用的內存,避免使用 finalize 邏輯來釋放 native 內存。
文中提高的兩個工具(Native 內存監控工具 Raphael & Android 堆內存快照裁剪壓縮工具)是西瓜視頻 Android 團隊在長期的內存優化治理中開發的兩套高效實用的基礎工具,在我司內部各大 app 中應用非常廣泛,是內存優化&穩定性治理的絕對首選。這兩套工具我們也會在后續的監控工具建設&優化治理實踐等技術文章中介紹相關技術細節,敬請關注。
更多分享
字節跳動自研線上引流回放系統的架構演進
字節跳動表格存儲中的事務
iOS大解密:玄之又玄的KVO
今日頭條 Android '秒' 級編譯速度優化
字節跳動-西瓜視頻 Android 團隊
字節跳動-西瓜視頻 Android 團隊是負責字節跳動旗下西瓜視頻 App 研發的客戶端團隊,團隊在滿足業務高速迭代的同時,持續優化性能和體驗,提升研發效率,探索 Flutter 等跨平臺方案。我們長期招聘業務研發、架構師、Flutter 工程師、骨干工程師、實習生,在北京、杭州、上海三地均有職位。業務體量大,團隊成長快,技術挑戰大,歡迎各路人才加入!聯系郵箱: [email protected] ;郵件標題:姓名-工作年限-西瓜-Android。