native 內(nèi)存泄漏檢測(cè)工具之Raphael

簡(jiǎn)介:

Raphael 是西瓜視頻基礎(chǔ)技術(shù)團(tuán)隊(duì)開發(fā)的一款 native 內(nèi)存泄漏檢測(cè)工具,廣泛用于字節(jié)跳動(dòng)旗下各大 App 的 native 內(nèi)存泄漏治理,收益顯著。工具現(xiàn)已開源,本文將通過原理、方案和實(shí)踐來剖析 Raphael 的相關(guān)細(xì)節(jié)。

背景

Android 平臺(tái)上的內(nèi)存問題一直是性能優(yōu)化和穩(wěn)定性治理的焦點(diǎn)和痛點(diǎn),Java 堆內(nèi)存因?yàn)橛斜容^成熟的工具和方法論,加上 hprof 快照作為補(bǔ)充,定位和治理都很方便。而 native 內(nèi)存問題一直缺乏穩(wěn)定、高效的工具,僅有的 malloc debug,不僅性能和穩(wěn)定性難以滿足需要,還存在 Android 版本兼容的問題。

現(xiàn)狀事實(shí)上,native內(nèi)存泄漏治理一直不乏優(yōu)秀的工具,已知的可用于調(diào)查 native
內(nèi)存泄漏問題的工具主要有:LeakTracer、MTrace、MemWatch、Valgrind-memcheck、TCMalloc、LeakSanitizer等。
但由于 Android 平臺(tái)的特殊性,這些工具要么不兼容,要么接入成本過高,很難在 Android
平臺(tái)上落地。這些工具的原理基本都是:先代理內(nèi)存分配/釋放相關(guān)的函數(shù)(如:malloc/calloc/realloc/memalign/free),再通過unwind 回溯調(diào)用堆棧,最后借助緩存管理過濾出未釋放的內(nèi)存分配記錄。
因此,這些工具的主要差異也就體現(xiàn)在代理實(shí)現(xiàn)、?;厮莺途彺婀芾砣齻€(gè)方面。
根據(jù)這些工具代理實(shí)現(xiàn)的差異,大致可以分為hook 和 LD_PRELOAD 兩大類,典型的如 malloc debug [5] 和 LeakTracer。

malloc debug:
malloc debug 是 Android 系統(tǒng)自帶的內(nèi)存調(diào)試工具(官方 Native 內(nèi)存調(diào)試 有相關(guān)介紹,雖然沒有額外的接入代碼,但開啟方式和核心功能等都受 Android 版本限制。

我們?cè)诰€下嘗試使用 malloc debug 監(jiān)控西瓜視頻 App(配置 wrap.sh)時(shí)發(fā)現(xiàn),正常啟動(dòng)時(shí)間小于 1s 的機(jī)型(Pixel 2 & Android 10),其冷啟動(dòng)時(shí)間被拉長(zhǎng)到了 11s+。而且在正常使用過程中滑動(dòng)時(shí)的卡頓感非常明顯,頁面切換時(shí)耗時(shí)難以接受,監(jiān)控過程中應(yīng)用的使用體驗(yàn)極差。不僅如此,西瓜視頻在 malloc debug 監(jiān)控過程中還會(huì)遇到必現(xiàn)的棧回溯 crash(堆棧如下,《libunwind llvm 編年史》[8] 有相關(guān)分析)。

LeakTracer:

是另一個(gè)比較知名的內(nèi)存泄漏監(jiān)控工具,其原理是:通過 LD_PRELOAD 機(jī)制搶先加載一個(gè)定義了
malloc/calloc/realloc/memalign/free 等同名函數(shù)的代理庫,這樣就全局代理了應(yīng)用層內(nèi)存的分配和釋放,通過unwind 回溯調(diào)用棧并過濾出疑似的內(nèi)存泄漏信息。Android 平臺(tái)上的 LD_PRELOAD 是被嚴(yán)格限制的,因?yàn)槠錄]有獨(dú)立的unwind 實(shí)現(xiàn),依賴系統(tǒng)的 unwind 能力,也會(huì)遇到 malloc debug 遇到的棧幀兼容問題;

如果把 LeakTracer集成到目標(biāo) so 里通過 override 方式實(shí)現(xiàn)代理,只能攔截到本 so 里顯式的內(nèi)存分配/釋放,無法攔截到其他 so 和跨 so調(diào)用的內(nèi)存分配/釋放。通過 native 插樁的方式也是如此,只能監(jiān)控局部單純的內(nèi)存泄漏,無法全局監(jiān)控內(nèi)存使用。

=== 綜合以上分析和接入體驗(yàn),我們不難發(fā)現(xiàn),這些內(nèi)存泄漏監(jiān)控工具在 Android 平臺(tái)上實(shí)際接入時(shí)基本都存在以下三個(gè)比較典型的問題 ===

流程繁瑣:需要配置 wrap.sh/root permission/setprop 等,受 Android 版本限制

兼容問題:unwind 庫存在嚴(yán)重的兼容性問題,libunwind_llvm 無法正確回溯 GNU 編譯的棧幀

性能問題:官方的 malloc debug 性能數(shù)據(jù)是損失 10 倍以上,實(shí)測(cè)西瓜開啟后在中高端機(jī)上不可用

我們的需求:

西瓜視頻App 是一個(gè)匯集了視頻播放、特效拍攝、視頻剪輯輯、P2P 加速等 native 代碼非常多的中大型應(yīng)用,每個(gè) native代碼相關(guān)的模塊背后都有一個(gè)專業(yè)團(tuán)隊(duì)在高速迭代,加上日人均使用時(shí)長(zhǎng)超過 100 分鐘的影響,西瓜視頻 App 的 native內(nèi)存問題治理難度非常大。事實(shí)上,單純的內(nèi)存泄漏問題相對(duì)較少,更多的是因?yàn)闃I(yè)務(wù)邏輯不合理帶來的內(nèi)存使用問題,需要工具滲透到 App

運(yùn)行的過程中進(jìn)行監(jiān)控,無形中提高了對(duì)工具性能和穩(wěn)定性的要求。線上native 內(nèi)存問題基本都是以虛擬內(nèi)存觸頂?shù)男问奖┞冻鰜淼?。在西瓜視頻 App里,虛擬內(nèi)存的消耗除了上述幾大模塊外,還有其他幾個(gè)消耗大戶,如線程、webview、Flutter、硬件加速、顯存等。

事實(shí)上,malloc/calloc/realloc/memalign,等相對(duì)于 mmap/mmap64直接分配出的內(nèi)存在整個(gè)虛擬內(nèi)存空間中通常占比比較小。因?yàn)閮?nèi)存問題通常以虛擬內(nèi)存耗盡的形式表現(xiàn)出來,只有盡可能多的收集各種內(nèi)存消耗來無限逼近虛擬內(nèi)存上限,才能準(zhǔn)確找出虛擬內(nèi)存耗盡的原因。

因此,像malloc debug 這樣只監(jiān)控 malloc/calloc/realloc/memalign/free 等根本無法滿足內(nèi)存治理需要,覆蓋mmap/mmap64/munmap 等盡可能多的內(nèi)存分配形式是監(jiān)控工具必須要做的。

綜合上面的分析可以得出,西瓜視頻 App 乃至整個(gè)字節(jié)跳動(dòng)旗下其他 App, 對(duì)于一個(gè)通用的 native 內(nèi)存泄漏監(jiān)控工具的訴求主要有以下幾個(gè)方面:
接入層面:不依賴 Android 版本,無需 root,對(duì)業(yè)務(wù)滲透盡可能低
穩(wěn)定性:不存在影響業(yè)務(wù)的穩(wěn)定性問題,可以滿足線上使用的訴求
性能層面:沒有明顯的性能問題,達(dá)到可線上使用的標(biāo)準(zhǔn)
監(jiān)控范圍:不局限于 malloc/calloc/realloc/memalign/free,至少還能覆蓋 mmap/mmap64/munmap

Raphael 核心設(shè)計(jì):

通過前面的分析可以知道,一個(gè)完整的 native 內(nèi)存泄漏監(jiān)控工具主要包含三部分:代理實(shí)現(xiàn)、?;厮莺途彺婀芾?。
代理實(shí)現(xiàn)是解決 Android 平臺(tái)上接入問題的關(guān)鍵,棧回溯是性能和穩(wěn)定性的核心,緩存邏輯在一定程度上也會(huì)直接影響性能和穩(wěn)定性。接下來我們會(huì)從四個(gè)方面介紹 Raphael的核心設(shè)計(jì)。

代理實(shí)現(xiàn), 鑒于wrap.sh 和 LD_PRELOAD 在 Android 平臺(tái)上不具有通用性,首先被排除。又因 malloc hook 只能代理malloc/calloc/realloc/free,無法覆蓋 mmap/mmap64/munmap,也被放棄。但受 malloc hook實(shí)現(xiàn)方式的啟發(fā),借助于 inline hook / PLT hook 工具我們可以實(shí)現(xiàn)同樣的代理效果,這其中比較有代表性的工具主要有

Android-Inline-Hook[3] 和 xHook[1]。

xHook 是比較優(yōu)秀的 PLT hook 工具代表,其穩(wěn)定性可以達(dá)到上線標(biāo)準(zhǔn)。因其實(shí)現(xiàn)依賴正則,同時(shí) hook 的 so 或函數(shù)比較多時(shí),hook 耗時(shí)會(huì)比較明顯。此外,其原生實(shí)現(xiàn)只能 hook 當(dāng)前已經(jīng)加載的 so,對(duì)于未加載的沒做特殊處理,如果用來做長(zhǎng)時(shí)間的進(jìn)程級(jí)監(jiān)控,需要解決增量 so hook 問題。不過這種 hook 方式非常適合做 so 定向監(jiān)控。

與PLT hook 原理不同,inline hook 則是在目標(biāo)函數(shù)的頭部直接插入跳轉(zhuǎn)指令,其 hook 的是最終的函數(shù)實(shí)現(xiàn),不存在增量 sohook 問題,hook 效率高效直接。但 inline hook 在 hook 那些可能正在執(zhí)行的函數(shù)后,需要掛起相關(guān)線程進(jìn)行指令修正,這個(gè)是inline hook 的痛點(diǎn),現(xiàn)有 hook 實(shí)現(xiàn)很多沒有做指令修復(fù),或者在指令修復(fù)時(shí)或多或少都存在一些問題。

Raphael
在早期的驗(yàn)證版本里采用 xHook 來實(shí)現(xiàn)代理接入。后續(xù)為了實(shí)現(xiàn)長(zhǎng)時(shí)間進(jìn)程級(jí)監(jiān)控,以覆蓋更多的業(yè)務(wù)場(chǎng)景,Raphael 又通過Android-Inline-Hook 解決增量 so hook 問題,通過 xHook 實(shí)現(xiàn)定向監(jiān)控。為了進(jìn)一步提升工具的性能和穩(wěn)定性,Raphael 內(nèi)部最新版本已切換到了 bytehook(字節(jié)跳動(dòng)自研的 PLT hook 工具,可自動(dòng)處理增量 so hook 問題)。

棧回溯
定位一個(gè)對(duì)象或者一段內(nèi)存通常可以通過引用/依賴關(guān)系,也可以通過創(chuàng)建/分配時(shí)的堆棧。
Java堆內(nèi)存因?yàn)橛忻鞔_的組織形式和清晰的依賴關(guān)系,可以通過依賴關(guān)系靜態(tài)分析內(nèi)存泄漏問題。但 native 堆內(nèi)存依賴/引用比較隱晦,也沒有 Java堆內(nèi)存那樣明確的組織格式,無法通過依賴/引用關(guān)系進(jìn)行靜態(tài)分析,只能通過分配時(shí)的堆棧來輔助定位。?;厮荩╱nwind)是 native層獲取調(diào)用堆棧的通用方式,是 native內(nèi)存泄漏監(jiān)控工具不可或缺的核心,同時(shí)也是工具性能和穩(wěn)定性的瓶頸所在。接下來本文將從棧回溯工具選取、限制棧回溯頻次、減少無用?;厮萑齻€(gè)方面介紹

Raphael 在?;厮萆纤龅墓ぷ鳌?br> 棧回溯工具選取Android平臺(tái)上常用的 32 位?;厮輲熘饕校簂ibunwind_llvm、libunwind (nongnu)、libgcc_s、libudf、libbacktrace、libunwindstack等,實(shí)踐證實(shí)這些工具或多或少都存在一些問題,以下是我們基于三個(gè)主流的?;厮輲熳龅暮?jiǎn)單對(duì)比分析(平臺(tái):Pixel 2 & Android 10,性能:Demo 里統(tǒng)計(jì) 16 層棧幀回溯的總耗時(shí);兼容性:字節(jié)跳動(dòng)旗下多個(gè)應(yīng)用長(zhǎng)時(shí)間的優(yōu)化治理實(shí)踐)

?;厮萆婕暗降臇|西比較多,想要自己短時(shí)間內(nèi)實(shí)現(xiàn)一個(gè)在穩(wěn)定性、回溯性能、回溯成功率等方面都表現(xiàn)優(yōu)異的 32 位棧回溯工具難度非常大。為了快速驗(yàn)證并解決實(shí)際機(jī)問題,Raphael 在早期版本里采用的是 libunwind_llvm,隨后切換到 libunwind_llvm & libunwind (nongnu),通過 libunwind_llvm 保證回溯性能,在回溯深度低于 2 層時(shí)切換到 libunwind (nongnu),以保證回溯成功率。最新版本里則采用的是 libudf,兼具了性能和回溯成功率。相對(duì)而言,64 位下基于 FP 的棧回溯實(shí)現(xiàn)性能和穩(wěn)定性基本都能滿足需求,這里不做過多介紹。Rapahel 同時(shí)也在設(shè)計(jì)時(shí)做了充分的擴(kuò)展考慮,可以輕松切換到其他更優(yōu)秀的?;厮輰?shí)現(xiàn)。

限制棧回溯頻次, 即便是libudf 實(shí)現(xiàn),其在 demo 里回溯 16 層棧幀的平均耗時(shí)也需要 0.6ms,監(jiān)控工具實(shí)際運(yùn)行時(shí)對(duì) App性能的影響是很明顯的。提升監(jiān)控性能的途徑除了直接優(yōu)化?;厮菪阅芡猓瑴p少回溯頻次也是十分有效的手段。我們?cè)谖鞴弦曨l App的優(yōu)化治理實(shí)踐中發(fā)現(xiàn),多數(shù)場(chǎng)景小于 1024 byte 的內(nèi)存分配其頻率約占 70% 以上,但線上遇到的 native內(nèi)存觸頂問題,卻很少是由小內(nèi)存泄漏引發(fā)的,監(jiān)控小內(nèi)存泄漏對(duì)于解決線上 native 內(nèi)存觸頂問題沒有實(shí)質(zhì)效果。即便真的是由小內(nèi)存引發(fā)的,這個(gè)需要高頻和必現(xiàn)的場(chǎng)景才能達(dá)到,這類問題通常在線下單測(cè)(定向監(jiān)控)場(chǎng)景是完全可以覆蓋到的。

基于此,Raphael 通過設(shè)定內(nèi)存閾值來控制?;厮蓊l次,可以大幅降低?;厮莸男阅軗p耗。

減少無用?;厮?/p>

受限于代理流程和?;厮輰?shí)現(xiàn)機(jī)制,從代理函數(shù)入口到回溯開始的路徑上會(huì)存在幾層跟分配堆棧無關(guān)的函數(shù)調(diào)用,這幾層調(diào)用最終會(huì)體現(xiàn)在最后回溯成功的堆棧上(下圖的紅色部分),每次內(nèi)存分配都回溯這幾層無用的調(diào)用鏈?zhǔn)鞘謸p耗性能的。解決這種問題的直觀方法就是減少甚至完全規(guī)避這種無關(guān)的?;厮?,體現(xiàn)在代碼層面就是減少代理入口到回溯開啟函數(shù)之間的調(diào)用層級(jí)。
inline是一種簡(jiǎn)單直接的實(shí)現(xiàn)方式,也可以直接在代理入口處提前構(gòu)建回溯的 context 數(shù)據(jù)。

緩存管理

緩存管理作為 native 內(nèi)存監(jiān)控的重要一環(huán),對(duì)整個(gè)監(jiān)控工具性能的影響至關(guān)重要。以 malloc debug 和LeakTracer為例,它們都是通過分配后的內(nèi)存地址作為 key 來計(jì)算 hash 后散列存儲(chǔ)的,并通過一個(gè)全局鎖來同步緩存更新的時(shí)序。兩者不同的是,malloc debug 會(huì)通過堆棧聚合調(diào)用鏈完全相同的內(nèi)存分配記錄,其緩存的存儲(chǔ)單元通過 malloc 動(dòng)態(tài)分配;而 LeakTracer 則不會(huì)根據(jù)堆棧聚合,其存儲(chǔ)單元會(huì)預(yù)先分配一部分,緩存不足時(shí)也會(huì)動(dòng)態(tài)申請(qǐng)。

通過以上分析和實(shí)測(cè)可以發(fā)現(xiàn),malloc debug 的實(shí)際性能比LeakTracer 低很多,原因主要體現(xiàn)在堆棧聚合和緩存動(dòng)態(tài)分配上。

image

對(duì)比
malloc debug 和 LeakTracer 的源碼也可以發(fā)現(xiàn):運(yùn)行時(shí)的堆棧聚合是完全沒有必要的;如果限制內(nèi)存監(jiān)控的閾值,緩存空間和緩存單元的上限都可以控制在一定范圍內(nèi)的,不需要?jiǎng)討B(tài)申請(qǐng),可以減少動(dòng)態(tài)分配的性能損耗;此外,由于 native 內(nèi)存分配和釋放頻率比較高,全局鎖一定程序上會(huì)影響整體性能,通過 key 計(jì)算 hash 后再散列存儲(chǔ)時(shí)不需要全局鎖。

Raphael

是預(yù)先分配固定大小的緩存空間,除了發(fā)生內(nèi)存觸頂導(dǎo)致的 crash 外,緩存單元提前耗完也認(rèn)為存在內(nèi)存泄漏問題。這主要是因?yàn)椋簩?duì)于 32 位進(jìn)程,其虛擬內(nèi)存的上限通常是 4G,正常運(yùn)行時(shí)相對(duì)比較容易觸達(dá)上限,而 64 位進(jìn)程的虛擬地址空間非常大,實(shí)際很難遇到虛擬內(nèi)存觸頂?shù)?case,但遇到物理內(nèi)存不足的概率則要大很多,這與 32 位進(jìn)程基本相反。通過控制 vmPeak 閾值和緩存單元余量可以有效捕捉到內(nèi)存泄漏數(shù)據(jù),最終實(shí)現(xiàn)穩(wěn)定可靠的全自動(dòng)內(nèi)存泄漏監(jiān)控及消費(fèi)流程

監(jiān)控范圍

通過前面的分析可以知道,只監(jiān)控 malloc/calloc/realloc/memalign/free 是無法滿足治理需求的,這主要是因?yàn)?malloc/calloc/realloc/memalign/free 等分配出的內(nèi)存通常在整個(gè)虛擬內(nèi)存空間里占比較小,常見的內(nèi)存消耗大戶 Thread、webview、Flutter、硬件加速、顯存等,都不是通過這些函數(shù)分配出的。為了能夠?qū)?Android 平臺(tái)上的 native 內(nèi)存觸頂問題精準(zhǔn)歸因,監(jiān)控需要無限逼近虛擬內(nèi)存的上限,這就需要監(jiān)控盡可能多的內(nèi)存分配形式。

Android

上的內(nèi)存操作主要是 malloc/calloc/realloc/memalign/free 和 mmap/mmap64/munmap,同監(jiān)控
malloc/calloc/realloc/memalign/free 相比,監(jiān)控 mmap/mmap64/munmap 有兩點(diǎn)不同:一個(gè)是線程棧的釋放問題,雖然創(chuàng)建線程時(shí)是通過 mmap/mmap64 分配的棧內(nèi)存,但棧內(nèi)存的釋放并不一定是通過顯式調(diào)用 munmap 實(shí)現(xiàn)的;另一個(gè)是監(jiān)控重入問題,當(dāng)通過 malloc/calloc/realloc/memalign 等分配大內(nèi)存時(shí),底層通常是通過 mmap/mmap64 實(shí)現(xiàn)的,兩類接口同時(shí)監(jiān)控時(shí)會(huì)存在重入問題。

棧內(nèi)存釋放

線程的棧內(nèi)存又分為信號(hào)棧和執(zhí)行棧,信號(hào)棧在調(diào)用*void pthread_exit(void return_value)接口時(shí)會(huì)通過 munmap 即刻釋放,而執(zhí)行棧的釋放則有兩種形式:

void pthread_exit(voidreturn_value) 函數(shù)體里,當(dāng)線程狀態(tài)為 THREAD_DETACHED 時(shí)會(huì)直接通過 void _exit_with_stack_teardown(voidstack, size_t sz) 釋放

int pthread_join(pthread_t t, void** return_value) 里通過pthread_internal_remove_and_free,最終在pthread_internal_free 里通過 munmap 釋放

綜上,最終通過 munmap 釋放的內(nèi)存都可以被監(jiān)控到,而通過_exit_with_stack_teardown 釋放的內(nèi)存則無法攔截到。我們針對(duì)這種情況做了特殊處理:在 Raphael 里代理攔截了 void pthread_exit(void *),并判斷此時(shí)線程狀態(tài)是否為 THREAD_DETACHED,如果是則在監(jiān)控里直接移除相關(guān)記錄,否則不移除。

重入問題

下圖是一個(gè)典型的重入現(xiàn)場(chǎng),其上層的 malloc 函數(shù)最終調(diào)用到了 mmap 函數(shù),同時(shí)監(jiān)控兩類內(nèi)存接口時(shí)就會(huì)遇到此類問題。重入問題帶來的一個(gè)挑戰(zhàn)是緩存如何管理,同一個(gè)緩存里只能維護(hù)一個(gè)記錄,維護(hù)兩個(gè)記錄的邏輯和性能過于復(fù)雜。此外,從 malloc 到 mmap 的堆棧是固定的,這幾層堆棧對(duì)分析內(nèi)存泄漏完全沒用,因?yàn)檫@個(gè)時(shí)候關(guān)注的是 malloc 之上的堆棧。

解決重入問題的方案很直接,在檢測(cè)到 mmap/mmap64 之上有 malloc/calloc/realloc 等棧幀時(shí),忽略本次分配。這樣不僅解決了重入問題,也避免了不必要的?;厮?。因?yàn)?Android 平臺(tái)不支持 thread local storage(TLS),只能通過 pthread_setspecific 和 pthread_getspecific 實(shí)現(xiàn)。

綜合評(píng)估:

功能相對(duì)于 malloc , debug 和 LeakTracer,Raphael 不僅支持 malloc/calloc/realloc/memalign/free,也支持監(jiān)控 mmap/mmap64/munmap 等,使監(jiān)控范圍擴(kuò)展到了線程、webview、Flutter、顯存等,基本完全覆蓋了 Android 平臺(tái)上的 native 內(nèi)存使用場(chǎng)景

性能

Android 平臺(tái)上的 native 內(nèi)存泄漏檢測(cè)通常都是在程序運(yùn)行過程中進(jìn)行的,棧回溯和緩存管理會(huì)消耗部分 CPU 和內(nèi)存,帶來一定的性能損失。Raphael 可配置的監(jiān)控能力有很大的伸縮性,性能影響可以限制在可接受范圍內(nèi),以下數(shù)據(jù)基于西瓜視頻 App 32 位模式評(píng)測(cè)(中高端機(jī)型和 64 位下的性能更高):
CPU:32 位模式 & ≥1024 的監(jiān)控閾值下,在低端機(jī)上 CPU 消耗< 3%

內(nèi)存:32 位模式下默認(rèn)會(huì)有約 16M 的虛擬內(nèi)存消耗

幀率:32 位模式 & ≥1024 的監(jiān)控閾值下,低端機(jī)上幀率沒有明顯變化

穩(wěn)定性

已開源的版本是基于開源 inline hook 實(shí)現(xiàn)的,在部分 Android 6 機(jī)型上存在卡死問題,除此之外暫未發(fā)現(xiàn)其他穩(wěn)定性問題。此外,字節(jié)跳動(dòng)這邊早期的治理實(shí)踐集中在線下,并基于 Raphael 建設(shè)完善了線下的防治體系,更為穩(wěn)定的版本可以滿足線上的監(jiān)控需求,我們會(huì)在后續(xù)迭代開源。

治理實(shí)踐

Raphael 在字節(jié)跳動(dòng)內(nèi)部使用非常廣泛,是字節(jié)跳動(dòng) native 協(xié)會(huì)指定的 native 內(nèi)存泄漏檢測(cè)工具。在治理實(shí)踐中,Raphael 覆蓋了幾乎所有的 native 內(nèi)存使用場(chǎng)景,輔助解決了大量的 native 內(nèi)存泄漏和內(nèi)存使用不合理的問題。接下來通過四個(gè)典型的案例簡(jiǎn)單介紹下 Raphael 的監(jiān)控能力和基于 Raphael 的數(shù)據(jù)分析方法(應(yīng)用自身的,Java 層的,webview 的,系統(tǒng)層的)

案例 1

下圖是西瓜視頻里兩個(gè)比較典型的 native 內(nèi)存問題現(xiàn)場(chǎng),既有嚴(yán)格意義上的內(nèi)存泄漏(用完之后未釋放),也有更為廣泛的內(nèi)存不合理使用的問題(短暫泄漏、局部場(chǎng)景問題、上層業(yè)務(wù)邏輯問題等)。針對(duì)內(nèi)存泄漏問題,在明確了相關(guān)內(nèi)存的生命周期之后,可以相對(duì)輕松的快速定位到。對(duì)于內(nèi)存使用不合理的問題,則需要盡可能多的搜集未釋放的內(nèi)存,來綜合評(píng)估影響。

早期在分析數(shù)據(jù)時(shí),我們也會(huì)通過maps 來驗(yàn)證 Raphael 的數(shù)據(jù)。通常通過分析 maps 可以大致知道內(nèi)存觸頂?shù)脑颍聢D是一個(gè)典型的運(yùn)行時(shí)通過 malloc/calloc/realloc/memalign 和 mmap/mmap64 分配的內(nèi)存過多導(dǎo)致的 OOM 現(xiàn)場(chǎng)。

image

案例 2

下圖是字節(jié)跳動(dòng)內(nèi)部一個(gè)業(yè)務(wù)遇到的 native 內(nèi)存問題現(xiàn)場(chǎng),未接入 Raphael 前雖能輕松復(fù)現(xiàn) native 內(nèi)存增長(zhǎng)的問題,但無法定位內(nèi)存增長(zhǎng)的原因。在接入 Raphael 后,雖然攔截到的內(nèi)存并不多,但問題暴露的非常明顯。排名第一個(gè)的堆棧是 Java 層創(chuàng)建 bitmap 對(duì)象時(shí)調(diào)用到 native 層堆棧(Android 8 以后 Bitmap 的數(shù)據(jù)是存儲(chǔ)在 native 層),該問題的調(diào)查最終轉(zhuǎn)移到了 Java 層。

基于以上分析,我們可以斷定 Java 層的堆內(nèi)存里一定存在大量的 Bitmap 對(duì)象。因?yàn)樵搯栴}是線下可復(fù)現(xiàn)的,我們可以很容易的通過 Java 堆內(nèi)存快照驗(yàn)證并定位到問題原因(如下圖所示)。如果是線上,我們需要抓取異?,F(xiàn)場(chǎng)的快照才能最終定位,這也正是西瓜視頻穩(wěn)定性治理體系建設(shè)一:Tailor 原理及實(shí)踐里所提到的通用異常數(shù)據(jù)搜集建設(shè)。

案例 3

一直以來Android 設(shè)備上 webview 消耗的內(nèi)存很少被重視,隨著前端業(yè)務(wù)場(chǎng)景增多,webview 導(dǎo)致的內(nèi)存問題也越來越明顯、越來越頻繁。下圖是 Raphael 在西瓜視頻 App 里監(jiān)控到的一個(gè)前端活動(dòng)頁導(dǎo)致的內(nèi)存問題現(xiàn)場(chǎng)。由于系統(tǒng)webview自身的原因,工具無法回溯出完整的調(diào)用棧,無法直觀定位到問題原因。最終我們通過定向分析內(nèi)存數(shù)據(jù),定位到這些內(nèi)存基本都是前端頁面里緩存的圖片資源,在對(duì)該頁面的圖片緩存策略進(jìn)行優(yōu)化之后,相關(guān)的內(nèi)存觸頂?shù)漠惓4蠓档汀?/p>

案例 4

下圖是 Android 系統(tǒng)上長(zhǎng)期存在的一類 Camera 內(nèi)存泄漏現(xiàn)場(chǎng)。通過分析源碼可知,Camera 在拍攝過程中會(huì)在 native 層持續(xù)構(gòu)造 CameraMetadata 實(shí)例,而每個(gè) CameraMetadata 對(duì)象都會(huì)指向一塊不小的 native 內(nèi)存,這塊 native 內(nèi)存的釋放依賴 Java 層的 CameraMetadataNative 對(duì)象執(zhí)行 finalize 函數(shù)。這個(gè)邏輯最終導(dǎo)致這部分 native 內(nèi)存的回收間接依賴 Java 層的 GC。如果一段時(shí)間內(nèi) Java 層沒有 GC ,這部分 native 內(nèi)存就會(huì)因?yàn)闆]有及時(shí)釋放而堆積,進(jìn)而在觸頂后引發(fā)各種因 native 內(nèi)存不足而導(dǎo)致的異常。《Android Camera 內(nèi)存問題剖析》里有詳細(xì)的分析過程,《ART 視角 | 如何讓 GC 同步回收 native 內(nèi)存》針對(duì)此類問題也同步給出了方案,通過溝通 Android 團(tuán)隊(duì)表示會(huì)在后續(xù)版本里徹底修復(fù)此問題。

image

后續(xù)規(guī)劃

Native 內(nèi)存泄漏監(jiān)控的原理相對(duì)簡(jiǎn)單,但想要做到完美通用卻很困難,最主要的考驗(yàn)當(dāng)屬性能和穩(wěn)定性問題,例如 32 位?;厮莸男阅芎头€(wěn)定性、緩存管理的性能等。前期我們?cè)谡{(diào)研和開發(fā) Raphael
時(shí),基于快速落地和解決緊迫問題的目的,復(fù)用了大量第三方代碼,并簡(jiǎn)化了很多邏輯。經(jīng)過長(zhǎng)期的治理實(shí)踐,工具自身也暴露出一些問題和后續(xù)可以優(yōu)化的方向。

就代理邏輯而言,Android-Inline-Hook 和 And64InlineHook 雖然都是比較優(yōu)秀的 inline hook 工具,但實(shí)際使用時(shí)仍然存在兼容和卡死的問題。雖然 xHook 在兼容性和性能上都可以達(dá)到上線標(biāo)準(zhǔn),但不具有通用性,很難將 native 內(nèi)存泄漏監(jiān)控?cái)U(kuò)展到其他有上限的資源上(如 JNI Reference Table)。我們也在調(diào)研優(yōu)化 inline hook,探索更為穩(wěn)定高效的 hook 方案。

?;厮莺途彺婀芾硎莕ative 內(nèi)存泄漏監(jiān)控性能和穩(wěn)定性的瓶頸。相對(duì)而言,基于 FP 的 64 位?;厮莘桨敢呀?jīng)到了極致,但 32位下目前仍沒有完美理想的方案。在 32 位下,Raphael 通過限制?;厮萆疃群涂刂票O(jiān)控范圍來規(guī)避頻繁?;厮輲淼男阅苡绊?,雖然可以大幅提升性能,但也存在漏報(bào)問題。因此,32 位?;厮菪阅芤彩俏覀兒罄m(xù)的優(yōu)化方向。此外,Raphael 已開源的版本其緩存管理仍然是通過全局鎖來實(shí)現(xiàn)同步的,會(huì)有一定的性能損失,這個(gè)我們也會(huì)在后續(xù)的開源迭代里同步最新的優(yōu)化。

眾所周知,物理內(nèi)存、虛擬內(nèi)存、Thread、FD、JNI Reference Table 等都是典型的有上限的資源,不合理使用都會(huì)造成常規(guī)手段難以調(diào)查的穩(wěn)定性問題。顯而易見,內(nèi)存泄漏的監(jiān)控邏輯,同樣適用于其他這些有上限的資源。甚至于那些雖然沒有明確上限的(如 Binder、流量、耗時(shí)等),我們也可以構(gòu)造出相應(yīng)的上限來實(shí)現(xiàn)監(jiān)控和溯源?;?Raphael 擴(kuò)展其他的監(jiān)控能力是我們后續(xù)要高優(yōu)完善的。

總結(jié)

Android native 內(nèi)存泄漏話題由來已久,在此之前業(yè)界一直沒有穩(wěn)定可靠的工具可用,得益于 AOSP
和其他優(yōu)秀的開源項(xiàng)目(Android-Inline-Hook、And64InlineHook、xHook、xDL),使得我們有機(jī)會(huì)進(jìn)行相關(guān)的嘗試。Raphael 是西瓜視頻基礎(chǔ)技術(shù)團(tuán)隊(duì)的初步探索和嘗試,在字節(jié)跳動(dòng)內(nèi)部眾多 App(如西瓜、抖音、頭條)長(zhǎng)期的治理實(shí)踐中,不僅解決了大量疑難問題,也進(jìn)一步完善了工具和方法論。

雖然基于Raphael 的 native 內(nèi)存泄漏監(jiān)控方案目前已經(jīng)足夠成熟和穩(wěn)定,但其監(jiān)控過程畢竟?jié)B透到了 App 的運(yùn)行過程,會(huì)有一定程度的性能損失和穩(wěn)定性風(fēng)險(xiǎn)。我們倡導(dǎo)的方案是基于此來建設(shè)完善線下的內(nèi)存泄漏防治體系,謹(jǐn)慎帶到線上。由于內(nèi)部迭代的 Raphael 版本比較多,且涉及其他未開源的項(xiàng)目,本次開源我們只能選擇其中一個(gè)穩(wěn)定可用的版本,其他優(yōu)化會(huì)在后續(xù)逐步開源。

Raphael 只是邁開了其中的一小步,方案還有很大的優(yōu)化空間。開源不是終點(diǎn),我們希望集思廣益、共同探索完善,在 Android 穩(wěn)定性治理上走的更快更遠(yuǎn)。

作者:idaretobee
鏈接:http://m.itdecent.cn/p/2a854f98dc18
來源:簡(jiǎn)書
著作權(quán)歸作者所有。商業(yè)轉(zhuǎn)載請(qǐng)聯(lián)系作者獲得授權(quán),非商業(yè)轉(zhuǎn)載請(qǐng)注明出處。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容