作為 Android 開發(fā)者,相信大家都碰到過 Java OOM 問題,導(dǎo)致 OOM 得原因可能是應(yīng)用存在內(nèi)存泄漏,也可能是因為手機得 heapsize 比較小不能滿足復(fù)雜應(yīng)用對內(nèi)存資源得大量需求。對于 Java 內(nèi)存泄漏治理,業(yè)界已經(jīng)有比較成熟得方案,這里不做介紹,感謝主要針對第二點嘗試進行分析和優(yōu)化。
舉個例子:我們在監(jiān)控平臺查看穩(wěn)定性數(shù)據(jù),發(fā)現(xiàn) heapsize=256M 得設(shè)備發(fā)生得 OOM 崩潰最多,而 heapsize=512M 得設(shè)備很少發(fā)生 OOM 崩潰。且除此之外,還有一個特點:OOM 崩潰絕大多數(shù)發(fā)生在 Android 8.0 之前得設(shè)備。
對于這種 heapsize 較小難以滿足業(yè)務(wù)復(fù)雜度得情況,可能有以下幾種方式來解決:
1. 增加 heapsize
如果我們已經(jīng)設(shè)置了 largeHeap,也就沒有常規(guī)得提升 heapsize 得方式了;再想往前一步,可以嘗試從虛擬機中突破這個限制,因為 heapsize 是虛擬機得配置,是否拋出 OOM 異常也是在虛擬機中決定得;修改虛擬機運行邏輯是有一定可能得,但是其難度和可行性與想要修改得內(nèi)容相關(guān)性較大,修改方案得穩(wěn)定性也需要非常深厚得功力才能保證,而如果運氣不好,找不到好得切入點,甚至從理論上都無法保證其穩(wěn)定性,那么達(dá)到上線得難度就更大了,感謝不在這個方向深入。
2. 降低業(yè)務(wù)復(fù)雜度,裁剪應(yīng)用功能
這個方案也不在我們得考慮范圍之內(nèi),實際上很多應(yīng)用都有推出極速版,但是功能都會有所裁剪,對于使用常規(guī)版本得用戶,我們也不能推送極速版,因為使用體驗會有很大變化。
3. 分析 Java Heap 里得內(nèi)容都是什么,嘗試發(fā)現(xiàn)主要矛盾進行優(yōu)化,對癥下藥
實際上感謝就是從這個方向經(jīng)過調(diào)查后,找到了一個相對穩(wěn)定得突破口。下面是結(jié)合 OOM 堆棧、android 版本、heapsize 維度對 OOM 整體概況得一個分析:
- 最常見 OOM 堆棧
出現(xiàn)最多得堆棧就是 Bitmap 創(chuàng)建時內(nèi)存不足從而 OOM 崩潰,那么是不是已使用得內(nèi)存大多都是 Bitmap 呢 ?不能 百分百確定,因為直接觸發(fā) OOM 崩潰得原因是最后一次內(nèi)存分配失敗,而真正得原因是 OOM 之前得內(nèi)存分配;但是仍然有一定可能性,因為總是出現(xiàn)同一個堆??赡懿⒉皇乔珊希梢栽谝欢ǔ潭壬险f明這個堆棧執(zhí)行得比較頻繁,而且 Bitmap 一般占用內(nèi)存較大。
這里先做一個不 百分百確認(rèn)得初步推斷:OOM 時 Java heap 中占用內(nèi)存較多得對象是 Bitmap。
- OOM 在不同 android 版本、heapsize 上得表現(xiàn)
繼續(xù)對 OOM 數(shù)據(jù)做總結(jié)后發(fā)現(xiàn)了 OOM 得分布規(guī)律如下圖:
上圖紅色地雷代表 OOM,橫坐標(biāo)是 android 版本,縱坐標(biāo)是 heapsize,原點是:(android8.0, 384M);可以看到:
- 第壹、四象限,OOM 最少;對應(yīng) android 高版本,大 heapsize 和小 heapsize 都有
- 第二象限有一定 OOM;對應(yīng) android 低版本,大 heapsize
- 第三象限 OOM 最多;對應(yīng) android 低版本,小 heapsize
簡單總結(jié)就是:
- heapsize 越大越不容易 OOM
- Android8.0 及之后得版本更不容易 OOM
第四象限得數(shù)據(jù)說明,即便在 heapsize 較小得情況下,在 android 8.0 之后得版本上也不容易發(fā)生 OOM,結(jié)合上面得初步推斷信息“OOM 時 Java heap 中占用內(nèi)存較多得對象是 Bitmap”,很容易想到,應(yīng)該是 Bitmap 在 android 8.0 前后得實現(xiàn)變化導(dǎo)致了當(dāng)前得 OOM 分布現(xiàn)象:
Bitmap 變化:
在 Android 8.0 之前,Bitmap 像素占用得內(nèi)存是在 Java heap 中分配得
Android 8.0 及之后,Bitmap 像素占用得內(nèi)存分配到了 Native Heap
由于 Native heap 得內(nèi)存分配上限很大,32 位應(yīng)用得可用內(nèi)存在 3~4G,64 位上更大,虛擬內(nèi)存幾乎很難耗盡,所以在前面得推測 “OOM 時 Java heap 中占用內(nèi)存較多得對象是 Bitmap” 成立得情況下,應(yīng)用更不容易 OOM。
而第三象限數(shù)據(jù),則進一步佐證了前面得推測,Android 8.0 之前,Bitmap 像素內(nèi)存在 Java heap 中分配時,即便 heap size 大到 512M,OOM 發(fā)生也比較多。
至此,得到了確定得結(jié)論:
- OOM 得分布主要在 Android 8.0 之前 heap size 較小得設(shè)備
- OOM 時 Java heap 中占用內(nèi)存較多得是 Bitmap(確切得說是 Bitmap 得像素數(shù)據(jù)),當(dāng) Bitmap 像素占用內(nèi)存在 Native Heap 分配時,即便 heap size 很小,應(yīng)用也不容易 OOM
根據(jù)上述結(jié)論,目標(biāo)也就比較清晰了:
使 Android 8.0 之前 Bitmap 得像素內(nèi)存也從 Native 層分配,從而減少 Java OOM 崩潰。
二、Bitmap 使用分析和方案調(diào)查想要使得 Android 8.0 之前得設(shè)備 Bitmap 像素內(nèi)存也分配在 Native heap,需要先把 Bitmap 得創(chuàng)建流程調(diào)查清楚。
Bitmap 創(chuàng)建流程如下堆棧描述了 Bitmap 得創(chuàng)建:
Bitmap 得構(gòu)造方法是不公開得,在使用 Bitmap 得時候,一般都是通過 Bitmap、BitmapFactory 提供得靜態(tài)方法來創(chuàng)建 Bitmap 實例。下圖中以 Bitmap.createBitmap 說明了 Bitmap 對象得主要創(chuàng)建過程:
從上圖可以看到 Java Bitmap 對象是在 Native 層通過 NewObject 創(chuàng)建得。圖中得兩個函數(shù):
- allocateJavaPixelRef,是 8.0 之前版本為 Bitmap 像素從 Java heap 申請內(nèi)存
- allocateHeapBitmap,是 8.0 版本為 Bitmap 像素從 Native heap 申請內(nèi)存
allocateJavaPixelRef 通過 newNonMovableArray 從 Java 堆上為 Bitmap 像素分配內(nèi)存,然后再構(gòu)造 Native Bitmap 對象,對應(yīng)得構(gòu)造函數(shù)如下:
構(gòu)造函數(shù)中發(fā)現(xiàn) Native Bitmap 構(gòu)造時對應(yīng)得 mPixelStorageType 是 PixelStorageType::Java,表示 Bitmap 得像素是保存在 Java 堆上,所以嘗試看下 PixelStorageType 總共有幾種,是否可能有把 pixels 數(shù)據(jù)存儲到 Native 層。查找代碼發(fā)現(xiàn) PixelStorageType 只有三類,如下:
這個信息可以作為一個切入點,在后面進行深入調(diào)查。
allocateHeapBitmap 實現(xiàn)allocateHeapBitmap 主要是通過 calloc 為 Bitmap 得像素分配內(nèi)存,這個分配就在 Native 堆上了。
通過初步得分析,初步有兩個思路可以先進行嘗試:
- 在創(chuàng)建 Bitmap 時,把對 allocateJavaPixelRef 得調(diào)用替換為調(diào)用 allocateHeapBitmap 來達(dá)到從 Native 層分配內(nèi)存得目得
- 調(diào)查 PixelStorageType 共有哪些種類,是否可能從當(dāng)前得保存到 Java 堆切換為保存到 Native 堆
這個思路看起來想要實現(xiàn)目標(biāo),做一下替換就可以了,但實際上沒有這么簡單,存在得問題如下:
- allocateHeapBitmap 返回得是 skBitmap,allocateJavaPixelRef 返回得是 android::Bitmap,類型并不匹配
- 并不是簡單得插拔就可以把 allocateJavaPixelRef 替換為 allocateHeapBitmap,8.0 之前得 Android 版本上沒有 allocateHeapBitmap 得實現(xiàn)。如果想要為 8.0 之前得系統(tǒng)寫一個全新得實現(xiàn),只是參數(shù)得獲取就需要做很多適配,比如無法直接使用 skia 中得 SkBitmap、SkColorTab、SkImageInfo,就沒有辦法動態(tài)獲取到要分配得內(nèi)存 size
- Bitmap 內(nèi)存得申請和釋放要有匹配得邏輯和合適得時機
所以這個思路基本可以斷定不可行。
思路 2:allocateJavaPixelRef 替換為 allocateAshmemPixelRef前面得調(diào)查發(fā)現(xiàn) PixelStorageType 只有三類,如下:
其中 External 方式存儲 Bitmap 像素,在源碼中沒有看到相關(guān)使用,無法參考;Java 類型就是默認(rèn)得 Bitmap 創(chuàng)建方式,像素內(nèi)存分配得 Java 堆上;Ashmem 方式存儲 Bitmap 像素得方式在源碼中有使用,主要是在跨進程 Bitmap 傳遞時使用,對應(yīng)得場景主要是 Notification 和截圖場景:
查看其實現(xiàn):
從代碼中看到 allocateAshmemPixelRef 這個函數(shù)是通過 mmap ashmem 內(nèi)存來創(chuàng)建 native Bitmap 對象,且參數(shù)、返回值都與 allocateJavaPixelRef 相同,所以使用 Ashmem 方式存儲 Bitmap 像素看起來有一定可行性,只需把 allocateJavaPixelRef 得調(diào)用替換為 allocateAshmemPixelRef 即可達(dá)到從 Native 層為 Bitmap 像素分配內(nèi)存得目得。
但經(jīng)過詳細(xì)得源碼分析以及實際驗證,其可行性仍然很低,主要原因如下:
實際情況中,6.0 系統(tǒng)得 OOM 占了非常大一部分,如果這個方案可行,也可以解決一部分問題,所以不會因為這個原因阻礙對這種方案得嘗試,還可以繼續(xù)嘗試
這個問題基本是無解得,但如果方案可行,可以嘗試只給一定數(shù)量得 Bitmap 使用 ashmem 方式申請像素內(nèi)存,比如 500 個;所以方案還可以繼續(xù)嘗試
上圖 Bitmap 得 reconfigure 代碼中可以看到?jīng)]有 mBuffer 得 Bitmap 不支持 reconfigure,Ashmem 方式創(chuàng)建得 Bitmap 沒有從 Java 堆申請 mBuffer,所以一定是不支持 reconfigure 得。當(dāng)然到這里之后還沒有完全堵死這個方式,還可以繼續(xù)嘗試在 ashmem 方式申請 Bitmap 時給其一個假得 mBuffer 來繞過這個限制,但接下來要做得調(diào)查和改動勢必很大,因為 ashmem 方式申請 Bitmap 本身不支持 mBuffer 得管理,新創(chuàng)建得 buffer 就難以找到合適得時機進行釋放。
結(jié)合上述 3 個點綜合判斷,這個方案限制比較多,也有一定風(fēng)險,所以暫時將當(dāng)前得方案暫時掛起,作為備用方案。
上述得兩種思路不成功其實有一定得必然性,畢竟對應(yīng)代碼得設(shè)計并不是為了給我們?nèi)∏勺銮袚Q用得。既然沒有辦法這么容易實現(xiàn),就深入調(diào)查清楚為 Bitmap 從 Java 堆申請內(nèi)存得流程和這個內(nèi)存得使用流程,再嘗試從這些流程中找到切入點進行修改。
思路 3:剖析 Java 堆分配 Bitmap 內(nèi)存得過程,再嘗試找到方案Bitmap 內(nèi)存申請調(diào)查思路:
實際就是查找 hook 點得思路,先分析內(nèi)存是如何分配得,分配出來得內(nèi)存是如何使用得(主要指分配出內(nèi)存后,指針或者對象得傳遞路徑),嘗試把從 Java 堆分配內(nèi)存得關(guān)鍵點替換為使用 malloc/calloc 函數(shù)從 Native 堆上進行分配,并把分配出來得內(nèi)存指針構(gòu)造成原流程中使用得數(shù)據(jù)結(jié)構(gòu),并保證其能夠正常運行。
Android 8.0 之前 Bitmap 內(nèi)存申請和使用如下圖:
上圖為簡化后得核心內(nèi)存分配流程,框起來得部分就是為 Bitmap 從 Java heap 申請像素內(nèi)存得代碼。其中:
這里需要先說明一下 java byte array 得內(nèi)存布局(對應(yīng)代碼在 ART 虛擬機中):
前面得 8 個字節(jié)是 Object 成員,length_ 是這個數(shù)組得長度,first_element_ 數(shù)組用來實際存放 byte 數(shù)據(jù),數(shù)組得長度由 length_/4 來決定。addressOf(arrayObj) 獲取到得就是 first_element_地址;arrayObj 和 addr 得傳遞在上圖已經(jīng)用分別用綠色和紅色虛線箭頭標(biāo)記出來了。
想要把 Bitmap 內(nèi)存分配改為在 Native 層分配,就需要從分配這里入手, 所以必須要把 arrayObj 和 addr 使用梳理清晰,為后續(xù)替換和適配做好鋪墊。arrayObj 和 addr 使用如下:
arrayObj 得使用1. 在 Native 層使用,即在 android::Bitmap 對象中使用
2. 在 Java Bitmap 對象中引用,對應(yīng) Bitmap 得 mBuffer 成員
在為 Bitmap 分配 nonMovableArray 之后,通過 addr = addressOf(arrayObj)獲取:
在創(chuàng)建 native bitmap 時,作為指針傳遞給其成員 mPixelRef:
上述參數(shù) mStorage 就是 addr,其關(guān)鍵使用點是在 WrappedPixelRef 得 onNewLockPixels 被調(diào)用時,賦值給 LockRec 得 fPixels 成員:
mPixelRef 會被設(shè)置給 skBitmap。
每個 nativeBitmap 對應(yīng)一個 skia 得 skBitmap 對象,在創(chuàng)建 Bitmap 時會把 native bitmap 得成員 mPixelRef 設(shè)置給 skBitmap:
在 skia 中 SkBitmap 繪制 Bitmap 需要使用內(nèi)存來處理 Bitmap 像素數(shù)據(jù)時,就會通過 mPixelRef->onNewLockPixels() 來獲取存放 Bitmap 像素得內(nèi)存地址,即 arrayObj 得元素地址 addr,其是作為指針類型數(shù)據(jù)來使用得。
小結(jié):addr 指向得內(nèi)存是在 java 堆上,其會在需要得時候被傳遞給 skia 用來處理 bitmap 像素數(shù)據(jù)。
Bitmap 內(nèi)存使用總結(jié):
存儲 Bitmap 像素數(shù)據(jù)使用得內(nèi)存是通過 NewNonMovableArray 從 Java heap 申請得 byte 數(shù)組 arrayObj,arrayObj 對象得引用只在 Bitmap native 對象和 Java 對象中,作用分別是用來管理 arrayObj 得生命周期以及使用它得 length 來獲取 Bitmap 像素占用得內(nèi)存大小。
skia 中并不會為 Bitmap 得像素數(shù)據(jù)分配內(nèi)存,它把 Java heap 上 byte 數(shù)組得元素首地址轉(zhuǎn)換為 void* 來使用;也就是說在當(dāng)前實現(xiàn)中,Bitmap 像素內(nèi)存不一定非得是在 Java heap 上分配,我們可以 malloc 一塊內(nèi)存?zhèn)鬟f給 skia 使用,并不需要再給 skia 做任何適配。
有了上面這些信息,把 android 8.0 之前得 Bitmap 像素內(nèi)存改到在 Native 層分配目標(biāo)就看到了希望,因為不需要在 skia 層適配,可以降低一定難度。
嘗試從 native 層申請 Bitmap 內(nèi)存根據(jù)上面得分析,只需要找好 hook 得切入點,并完成 3 個關(guān)鍵點得替換即可,如下圖:
- 目標(biāo)是不再從 java heap 給 Bitmap 分配內(nèi)存,這一步得 byte[] 申請必然是需要去掉得
- 這里通過 malloc 分配內(nèi)存,交給 PixelRef 引用,間接得就可以被 SkBitmap 使用了
- 原有實現(xiàn)中 Java Bitmap 通過 mBuffer 成員引用 byte[],主要用來通過 mBuffer.length 獲取支持大小
上述 3 個關(guān)鍵點中,前兩個點比較好實現(xiàn),都是 native 層得代碼,hook 點也比較好找,這里不再贅述。而第 3 個點需要特殊處理,因為 Java 層 Bitmap 通過 mBuffer.length 獲取 Bitmap size,目前沒有穩(wěn)定得 Java hook 方案,且我們又不能真得給它一個長度為 Bitmap size 大小得 byte[](那樣就又從 Java 堆上進行 Bitmap 得內(nèi)存分配了),所以只能給個假得。
那么如何構(gòu)造一個假得 byte array ?前面分析過 java byte array 得內(nèi)存布局:
實際上 array.length 得就是 array 對象得 length_ 值,而虛擬機又提供了 addressOf 來獲取一個 array 得首元素地址,也即 first_element_ 地址,所以可以嘗試通過 first_element_ 來定位 length_ 得位置,進行修改即可。
這樣就可以在 java heap 上申請一個比較小得 byte array,并把它得長度偽造成與 Bitmap size 相等。申請得這個小 size 得 byte array 本身占用得內(nèi)存就作為 Bitmap 內(nèi)存轉(zhuǎn)移到 Native 層得代價。
這種方式看起來好像不太穩(wěn)定,但是可以通過校驗來保證,比如我們在執(zhí)行方案之前先嘗試偽造一個 byte array 來進行驗證,如下代碼就是申請了 1 字節(jié)長度得 byte array,把它得長度偽造成 36,然后進行校驗,校驗失敗則不再執(zhí)行 NativeBitmap 方案。
至此,Bitmap 內(nèi)存申請從 Java heap 轉(zhuǎn)移到 native heap 所需要解決得關(guān)鍵問題都解決了,離最終得目標(biāo)還有 50% 得距離。接下來需要完成 malloc 出來得 Bitmap 內(nèi)存得釋放邏輯。
Bitmap 內(nèi)存釋放原生釋放邏輯原生 Bitmap 得像素內(nèi)存存放在 byte array (mBuffer)中,Bitmap 得內(nèi)存釋放流程就對應(yīng)于 mBuffer 對象得釋放,這個釋放流程在 android 5.x ~7.x 大體相同,只有細(xì)微差別,下述以 android 6.0 代碼為例進行說明。Bitmap 像素內(nèi)存釋放主要有兩種方式觸發(fā):一種是 Java Bitmap 對象不再被引用后,GC 回收 Java Bitmap 對象時析構(gòu) Native Bitmap ,從而釋放 Bitmap 像素內(nèi)存;一種是主動調(diào)用 Bitmap.recycle() 來觸發(fā) Bitmap 像素內(nèi)存得釋放:
這個 mBuffer 是在 Native 層申請得 Java 對象,主要在兩個地方引用:
- Native 層通過 NewWeakGlobalRef(arrayObj) 把它添加到 Weak Global Reference table 中進行引用
- Java 層 Bitmap 通過 mBuffer 來引用,實際是在 Native 層通過 NewGlobalRef(arrayObj) 把它添加到了 Global Ref table 中,即 mBuffer 是一個關(guān)聯(lián)到 Java byte array 得 Global ref
而這兩個引用得釋放順序是先通過 DeleteGlobalRef 刪除全局強引用(Skia 中不再使用這個 Bitmap 時會觸發(fā)強引用刪除),再通過 DeleteWeakGlobalRef 來刪除全局弱引用,最終這個 byte array 對象被 GC 回收。
但實際運行過程中不完全是這樣得順序,mBuffer 得回收必然是在 DeleteGlobalRef 之后,但卻不一定是在 DeleteWeakGlobalRef 之后,因為一旦 bytearray 只被 Weak glabal ref table 引用時,只要發(fā)生 GC,就會把它回收掉。
新得釋放邏輯原生得 Bitmap 像素內(nèi)存釋放是通過回收 mBuffer 引用得 byte array,而 NativeBitmap 方案將像素內(nèi)存轉(zhuǎn)移到 Native 內(nèi)存之后,存在兩份內(nèi)存需要被釋放:
- 給 Java Bitmap 使用得小 size 得 byte array 對象,這個對象仍然按照原生邏輯釋放,無需再做其他變動
- malloc 出來得用以存放 bitmap 像素數(shù)據(jù)得內(nèi)存,在 byte array 釋放時進行 free,相當(dāng)于附著于原生得內(nèi)存釋放邏輯,從而不會影響 Bitmap 得生命周期
實現(xiàn)釋放有兩個關(guān)鍵點:
- malloc 出來得指針需要與 mBuffer 關(guān)聯(lián),這樣才能在 mBuffer 釋放時找到對應(yīng)得內(nèi)存進行釋放
解決方式:由于此時得 mBuffer 是偽造得 byte array,可以把 malloc 出來得 bitmap 指針保存在 byte array 中,當(dāng) byte array 被釋放時,先從中取出 bitmap 指針進行 free,再進行 byte array 釋放即可
- 需要使 mBuffer 得釋放邏輯固定,這樣便于確認(rèn) hook 點,原生得 mBuffer 釋放邏輯是在 DeleteGlobalRef 之后得首次 GC 時,比較難以操作
解決方式:給 mBuffer 額外添加一個引用,放到 Global Reference Table 中,保證 mBuffer 不被提前釋放,從而保證 mBuffer 得釋放時機穩(wěn)定保持在 Bitmap::doFreePixels() 中得 DeleteWeakGlobalRef(mBuffer) 位置,在這里從 mBuffer 中取出 malloc 出得 bitmap 指針執(zhí)行 free,然后再依次刪除給 mBuffer 額外添加得 Global Reference 和 Weak global ref。
新得釋放邏輯與原生釋放邏輯變化不大,如下圖,主要是固定了 mBuffer 得釋放時機在 DeleteWeakGlobalRef(mBuffer) 時,以及在此時釋放 malloc 出來得 bitmap 內(nèi)存:
至此,malloc 出來得內(nèi)存也能夠找到合適得時機進行釋放,把 Bitmap 得像素內(nèi)存從 Java heap 轉(zhuǎn)移到 Native heap 上得方案理論上完全可以實現(xiàn),且需要得改動不大,只需要在原生 Bitmap 得創(chuàng)建流程和釋放流程中做好修改即可。
三、實現(xiàn)方案根據(jù)上述思路 3 得方案,最終實現(xiàn)如下:
Bitmap 創(chuàng)建改造改造前 Bitmap 得創(chuàng)建和內(nèi)存申請流程:改造后 Bitmap 得創(chuàng)建和內(nèi)存申請流程:改造后在 Bitmap 創(chuàng)建過程中做了兩個 hook,對應(yīng)上圖中兩條紫色箭頭指向得代碼:
1. hook newNonMovableArray 函數(shù)
當(dāng)為一個 Bitmap 在 java 堆上通過 newNonMovableArray 申請一個 bitmapSize 大小得 byte array 時,通過代理改造,實際只申請大小為 (sizeof(int) + sizeof(jobject) + sizeof(void*)) 得 byte array(32 位上大小為 12 字節(jié),64 位上為 16 字節(jié))。
修改這個 byte array 得 size 為 bitmapSize,以供 Java 層 Bitmap 使用它獲取 bitmap 得真實 size。
在 byte array 得 element 首地址開始得前 4 個字節(jié)保存 0x13572468 作為 magic number,用以判斷這是一個改造之后得 byte array。
通過 NewGlobalRef(fakeArrayObj) 把這個 byte array 對象添加到 Global Ref table 中,以保證 byte array 得釋放時機一定是在 DeleteWeakGlobalRef 之后,并保存到 byte array 中,以便后續(xù)釋放時使用;實際創(chuàng)建得 array 內(nèi)存布局如下,這個 array 稱為 fakeArray。
這個 array 得實際 length 是 12 字節(jié)(32 位),此時 1~4 字節(jié)存放 magic:0x13572468,5~8 字節(jié)存放 globalRef,9~12 字節(jié)暫時沒有存放數(shù)據(jù)
2. hook addressOf 函數(shù)
在 addressOf 得代理函數(shù)中根據(jù)前 4 個字節(jié)數(shù)據(jù)是否是 magic number 來判斷傳入進來得 array 是否是被改造得 array,如果不是則調(diào)用原函數(shù)進行返回,如果是則繼續(xù)進行下述步驟;
此時 fake array 中存放數(shù)據(jù)如下:
在后面釋放 Bitmap 相關(guān)內(nèi)存時會使用到 byte array 中填充得這些數(shù)據(jù)。
在前面提到過申請得 fakeArray 本身占用得內(nèi)存就作為 Bitmap 內(nèi)存轉(zhuǎn)移到 Native 層得代價,到這里及可以計算一出 Bitmap 被轉(zhuǎn)移到 Native 層需要付出得內(nèi)存代價是多少 ?
答案是:在 32 位上是 12 字節(jié),在 64 位上是 16 字節(jié),多使用得內(nèi)存就是 fakeArray 中 0x13572468,globalRef,bitmap 這三個數(shù)據(jù)占用得內(nèi)存。一個進程如果使用 1000 個 Bitmap,最多額外占用 16* 1000 = 15KB+,是能夠被接受得。
Bitmap 釋放改造前述 Bitmap 創(chuàng)建過程得改造已經(jīng)保證了 Bitmap 成員 mBuffer 得釋放一定是在 Bitmap::doFreePixels() 得 DeleteWeakGlobalRef 之后了,所以只需要按照之前思路 hook DeleteWeakGlobalRef 函數(shù)即可:
上圖中虛線上方為原生得釋放流程,虛線下方是在原生流程上新添加得釋放流程。其中右側(cè)得代碼就是新得邏輯下對 Bitmap 像素數(shù)據(jù)和幫助數(shù)據(jù)釋放得關(guān)鍵代碼。釋放邏輯已經(jīng)在第二大節(jié)中得 [新得釋放邏輯] 中說明,這里不再復(fù)述。
上述對 Bitmap 創(chuàng)建和釋放流程得改造即可實現(xiàn)從 Native heap 給 Bitmap 申請像素內(nèi)存,但這樣得改造必然會影響原有得 java heap GC 得發(fā)生,因為 Bitmap 使用得像素內(nèi)存被轉(zhuǎn)移到了 Native 層,Java heap 內(nèi)存得壓力會變小,但 Native heap 內(nèi)存得壓力會變大,需要有對應(yīng)得 GC 觸發(fā)邏輯來回收 Java Bitmap 對象,從而回收其對應(yīng)得 Native 層像素內(nèi)存。
這種情況可以通過在 native 內(nèi)存申請和釋放時通知到虛擬機,由虛擬機來判斷是否達(dá)到 GC 條件,來進行 GC 得觸發(fā)。實際上 android 8.0 之后 Bitmap 內(nèi)存申請和釋放就是使用得這個方式。
對應(yīng)得代碼在 VMRuntime 中實現(xiàn):
只需要在給 Bitmap 申請內(nèi)存時調(diào)用 registerNativeAllocation(bitmapSize),在釋放 Bitmap 內(nèi)存時調(diào)用 registerNativeFree(bitmapSize)即可。
兼容性:android 5.1.x ~ 7.x目前該方案支持到 android 5.1.x ~ 7.x 得系統(tǒng)。4.x~5.0 得系統(tǒng)較早,實現(xiàn)差異較大,待后續(xù)完善。
四、線下驗證和線上效果線下驗證使用一臺 android 6.0 得手機機型驗證,java heapsize 是 128M。
測試代碼在測試代碼中嘗試把一個 bitmap 緩存 5001 次:
private static ArrayList<Bitmap> sBitmapCache = new ArrayList<>();void testNativeBitmap(Context context) { NativeBitmap.enable(context); for (int i = 0; i <= 5000; i++) { Bitmap bt = BitmapFactory.decodeResource(context.getResources(),R.drawable.icon); if (i%100 == 0) { Log.e("hanli", "loadbitmaps: " + i); } sBitmapCache.add(bt); }}
原生流程,只能加載 1400+個 Bitmap
在不開啟 NativeBitmap 時,load 1400+ 張支持后,應(yīng)用得 Java 堆內(nèi)存耗盡,發(fā)生 OOM 崩潰:
17979 18016 E hanli: loadbitmaps: 017979 18016 E hanli: loadbitmaps: 100...17979 18016 E hanli: loadbitmaps: 130017979 18016 E hanli: loadbitmaps: 140017979 18016 I art : Alloc concurrent mark sweep GC freed 7(208B) AllocSpace objects, 0(0B) LOS objects, 0% free, 127MB/128MB, paused 280us total 15.421ms17979 18016 W art : Throwing OutOfMemoryError "Failed to allocate a 82956 byte allocation with 7560 free bytes and 7KB until OOM"
打開 NativeBtimap
完成加載 5001 個 Bitmap,并且應(yīng)用仍能夠正常使用:
17516 17553 D hanli: NativeBitmap enabled.17516 17553 E hanli: loadbitmaps: 017516 17553 E hanli: loadbitmaps: 100...17516 17553 E hanli: loadbitmaps: 480017516 17553 E hanli: loadbitmaps: 490017516 17553 E hanli: loadbitmaps: 5000
線上效果:發(fā)生 Java OOM 得用戶數(shù)量降低 50%+產(chǎn)品 1
針對 heapsize 為 256M 及以下得設(shè)備啟用,當(dāng) Java heap 使用率達(dá)到 heapsize 得 70% 之后開始打開 NativeBitmap,Java OOM 崩潰影響用戶數(shù)-56.4785%,OOM 次數(shù)降低 72%。
產(chǎn)品 2針對 heapsize 為 384M 及以下得設(shè)備啟用,當(dāng) Java heap 使用率達(dá)到 heapsize 得 80% 之后開始打開 NativeBitmap,Java OOM 崩潰影響用戶數(shù)降低 63.063%,OOM 次數(shù)降低 76%。
在使用中我們對 NativeBitmap 方案得使用做了限制,因為 Bitmap 內(nèi)存轉(zhuǎn)移到 Native 層之后會占用虛擬內(nèi)存,而 32 位設(shè)備得虛擬內(nèi)存可用上限為 3G~4G,為了減少對虛擬內(nèi)存得使用,只在 heap size 較小得機型才開啟 NativeBitmap。我們在持續(xù)得優(yōu)化中發(fā)現(xiàn) Android 5.1.x ~ 7.1.x 版本上,已經(jīng)有很多設(shè)備是 64 位得,所以當(dāng)用戶安裝了 64 位得產(chǎn)品時,就可以在 heap size 較大得機型上也開啟 NativeBitmap,因為此時得虛擬內(nèi)存基本無法耗盡。在 64 位產(chǎn)品上把開啟 NativeBitmap 得 heap size 限制提升到 512M 之后,Java OOM 數(shù)據(jù)在優(yōu)化得基礎(chǔ)上又降低了 72%。
五、兩點說明有兩個問題做一下說明:
- 是否使用了 NativeBitmap 就一定不會發(fā)生 Java OOM 了?
答:并不是,NativeBitmap 只是把應(yīng)用內(nèi)存使用得大頭(即 Bitmap 得像素占用得內(nèi)存)轉(zhuǎn)移到 Native 堆,如果其他得 Java 對象使用不合理占用較多內(nèi)存,仍然會發(fā)生 Java OOM
- 方案可能產(chǎn)生得影響?
Bitmap 得像素占用得內(nèi)存轉(zhuǎn)移到 Native 堆之后,會使得虛擬內(nèi)存使用增多,當(dāng)存在泄漏時,可能會導(dǎo)致 32 位應(yīng)用得虛擬內(nèi)存被耗盡(實際上這個表現(xiàn)和 Android8.0 之后系統(tǒng)得表現(xiàn)一致)。
所以,方案得目標(biāo)實際是為了使老得 android 版本能夠支持更復(fù)雜得應(yīng)用設(shè)計,而不是為了解決內(nèi)存泄漏。