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

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


既然工具能攔截到這么多的未釋放的內(nèi)存分配,一定是因?yàn)檫@些內(nèi)存的釋放邏輯出問題導(dǎo)致的,我們需要優(yōu)先調(diào)查清楚 CameraMetadata.mBuffer 的釋放邏輯。通過分析 CameraMetadata.cpp 的源碼可知,CameraMetadata::release()并未釋放 mBuffer 所指向的內(nèi)存,而是把 mBuffer 所指向的內(nèi)存賦值給了另一個(gè) CameraMetadata 對(duì)象;CameraMetadata::clear()是真釋放,而 clear 的調(diào)用有兩個(gè)場(chǎng)景:一個(gè)是在 camera_metadata_t 復(fù)用時(shí),另一個(gè)是 CameraMetadata 對(duì)象析構(gòu)時(shí)。

前述結(jié)論可知 CameraMetadata.mBuffer 所指向的 camera_metadata_t 是彼此獨(dú)立的。通過工具攔截到的堆棧和分配數(shù)量猜測(cè),Native OOM 時(shí)內(nèi)存中一定存在大量的 CameraMetadata 實(shí)例。C++對(duì)象的析構(gòu)通常是調(diào)用 delete 來(lái)實(shí)現(xiàn)的,AOSP 里想搜索哪里 delete 了一個(gè) CameraMetaData 對(duì)象是很難的,因?yàn)楹茈y知道 delete 時(shí)的變量名。根據(jù)一個(gè)基本的 C++編程規(guī)范,內(nèi)存通常在哪里創(chuàng)建的,應(yīng)該就在那里釋放,我們?nèi)炙阉?new CameraMetaData 字符串就可以很輕松的發(fā)現(xiàn) CameraMetaData 對(duì)象的創(chuàng)建和釋放均是在/frameworks/base/core/jni/android_hardware_camera2_CameraMetadata.cpp里實(shí)現(xiàn)的。



通過 android_hardware_camera2_CameraMetadata.cpp 里的注冊(cè)清單可以看到與這些函數(shù)關(guān)聯(lián)的 Java 層 class 是android/hardware/camera2/impl/CameraMetadataNative,CameraMetadata_close 函數(shù)在 Java 對(duì)應(yīng)的是 nativeClose 函數(shù)。可以進(jìn)一步發(fā)現(xiàn) CameraMetaDataNative 里 nativeClose 函數(shù)是在 close 函數(shù)里調(diào)用的,而 close 函數(shù)又是在 finalize 函數(shù)調(diào)用的。


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

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

結(jié)合 FinalizerDaemon 的源碼可以看到,每執(zhí)行一個(gè)對(duì)象的 finalize 方法時(shí),都會(huì)通過finalizingObject屬性記錄當(dāng)前的對(duì)象。如果真的是 finalize 超時(shí)導(dǎo)致的,一定存在 finalizingObject 屬性不為空的現(xiàn)場(chǎng)。我們?cè)诒闅v完所有相關(guān)內(nèi)存快照里的 FinalizerDaemon 線程狀態(tài)后發(fā)現(xiàn),這些現(xiàn)場(chǎng)的 finalizingObject 屬性均為空。這個(gè)結(jié)果很意外,似乎并不是某個(gè)對(duì)象的 finalize 方法執(zhí)行超時(shí)導(dǎo)致的。

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

源碼顯示 goToSleep 是個(gè)同步方法,可能會(huì) block。但遍歷所有相關(guān)快照發(fā)現(xiàn)所有的 needToWork 屬性均是 false,證明已經(jīng)走過(只有FinalizerWatchdogDaemon.INSTANCE.goToSleep() 會(huì)置為 false,而且這個(gè)函數(shù)是 private 的,只在 FinalizerDaemon 線程里調(diào)用),所以 block 在這里的可能性幾乎沒有。


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


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

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

線下實(shí)驗(yàn)也可以發(fā)現(xiàn),開啟主動(dòng)回收策略后,Native 內(nèi)存的增長(zhǎng)速度比之前大幅降低。這期間 Java 堆& native 層仍有持續(xù)增加的小對(duì)象,但 native 的增長(zhǎng)速度遠(yuǎn)小于 Java 層了,這種場(chǎng)景下 Java 內(nèi)存會(huì)在 native 內(nèi)存觸頂之前先觸發(fā) GC,而大幅降低了發(fā)生 native OOM 的可能

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