本文通過(guò)一類 Android 機(jī)型上相機(jī)拍攝過(guò)程中的 native 內(nèi)存 OOM 的問(wèn)題展開,借助內(nèi)存快照裁剪回?fù)坪?Native 內(nèi)存監(jiān)控工具的賦能贷盲,來(lái)深入剖析此類問(wèn)題淘这。
背景
Raphael 是西瓜視頻 Android 團(tuán)隊(duì)開發(fā)的一款 native 內(nèi)存監(jiān)控工具,在字節(jié)跳動(dòng)內(nèi)部產(chǎn)品(如西瓜巩剖、抖音铝穷、頭條等)上廣泛用于監(jiān)控 native 內(nèi)存泄漏問(wèn)題。在抖音 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)存分配失敗的日志朦佩。
問(wèn)題
通過(guò)對(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)存分配的頻率過(guò)高弄砍,獲取 Java 層堆棧又比較耗時(shí)仙畦,在攔截 native 內(nèi)存分配時(shí)并不適合直接頻繁抓取 Java 堆棧。Native 內(nèi)存不同于 Java 內(nèi)存音婶,單從攔截到的數(shù)據(jù)很難直觀給出結(jié)論慨畸。通常對(duì)于內(nèi)存等資源不合理使用導(dǎo)致的資源不足而引發(fā)的問(wèn)題都很難歸因,從攔截到的數(shù)據(jù)來(lái)看衣式,CameraMetaData 所引用的內(nèi)存最大寸士,嫌疑也最大檐什,基于此決定剖析一下這個(gè)問(wèn)題
初步分析
分析 native 內(nèi)存的分配和釋放
通過(guò)攔截到的堆棧可以看出弱卡,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)存瓮具;通過(guò)源碼可知,每個(gè) CameraMetadata 對(duì)象的 mBuffer 所指向的 camera_metadata_t 是獨(dú)立的凡蜻,彼此是不重疊的搭综。
既然工具能攔截到這么多的未釋放的內(nèi)存分配,一定是因?yàn)檫@些內(nèi)存的釋放邏輯出問(wèn)題導(dǎo)致的划栓,我們需要優(yōu)先調(diào)查清楚 CameraMetadata.mBuffer 的釋放邏輯。通過(guò)分析 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ú)立的修档。通過(guò)工具攔截到的堆棧和分配數(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)的照激。
通過(guò) 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)用的。
通過(guò)上述分析可知只有在 CameraMetaDataNative 對(duì)象執(zhí)行 finalize 方法時(shí)才會(huì)回收與之對(duì)應(yīng)的 native 內(nèi)存口柳,而 finalize 方法又是在 FinalizerDaemon 線程里執(zhí)行的苹粟,猜測(cè)到如果發(fā)生了上述堆棧的 native OOM,Java 層一定存在大量還沒(méi)有執(zhí)行 finalize 方法的 CameraMetaDataNative 對(duì)象啄清。
排查 Java 堆現(xiàn)場(chǎng)
幸運(yùn)的是我們通過(guò)內(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)證了問(wèn)題浅缸,也證實(shí)了之前的猜想轨帜,但仍未找到導(dǎo)致此類問(wèn)題的深層次原因,對(duì)于最終解決此類問(wèn)題也仍然束手無(wú)策衩椒。為什么會(huì)有這么多的 CameraMetadataNative 對(duì)象等待執(zhí)行 finalize 方法或許是下一步的調(diào)查方向蚌父。做過(guò) 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ì)通過(guò)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)致的僻他。
通過(guò)分析 FinalizerDaemon 的源碼猜測(cè)還有另外一種可能,就是該線程的核心邏輯可能 block 在某個(gè)同步邏輯上腊尚,根據(jù)判有兩處代碼有可能:一個(gè)是FinalizerWatchdogDaemon.INSTANCE.goToSleep()?另一個(gè)是finalizingReference = (FinalizerReference<?>)queue.remove()
源碼顯示 goToSleep 是個(gè)同步方法吨拗,可能會(huì) block。但遍歷所有相關(guān)快照發(fā)現(xiàn)所有的 needToWork 屬性均是 false婿斥,證明已經(jīng)走過(guò)(只有FinalizerWatchdogDaemon.INSTANCE.goToSleep()會(huì)置為 false劝篷,而且這個(gè)函數(shù)是 private 的,只在 FinalizerDaemon 線程里調(diào)用)民宿,所以 block 在這里的可能性幾乎沒(méi)有娇妓。
通過(guò)分析finalizingReference = (FinalizerReference<?>)queue.remove()發(fā)現(xiàn)這行代碼后面的邏輯并沒(méi)有對(duì)finalizingReference?判空,說(shuō)明這個(gè)地方一定不會(huì)返回空活鹰。既然不為空哈恰,queue.remove()只能 block 等待只估,這個(gè) ReferenceQueue.java 的源碼也證實(shí)了猜想。
其實(shí) block 在這里的原因通常是因?yàn)橹挥性?GC 時(shí)才會(huì)將需要執(zhí)行 finalize 的對(duì)象加入到 FinalizerDaemon 的隊(duì)列里着绷。如果一段時(shí)間內(nèi)沒(méi)有 GC蛔钙,且隊(duì)列就為空時(shí),上面的 remove 會(huì)一直 block荠医,直到 GC 后才有對(duì)象加入到這個(gè)隊(duì)列里吁脱。巧合的是我們?cè)诎l(fā)生這類 native OOM 時(shí)會(huì)通過(guò) Tailor 主動(dòng) dump Java 堆的內(nèi)存快照,而 dump 快照時(shí)會(huì)觸發(fā) GC & suspend彬向,這個(gè)最終導(dǎo)致大量的 CameraMetadataNative 對(duì)象被同時(shí)加入到 FinalizerDaemon.queue 的隊(duì)列里兼贡。
分析 GC 策略
通過(guò)上述分析可知如果不是 GC,這些對(duì)象是不會(huì)被被加入到 FinalizerDaemon.queue 里的娃胆,這說(shuō)明這類 native OOM 發(fā)生前的一段時(shí)間內(nèi)一直沒(méi)有 GC遍希,才導(dǎo)致大量 CameraMetadataNative 對(duì)象沒(méi)有及時(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ù)到正常水平枷畏。雖然問(wèn)題不是 block 在 finalize 環(huán)節(jié)别厘,但最終這個(gè)問(wèn)題的原因被鎖定在了 GC 邏輯上!
了解 GC 的同學(xué)可能會(huì)知道 ART 虛擬機(jī)的 GC cause 有很多種拥诡,kGcCauseForAlloc/kGcCauseBackground 是虛擬機(jī)最易頻繁觸發(fā)的触趴。當(dāng)停留在拍攝頁(yè)不做任何操作時(shí),程序邏輯相對(duì)簡(jiǎn)單渴肉,這期間只有相機(jī)服務(wù)周期(>=30 次/s)地通過(guò) binder 在應(yīng)用端觸發(fā)創(chuàng)建 CameraMetadataNative 對(duì)象冗懦,并在拍攝頁(yè)顯示一張相機(jī)采集到的圖像。這個(gè)過(guò)程 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)存的增幅過(guò)大没讲,在下次 GC 之前觸頂就發(fā)生 native OOM
綜上,這類 native OOM 的根本原因是:當(dāng)應(yīng)用自身的 native 內(nèi)存本身已處于高水位時(shí)礁苗,開啟相機(jī)后爬凑,相機(jī)服務(wù)會(huì)持續(xù)通過(guò) binder 通信在應(yīng)用側(cè)創(chuàng)建 CameraMetadataNative 對(duì)象,創(chuàng)建 CameraMetadataNative 對(duì)象的同時(shí)也會(huì)在應(yīng)用側(cè)通過(guò) 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潘靖。
解決思路
問(wèn)題的原因雖然相對(duì)比較簡(jiǎn)單穿剖,但如何解決這類問(wèn)題還是比較難抉擇的。既然是 GC 不及時(shí)導(dǎo)致的秘豹,一種簡(jiǎn)單的方案就是在拍攝頁(yè)周期性觸發(fā) GC携御。但如果 GC 間隔比較小,GC 畢竟是耗時(shí)的既绕,GC 過(guò)于頻繁會(huì)嚴(yán)重影響拍攝體驗(yàn)啄刹;如果 GC 間隔時(shí)間比較長(zhǎng),還是會(huì)有大概率重蹈這類 native OOM 的覆轍凄贩。
主動(dòng)觸發(fā) GC 的方案很難平衡對(duì)性能的影響誓军。其實(shí)問(wèn)題的重點(diǎn)不是 Java 層,而是 Java 對(duì)象引用的 native 內(nèi)存疲扎,如果及時(shí)主動(dòng)釋放這部分內(nèi)存就可以從根本上徹底解決此類問(wèn)題昵时。通過(guò)前面的分析可以知道,這部分內(nèi)存原本是在 GC 時(shí)的 finalize 環(huán)節(jié)回收椒丧,但如果提前發(fā)現(xiàn) CameraMetadataNative 不再使用時(shí)壹甥,主動(dòng)觸發(fā)來(lái)釋放這部分內(nèi)存就可以一勞永逸。通過(guò)分析源碼可以發(fā)現(xiàn) CameraMetadataNative 傳遞到應(yīng)用層之后后續(xù)并未再使用壶熏,在應(yīng)用層使用完 CameraMetadataNative 對(duì)象之后句柠,通過(guò)反射調(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àn)影鹰贵!
總結(jié)
此類問(wèn)題存在時(shí)間很久,至少?gòu)?Android 4.4 開始都是通過(guò) CameraMetadataNative 的 finalize 函數(shù)來(lái)釋放 native 內(nèi)存康嘉。過(guò)去拍攝的需求比較簡(jiǎn)單碉输,絕大多數(shù)時(shí)候都是使用 ROM 自帶的相機(jī)應(yīng)用來(lái)拍照,因?yàn)檫@類 app 比較簡(jiǎn)單亭珍,native 內(nèi)存水位本身很低敷钾,很難觸發(fā)到虛擬內(nèi)存的上限枝哄,所以此類問(wèn)題并沒(mé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í),這類問(wèn)題就很容易觸發(fā)羊壹。
此外蓖宦,CameraMetadata 的內(nèi)存分配失敗時(shí),并不會(huì)直接 crash油猫,這個(gè)時(shí)候有其他內(nèi)存分配請(qǐng)求時(shí)才會(huì)觸發(fā) crash(如線程創(chuàng)建稠茂、GL 內(nèi)存分配等),這也是很多拍攝過(guò)程中相機(jī)黑屏問(wèn)題的根本原因情妖。該方案也不經(jīng)意間解決了長(zhǎng)期存在的拍攝時(shí)相機(jī)黑屏的疑難問(wèn)題睬关。
這類問(wèn)題既有應(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)存阱州。