1. 程式人生 > 其它 >深入探索 Android 記憶體優化(煉獄級別-上)

深入探索 Android 記憶體優化(煉獄級別-上)

前言

成為一名優秀的Android開發,需要一份完備的知識體系,在這裡,讓我們一起成長為自己所想的那樣~。

本篇是 Android 記憶體優化的進階篇,難度可以說達到了煉獄級別,建議對記憶體優化不是非常熟悉的仔細看看前篇文章:Android效能優化之記憶體優化,其中詳細分析了以下幾大模組:

  • 1)、Android的記憶體管理機制
  • 2)、優化記憶體的意義
  • 3)、避免記憶體洩漏
  • 4)、優化記憶體空間
  • 5)、圖片管理模組的設計與實現

如果你對以上基礎內容都比較瞭解了,那麼我們便開始 Android 記憶體優化的探索之旅吧。

本篇文章非常長,建議收藏後慢慢享用~

目錄

  • 一、重識記憶體優化

  • 1、手機RAM

  • 2、記憶體優化的緯度

  • 3、記憶體問題

  • 二、常見工具選擇

  • 1、Memory Profiler

  • 2、Memory Analyzer

  • 3、LeakCanary

  • 三、Android記憶體管理機制回顧

  • 1、Java 記憶體分配

  • 2、Java 記憶體回收演算法

  • 3、Android 記憶體管理機制

  • 4、小結

  • 四、記憶體抖動

  • 1、那麼,為什麼記憶體抖動會導致 OOM?

  • 2、記憶體抖動解決實戰

  • 3、記憶體抖動常見案例

  • 五、記憶體優化體系化搭建

  • 1、MAT回顧

  • 2、搭建體系化的圖片優化 / 監控機制

  • 3、建立線上應用記憶體監控體系

  • 4、建立全域性的執行緒監控元件

  • 5、GC 監控元件搭建

  • 6、建立線上 OOM 監控元件:Probe

  • 7、實現 單機版 的 Profile Memory 自動化記憶體分析

  • 8、搭建線下 Native 記憶體洩漏監控體系

  • 9、設定記憶體兜底策略

  • 10、更深入的記憶體優化策略

  • 六、記憶體優化演進

  • 1、自動化測試階段

  • 2、LeakCanary

  • 3、使用基於 LeakCannary 的改進版 ResourceCanary

  • 七、記憶體優化工具

  • 1、top

  • 2、dumpsys meminfo

  • 3、LeakInspector

  • 4、JHat

  • 5、ART GC Log

  • 6、Chrome Devtool

  • 八、記憶體問題總結

  • 1、內類是有危險的編碼方式

  • 2、普通 Hanlder 內部類的問題

  • 3、登入介面的記憶體問題

  • 4、使用系統服務時產生的記憶體問題

  • 5、把 WebView 型別的洩漏裝進垃圾桶程序

  • 6、在適當的時候對元件進行登出

  • 7、Handler / FrameLayout 的 postDelyed 方法觸發的記憶體問題

  • 8、圖片放錯資源目錄也會有記憶體問題

  • 9、列表 item 被回收時注意釋放圖片的引用

  • 10、使用 ViewStub 進行佔位

  • 11、注意定時清理 App 過時的埋點資料

  • 12、針對匿名內部類 Runnable 造成記憶體洩漏的處理

  • 九、記憶體優化常見問題

  • 1、你們記憶體優化專案的過程是怎麼做的?

  • 2、你做了記憶體優化最大的感受是什麼?

  • 3、如何檢測所有不合理的地方?

  • 十、總結

  • 1、優化大方向

  • 2、優化細節

  • 3、記憶體優化體系化建設總結

一、重識記憶體優化

Android給每個應用程序分配的記憶體都是非常有限的,那麼,為什麼不能把圖片下載下來都放到磁碟中呢?那是因為放在記憶體中,展示會更 “”,快的原因有兩點,如下所示:

  • 1)、硬體快:記憶體本身讀取、存入速度快。
  • 2)、複用快:解碼成果有效儲存,複用時,直接使用解碼後物件,而不是再做一次影象解碼。

這裡說一下解碼的概念。Android系統要在螢幕上展示圖片的時候只認 “畫素緩衝”,而這也是大多數作業系統的特徵。而我們常見的jpg,png等圖片格式,都是把 “畫素緩衝” 使用不同的手段壓縮後的結果,所以這些格式的圖片,要在裝置上展示,就必須經過一次解碼,它的執行速度會受圖片壓縮比、尺寸等因素影響。(官方建議:把從記憶體中淘汰的圖片,降低壓縮比後儲存到本地,以備後用,這樣可以最大限度地降低以後複用時的解碼開銷。)

下面,我們來了解一下記憶體優化的一些重要概念。

1、手機RAM

手機不使用PCDDR記憶體,採用的是LPDDR RAM,即 ”低功耗雙倍資料速率記憶體“。其計算規則如下所示:

LPDDR系列的頻寬 = 時鐘頻率 ✖️記憶體匯流排位數 / 8
LPDDR4 = 1600MHZ ✖️64 / 8 ✖️雙倍速率 = 25.6GB/s。

那麼記憶體佔用是否越少越好?

當系統記憶體充足的時候,我們可以多用一些獲得更好的效能。當系統記憶體不足的時候,我們希望可以做到 ”用時分配,及時釋放“。

2、記憶體優化的緯度

對於Android記憶體優化來說又可以細分為如下兩個維度,如下所示:

  • 1)、RAM優化
  • 2)、ROM優化

1、RAM優化

主要是降低執行時記憶體。它的目的有如下三個:

  • 1)、防止應用發生OOM
  • 2)、降低應用由於記憶體過大被LMK機制殺死的概率
  • 3)、避免不合理使用記憶體導致GC次數增多,從而導致應用發生卡頓

2、ROM優化

降低應用佔ROM的體積,進行APK瘦身。它的目的主要是為了降低應用佔用空間,避免因ROM空間不足導致程式無法安裝

3、記憶體問題

那麼,記憶體問題主要是有哪幾類呢?記憶體問題通常來說,可以細分為如下三類:

  • 1)、記憶體抖動
  • 2)、記憶體洩漏
  • 3)、記憶體溢位

下面,我們來了解下它們。

1、記憶體抖動

記憶體波動圖形呈鋸齒張GC導致卡頓

這個問題在Dalvik虛擬機器上會更加明顯,而ART虛擬機器記憶體管理跟回收策略上都做了大量優化記憶體分配和GC效率相比提升了5~10倍,所以出現記憶體抖動的概率會小很多

2、記憶體洩漏

Android系統虛擬機器的垃圾回收是通過虛擬機器GC機制來實現的。GC會選擇一些還存活的物件作為記憶體遍歷的根節點GC Roots,通過對GC Roots的可達性來判斷是否需要回收。記憶體洩漏就是在當前應用週期內不再使用的物件被GC Roots引用,導致不能回收,使實際可使用記憶體變小。簡言之,就是物件被持有導致無法釋放或不能按照物件正常的生命週期進行釋放。一般來說,可用記憶體減少、頻繁GC,容易導致記憶體洩漏

3、記憶體溢位

即OOM,OOM時會導致程式異常。Android裝置出廠以後,java虛擬機器對單個應用的最大記憶體分配就確定下來了,超出這個值就會OOM。單個應用可用的最大記憶體對應於 /system/build.prop 檔案中的 dalvik.vm.heapgrowthlimit

此外,除了因記憶體洩漏累積到一定程度導致OOM的情況以外,也有一次性申請很多記憶體,比如說一次建立大的陣列或者是載入大的檔案如圖片的時候會導致OOM。而且,實際情況下很多OOM就是因圖片處理不當而產生的。

二、常見工具選擇

在 Android效能優化之記憶體優化中我們已經介紹過了相關的優化工具,這裡再簡單回顧一下。

1、Memory Profiler

作用

  • 1)、實時圖表展示應用記憶體使用量
  • 2)、用於識別記憶體洩漏、抖動等
  • 3)、提供捕獲堆轉儲、強制GC以及根據記憶體分配的能力

優點

  • 1)、方便直觀
  • 2)、線下使用

2、Memory Analyzer

強大的Java Heap分析工具,查詢記憶體洩漏及記憶體佔用, 生成整體報告分析記憶體問題等等。建議線下深入使用

3、LeakCanary

自動化記憶體洩漏檢測神器。建議僅用於線下整合

它的缺點比較明顯,具體有如下兩點:

  • 1)、雖然使用了idleHandler與多程序,但是dumphprof 的 SuspendAll Thread 的特性依然會導致應用卡頓
  • 2)、在三星等手機,系統會快取最後一個Activity,此時應該採用更嚴格的檢測模式

三、Android記憶體管理機制回顧

ART 和 Dalvik 虛擬機器使用分頁和記憶體對映來管理記憶體。下面我們先從Java的記憶體分配開始說起。

1、Java 記憶體分配

Java的記憶體分配區域分為如下五部分

  • 1)、方法區:主要存放靜態常量
  • 2)、虛擬機器棧:Java變數引用
  • 3)、本地方法棧:native變數引用
  • 4)、堆:物件
  • 5)、程式計數器:計算當前執行緒的當前方法執行到多少行

2、Java 記憶體回收演算法

1、標記-清除演算法

流程可簡述為兩步

  • 1)、標記所有需要回收的物件
  • 2)、統一回收所有被標記的物件

優點

實現比較簡單。

缺點

  • 1)、標記、清除效率不高
  • 2)、產生大量記憶體碎片

2、複製演算法

流程可簡述為三步

  • 1)、將記憶體劃分為大小相等的兩塊
  • 2)、一塊記憶體用完之後複製存活物件到另一塊
  • 3)、清理另一塊記憶體

優點

實現簡單,執行高效,每次僅需遍歷標記一半的記憶體區域

缺點

浪費一半的空間,代價大。

3、標記-整理演算法

流程可簡述為三步

  • 1)、標記過程與 標記-清除演算法 一樣
  • 2)、存活物件往一端進行移動
  • 3)、清理其餘記憶體

優點

  • 1)、避免 標記-清除 導致的記憶體碎片
  • 2)、避免複製演算法的空間浪費

4、分代收集演算法

現在主流的虛擬機器一般用的比較多的還是分代收集演算法,它具有如下特點

  • 1)、結合多種演算法優勢
  • 2)、新生代物件存活率低,使用 複製演算法
  • 3)、老年代物件存活率高,使用 標記-整理演算法

3、Android 記憶體管理機制

Android 中的記憶體是彈性分配的,分配值 與 最大值 受具體裝置影響

對於OOM場景其實可以細分為如下兩種:

  • 1)、記憶體真正不足
  • 2)、可用(被分配的)記憶體不足

我們需要著重注意一下這兩種的區分。

4、小結

以Android中虛擬機器的角度來說,我們要清楚Dalvik 與 ART 區別Dalvik僅固定一種回收演算法,而ART回收演算法可在執行期按需選擇,並且,ART具備記憶體整理能力,減少記憶體空洞

最後,LMK(Low Memory killer)機制保證了程序資源的合理利用,它的實現原理主要是根據程序分類和回收收益來綜合決定的一套演算法集

四、記憶體抖動

記憶體頻繁分配和回收導致記憶體不穩定,就會出現記憶體抖動,它通常表現為頻繁GC、記憶體曲線呈鋸齒狀

並且,它的危害也很嚴重,通常會導致頁面卡頓,甚至造成OOM

1、那麼,為什麼記憶體抖動會導致 OOM?

主要原因有如下兩點:

  • 1)、頻繁建立物件,導致記憶體不足及碎片(不連續)
  • 2)、不連續的記憶體片無法被分配,導致OOM

2、記憶體抖動解決實戰

這裡我們假設有這樣一個場景:點選按鈕使用 handler 傳送一個空訊息,handler 的 handleMessage 接收到訊息後建立記憶體抖動,即在 for 迴圈建立 100個容量為10萬 的 strings 陣列並在 30ms 後繼續傳送空訊息。

一般使用Memory Profiler (表現為 頻繁GC、記憶體曲線呈鋸齒狀)結合程式碼排查即可找到記憶體抖動出現的地方。

通常的技巧就是著重檢視迴圈或頻繁被呼叫的地方。

3、記憶體抖動常見案例

下面列舉一些導致記憶體抖動的常見案例,如下所示:

1、字串使用加號拼接

  • 1)、使用StringBuilder替代
  • 2)、初始化時設定容量,減少StringBuilder的擴容

2、資源複用

  • 1)、使用全域性快取池,以重用頻繁申請和釋放的物件
  • 2)、注意結束使用後,需要手動釋放物件池中的物件

3、減少不合理的物件建立

  • 1)、ondraw、getView 中建立的物件儘量進行復用
  • 2)、避免在迴圈中不斷建立區域性變數

4、使用合理的資料結構

使用SparseArray類族、ArrayMap來替代HashMap

五、記憶體優化體系化搭建

在開始我們今天正式的主題之前,我們先來回歸一下記憶體洩漏的概念與解決技巧。

所謂的記憶體洩漏就是記憶體中存在已經沒有用的物件。它的表現一般為記憶體抖動、可用記憶體逐漸減少。 它的危害即會導致記憶體不足、GC頻繁、OOM

而對於記憶體洩漏的分析一般可簡述為如下兩步

  • 1)、使用 Memory Profiler 初步觀察
  • 2)、通過 Memory Analyzer 結合程式碼確認

1、MAT回顧

MAT查詢記憶體洩漏

對於MAT來說,其常規的查詢記憶體洩漏的方式可以細分為如下三步:

  • 1)、首先,找到當前 Activity,在 Histogram 中選擇其 List Objects 中的 with incoming reference(哪些引用引向了我)
  • 2)、然後,選擇當前的一個 Path to GC Roots/Merge to GC Roots 的 exclude All 弱軟虛引用
  • 3)、最後,找到的洩漏物件在左下角下會有一個小圓圈

此外,在Android效能優化之記憶體優化還有幾種進階的使用方式,這裡就不一一贅述了,下面,我們來看看關於 MAT 使用時的一些關鍵細節。

MAT的關鍵使用細節

要全面掌握MAT的用法,必須要先了解隱藏在 MAT 使用中的四大細節,如下所示:

  • 1)、善於使用 Regex 查詢對應洩漏類

  • 2)、使用 group by package 查詢對應包下的具體類

  • 3)、明白 with outgoing references 和 with incoming references 的區別

  • with outgoing references:它引用了哪些物件

  • with incoming references:哪些物件引用了它

  • 4)、瞭解 Shallow Heap 和 Retained Heap 的區別

  • Shallow Heap:表示物件自身佔用的記憶體

  • Retained Heap:物件自身佔用的記憶體 + 物件引用的物件所佔用的記憶體

MAT 關鍵元件回顧

除此之外,MAT 共有5個關鍵元件幫助我們去分析記憶體方面的問題,分別如下所示:

  • 1)、Dominator_tree
  • 2)、Histogram
  • 3)、thread_overview
  • 4)、Top Consumers
  • 5)、Leak Suspects

下面我們這裡再簡單地回顧一下它們。

1、Dominator(支配者):

如果從GC Root到達物件A的路徑上必須經過物件B,那麼B就是A的支配者。

2、Histogram和dominator_tree的區別:

  • 1)、Histogram 顯示 Shallow Heap、Retained Heap、Objects,而 dominator_tree 顯示的是 Shallow Heap、Retained Heap、Percentage
  • 2)、Histogram 基於的角度,dominator_tree是基於例項的角度。Histogram 不會具體顯示每一個洩漏的物件,而dominator_tree會

3、thread_overview

檢視執行緒數量執行緒的 Shallow Heap、Retained Heap、Context Class Loader 與 is Daemon

4、Top Consumers

通過圖形的形式列出佔用記憶體比較多的物件

在下方的Biggest Objects還可以檢視其相對比較詳細的資訊,例如Shallow Heap、Retained Heap

5、Leak Suspects

列出有記憶體洩漏的地方,點選 Details 可以檢視其產生記憶體洩漏的引用鏈

2、搭建體系化的圖片優化 / 監控機制

在介紹圖片監控體系的搭建之前,首先我們來回顧下Android Bitmap 記憶體分配的變化

Android Bitmap 記憶體分配的變化

在Android 3.0之前

  • 1)、Bitmap 物件存放在 Java Heap,而畫素資料是存放在 Native 記憶體中的
  • 2)、如果不手動呼叫 recycle,Bitmap Native 記憶體的回收完全依賴 finalize 函式回撥,但是回撥時機是不可控的

Android 3.0 ~ Android 7.0

Bitmap物件畫素資料統一放到Java Heap中,即使不呼叫 recycle,Bitmap 畫素資料也會隨著物件一起被回收。

但是,Bitmap 全部放在 Java Heap 中的缺點很明顯,大致有如下兩點:

  • 1)、Bitmap是記憶體消耗的大戶,而 Max Java Heap 一般限制為 256、512MB,Bitmap 過大過多容易導致 OOM
  • 2)、容易引起大量 GC,沒有充分利用系統的可用記憶體

Android 8.0及以後

  • 1)、使用了能夠輔助回收 Native 記憶體的NativeAllocationRegistry,以實現將畫素資料放到 Native 記憶體中,並且可以和 Bitmap 物件一起快速釋放,最後,在 GC 的時候還可以考慮到這些 Bitmap 記憶體以防止被濫用
  • 2)、Android 8.0 為了解決圖片記憶體佔用過多和影象繪製效率過慢的問題新增了硬體點陣圖Hardware Bitmap

那麼,我們如何將圖片記憶體存放在 Native 中呢?

將圖片記憶體存放在Native中的步驟有四步,如下所示:

  • 1)、呼叫 libandroid_runtime.so 中的 Bitmap 建構函式,申請一張空的 Native Bitmap。對於不同 Android 版本而言,這裡的獲取過程都有一些差異需要適配
  • 2)、申請一張普通的 Java Bitmap
  • 3)、將 Java Bitmap 的內容繪製到 Native Bitmap 中
  • 4)、釋放 Java Bitmap 記憶體

我們都知道的是,當系統記憶體不足的時候,LMK會根據OOM_adj開始殺程序,從後臺、桌面、服務、前臺,直到手機重啟。並且,如果頻繁申請釋放 Java Bitmap 也很容易導致記憶體抖動。對於這種種問題,我們該如何評估記憶體對應用效能的影響呢?

對此,我們可以主要從以下兩個方面進行評估,如下所示:

  • 1)、崩潰中異常退出和 OOM 的比例
  • 2)、低記憶體裝置更容易出現記憶體不足和卡頓,需要檢視應用中使用者的手機記憶體在 2GB 以下所佔的比例

對於具體的優化策略與手段,我們可以從以下七個方面來搭建一套成體系化的圖片優化 / 監控機制

1、統一圖片庫

在專案中,我們需要收攏圖片的呼叫,避免使用 Bitmap.createBitmap、BitmapFactory 相關的介面建立 Bitmap,而應該使用自己的圖片框架

2、裝置分級優化策略

記憶體優化首先需要根據裝置環境來綜合考慮,讓高階裝置使用更多的記憶體,做到針對裝置效能的好壞使用不同的記憶體分配和回收策略

因此,我們可以使用類似device-year-class的策略對裝置進行分級,對於低端機使用者可以關閉複雜的動畫或”重功能“,使用565格式的圖片或更小的快取記憶體等等。

業務開發人員需要考慮功能是否對低端機開啟,在系統資源不夠時主動去做降級處理

3、建立統一的快取管理元件

建立統一的快取管理元件,併合理使用 OnTrimMemory / LowMemory 回撥,根據系統不同的狀態去釋放相應的快取與記憶體

在實現過程中,需要解決使用 static LRUCache 來快取大尺寸 Bitmap 的問題

並且,在通過實際的測試後,發現onTrimMemory 的 ComponetnCallbacks2.TRIM_MEMORY_COMPLETE 並不等價於 onLowMemory,因此建議仍然要去監聽 onLowMemory 回撥

4、低端機避免使用多程序

一個空程序也會佔用10MB記憶體,低端機應該儘可能減少使用多程序。

針對低端機使用者可以推出4MB 的輕量級版本,如今日頭條極速版、Facebook Lite。

5、線下大圖片檢測

在開發過程中,如果檢測到不合規的圖片使用(如圖片寬度超過View的寬度甚至螢幕寬度),應該立刻提示圖片所在的Activity和堆疊,讓開發人員更快發現並解決問題。在灰度和線上環境,可以將異常資訊上報到後臺,還可以計算超寬率(圖片超過螢幕大小所佔圖片總數的比例)

下面,我們介紹下如何實現對大圖片的檢測。

常規實現

繼承 ImageView,重寫實現計算圖片大小。但是侵入性強,並且不通用。

因此,這裡我們介紹一種更好的方案:ARTHook。

ARTHook優雅檢測大圖

ARTHook,即掛鉤,用額外的程式碼勾住原有的方法,以修改執行邏輯,主要可以用於以下四個方面:

  • 1)、AOP程式設計
  • 2)、執行時插樁
  • 3)、效能分析
  • 4)、安全審計

具體我們是使用Epic來進行 Hook,Epic 是一個虛擬機器層面,以 Java 方法為粒度的執行時 Hook 框架。簡單來說,它就是ART 上的 Dexposed,並且它目前支援 Android 4.0~10.0

使用步驟

Epic通常的使用步驟為如下三個步驟:

1、在專案 moudle 的 build.gradle 中新增

compile 'me.weishu:epic:0.6.0'

2、繼承 XC_MethodHook,實現 Hook 方法前後的邏輯。如監控Java執行緒的建立和銷燬

class ThreadMethodHook extends XC_MethodHook{
    @Override
    protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
        super.beforeHookedMethod(param);
        Thread t = (Thread) param.thisObject;
        Log.i(TAG, "thread:" + t + ", started..");
    }

    @Override
    protected void afterHookedMethod(MethodHookParam param) throws Throwable {
        super.afterHookedMethod(param);
        Thread t = (Thread) param.thisObject;
        Log.i(TAG, "thread:" + t + ", exit..");
    }
}

3、注入 Hook 好的方法:

DexposedBridge.findAndHookMethod(Thread.class, "run", new ThreadMethodHook());

知道了 Epic 的基本使用方法之後,我們便可以利用它來實現大圖片的監控報警了。

專案實戰

以 Awesome-WanAndroid專案為例,首先,在 WanAndroidApp 的 onCreate 方法中新增如下程式碼:

DexposedBridge.hookAllConstructors(ImageView.class, new XC_MethodHook() {
        @Override
        protected void afterHookedMethod(MethodHookParam param) throws Throwable {
            super.afterHookedMethod(param);
        // 1
        DexposedBridge.findAndHookMethod(ImageView.class, "setImageBitmap", Bitmap.class, new ImageHook());
        }
    });

在註釋1處,我們通過呼叫 DexposedBridge 的 findAndHookMethod 方法找到所有通過 ImageView 的 setImageBitmap 方法設定的切入點,其中最後一個引數 ImageHook 物件是繼承了 XC_MethodHook 類,其目的是為了重寫 afterHookedMethod 方法拿到相應的引數進行監控邏輯的判斷

接下來,我們來實現我們的 ImageHook 類,程式碼如下所示:

public class ImageHook extends XC_MethodHook {

    @Override
    protected void afterHookedMethod(MethodHookParam param) throws Throwable {
        super.afterHookedMethod(param);
        // 1
        ImageView imageView = (ImageView) param.thisObject;
        checkBitmap(imageView,((ImageView) param.thisObject).getDrawable());
    }

    private static void checkBitmap(Object thiz, Drawable drawable) {
        if (drawable instanceof BitmapDrawable && thiz instanceof View) {
            final Bitmap bitmap = ((BitmapDrawable) drawable).getBitmap();
            if (bitmap != null) {
                final View view = (View) thiz;
                int width = view.getWidth();
                int height = view.getHeight();
                if (width > 0 && height > 0) {
                    // 2、圖示寬高都大於view的2倍以上,則警告
                    if (bitmap.getWidth() >= (width << 1)
                        &&  bitmap.getHeight() >= (height << 1)) {
                    warn(bitmap.getWidth(), bitmap.getHeight(), width, height, new RuntimeException("Bitmap size too large"));
                }
                } else {
                    // 3、當寬高度等於0時,說明ImageView還沒有進行繪製,使用ViewTreeObserver進行大圖檢測的處理。
                    final Throwable stackTrace = new RuntimeException();
                    view.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
                        @Override
                        public boolean onPreDraw() {
                            int w = view.getWidth();
                            int h = view.getHeight();
                            if (w > 0 && h > 0) {
                                if (bitmap.getWidth() >= (w << 1)
                                    && bitmap.getHeight() >= (h << 1)) {
                                    warn(bitmap.getWidth(), bitmap.getHeight(), w, h, stackTrace);
                                }
                                view.getViewTreeObserver().removeOnPreDrawListener(this);
                            }
                            return true;
                        }
                    });
                }
            }
        }
    }

    private static void warn(int bitmapWidth, int bitmapHeight, int viewWidth, int viewHeight, Throwable t) {
        String warnInfo = "Bitmap size too large: " +
            "\n real size: (" + bitmapWidth + ',' + bitmapHeight + ')' +
            "\n desired size: (" + viewWidth + ',' + viewHeight + ')' +
            "\n call stack trace: \n" + Log.getStackTraceString(t) + '\n';

        LogHelper.i(warnInfo);
    }
}

首先,在註釋1處,我們重寫了 ImageHook 的 afterHookedMethod 方法,拿到了當前的 ImageView 和要設定的 Bitmap 物件。然後,在註釋2處,如果當前 ImageView 的寬高大於0,我們便進行大圖檢測的處理:ImageView 的寬高都大於 View 的2倍以上,則警告。接著,在註釋3處,如果當前 ImageView 的寬高等於0,則說明 ImageView 還沒有進行繪製,則使用 ImageView 的 ViewTreeObserver 獲取其寬高進行大圖檢測的處理。至此,我們的大圖檢測檢測元件就已經實現了。

ARTHook方案實現小結

  • 1)、無侵入性
  • 2)、通用性強
  • 3)、相容性問題大,開源方案不能帶到線上環境

6、線下重複圖片檢測

首先我們來了解一下這裡的重複圖片所指的概念: 即Bitmap 畫素資料完全一致,但是有多個不同的物件存在

重複圖片檢測的原理其實就是 使用記憶體 Hprof 分析工具,自動將重複 Bitmap 的圖片和引用堆疊輸出

使用說明

使用非常簡單,只需要修改Main類的main方法的第一行程式碼,如下所示:

// 設定我們自己 App 中對應的 hprof 檔案路徑
String dumpFilePath = "//Users//quchao//Documents//heapdump//memory-40.hprof";

然後,我們執行main方法即可在//Users//quchao//Documents//heapdump這個路徑下看到生成的images資料夾,裡面儲存了專案中檢測出來的重複的圖片。images目錄如下所示:

注意:需要使用 8.0 以下的機器,因為 8.0 及以後 Bitmap 中的 buffer 已儲存在 native 記憶體之中。

實現步驟

具體的實現可以細分為如下三個步驟:

  • 1)、首先,獲取 android.graphics.Bitmap 例項物件的 mBuffer 作為 ArrayInstance ,通過 getValues 獲取的資料為 Object 型別。由於後面計算 md5 需要為 byte[] 型別,所以通過反射的方式呼叫 ArrayInstance#asRawByteArray 直接返回 byte[] 資料
  • 2)、然後,根據 mBuffer 的資料生成 png 圖片檔案。
  • 3)、最後,獲取堆疊資訊,直接使用LeakCanary 獲取 stack 的方法,使用 leakcanary-analyzer-1.6.2.jar 和 leakcanary-watcher-1.6.2.jar 這兩個庫檔案。並用反射的方式呼叫了HeapAnalyzer#findLeakTrace方法。

其中,獲取堆疊的資訊也可以直接使用haha庫來進行獲取。這裡簡單說一下使用 haha 庫獲取堆疊的流程,其具體可以細分為八個步驟,如下所示:

  • 1)、首先,預備一個已經存在重複 bitmap 的 hprof 檔案
  • 2)、利用 haha 庫上的 MemoryMappedFileBuffer 讀取 hrpof 檔案 [關鍵程式碼 new MemoryMappedFileBuffer(heapDumpFile) ]
  • 3)、解析生成 snapshot,獲取 heap,這裡我只獲取了 app heap [關鍵程式碼 snapshot.getHeaps(); heap.getName().equals("app") ]
  • 4)、從 snapshot 中根據指定 class 查找出所有的 Bitmap Classes [關鍵程式碼snapshot.findClasses(Bitmap.class.getName()) ]
  • 5)、從 heap 中獲得所有的 Bitmap 例項 instance [關鍵程式碼 clazz.getHeapInstances(heap.getId()) ]
  • 6)、根據 instance 中獲取所有的屬性資訊 Field[],並從 Field[] 查找出我們需要的 "mWidth" "mHeight" "mBuffer" 資訊
  • 7)、通過 "mBuffer" 屬性即可獲取到他們的 hashcode 來判斷是否是重複圖片
  • 8)、最後,通過 instance 中 mNextInstanceToGcRoot 獲取整個引用鏈資訊並列印

7、建立全域性的線上 Bitmap 監控

為了建立全域性的 Bitmap 監控,我們必須對 Bitmap 的分配和回收 進行追蹤。我們先來看看 Bitmap 有哪些特點:

  • 1)、建立場景比較單一:在 Java 層呼叫 Bitmap.create 或 BitmapFactory 等方法建立,可以封裝一層對 Bitmap 建立的介面,注意要包含呼叫第三方庫產生的 Bitmap,這裡我們具體可以使用ASM 編譯插樁 + Gradle Transform的方式來高效地實現。
  • 2)、建立頻率比較低
  • 3)、和 Java 物件的生命週期一樣服從 GC,可以使用 WeakReference 來追蹤 Bitmap 的銷燬

根據以上特點,我們可以建立一套 Bitmap 的高性價比監控元件

  • 1)、首先,在介面層將所有創建出來的 Bitmap 放入一個 WeakHashMap 中,並記錄建立 Bitmap 的資料、堆疊等資訊。
  • 2)、然後,每隔一定時間檢視 WeakHashMap 中有哪些 Bitmap 仍然存活來判斷是否出現 Bitmap 濫用或洩漏。
  • 3)、最後,如果發生了 Bitmap 濫用或洩露,則將相關的資料與堆疊等資訊打印出來或上報至 APM 後臺。

這個方案的效能消耗很低,可以在正式環境中進行。但是,需要注意的一點是,正式與測試環境需要採用不同程度的監控。

3、建立線上應用記憶體監控體系

要建立線上應用的記憶體監控體系,我們需要先獲取 App 的 DalvikHeap 與 NativeHeap,它們的獲取方式可歸結為如下四個步驟:

  • 1、首先,通過 ActivityManager 的 getProcessMemoryInfo => Debug.MemoryInfo 獲取記憶體資訊資料
  • 2、然後,通過hook Debug.MemoryInfo 的 getMemoryStat 方法(os v23 及以上)可以獲得 Memory Profiler 中的多項資料,進而獲得細分記憶體的使用情況
  • 3、接著,通過 Runtime 獲取 DalvikHeap
  • 4、最後,通過 Debug.getNativeHeapAllocatedSize 獲取 NativeHeap

對於監控場景,我們需要將其劃分為兩大類,如下所示:

  • 1)、常規記憶體監控
  • 2)、低記憶體監控

1、常規記憶體監控

根據 斐波那契數列 每隔一段時間(max:30min)獲取記憶體的使用情況。常規記憶體的監控方法有多種實現方式,下面,我們按照專案早期 => 壯大期 => 成熟期的常規記憶體監控方式進行演進式講解。

專案早期:針對場景進行線上 Dump 記憶體的方式

具體使用Debug.dumpHprofData()實現。

其實現的流程為如下四個步驟:

  • 1)、超過最大記憶體的 80%
  • 2)、記憶體 Dump
  • 3)、回傳檔案至伺服器
  • 4)、MAT 手動分析

但是,這種方式有如下幾個缺點:

  • 1)、Dump檔案太大,和物件數正相關,可以進行裁剪
  • 2)、上傳失敗率高,分析困難

壯大期:LeakCanary帶到線上的方式

在使用 LeakCanary 的時候我們需要預設洩漏懷疑點,一旦發現洩漏進行回傳。但這種實現方式缺點比較明顯,如下所示:

  • 1)、不適合所有情況,需要預設懷疑點
  • 2)、分析比較耗時,容易導致 OOM

成熟期:定製 LeakCanary 方式

那麼,如何定製線上的LeakCanary?

定製 LeakCanary 其實就是對haha元件來進行定製。haha庫是square出品的一款自動分析Android堆疊的java庫

對於haha庫,它的基本用法一般遵循為如下四個步驟:

1、匯出堆疊檔案

File heapDumpFile = ...
Debug.dumpHprofData(heapDumpFile.getAbsolutePath());

2、根據堆疊檔案創建出記憶體對映檔案緩衝區

DataBuffer buffer = new MemoryMappedFileBuffer(heapDumpFile);

3、根據檔案快取區創建出對應的快照

Snapshot snapshot = Snapshot.createSnapshot(buffer);

4、從快照中獲取指定的類

ClassObj someClass = snapshot.findClass("com.example.SomeClass");

我們在實現線上版的LeakCanary的時候主要要解決的問題有三個,如下所示:

  • 1)、解決 預設懷疑點 時不準確的問題 => 自動找懷疑點
  • 2)、解決掉將 hprof 檔案對映到記憶體中的時候可能導致記憶體暴漲甚至發生 OOM 的問題 => 物件裁剪,不全部載入到記憶體。即對生成的 Hprof 記憶體快照檔案做一些優化:裁剪大部分圖片對應的 byte 資料 以減少檔案開銷,最後,使用 7zip 壓縮,一般可 節省 90% 大小
  • 3)、分析洩漏鏈路慢而導致分析時間過長 => 分析 Retain size 大的物件

成熟期:實現記憶體洩漏監控閉環

在實現了線上版的 LeakCanary 之後,就需要將線上版的 LeakCanary 與伺服器和前端頁面結合起來。具體的記憶體洩漏監控閉環流程如下所示:

  • 1)、當在線上版 LeakCanary 上發現記憶體洩漏時,手機將上傳記憶體快照至伺服器
  • 2)、此時伺服器分析 Hprof,如果不是系統原因導致誤報則通過 git 得到該最近修改人
  • 3)、最後將記憶體洩漏 bug 單提交給負責人。該負責人通過前端實現的 bug 單系統即可看到自己新增的bug

此外,在實現圖片記憶體監控的過程中,應注意兩個關鍵點,如下所示:

  • 1)、在線上可以按照不同的系統、螢幕解析度等緯度去分析圖片記憶體的佔用情況
  • 2)、在 OOM 崩潰時,可以將圖片總記憶體、Top N 圖片佔用記憶體寫入崩潰日誌

2、低記憶體監控

對於低記憶體的監控,通常有兩種方式,分別如下所示:

  • 1、利用 onTrimMemory / onLowMemory 監聽系統回撥的實體記憶體警告
  • 2、在後臺起一個服務定時監控系統的記憶體佔用,只要超過虛擬記憶體大小最大限制的 90% 則直接觸發記憶體警告

3、記憶體監控指標

為了準確衡量記憶體效能,我們需要引入一系列的記憶體監控指標,如下所示:

1)、發生頻率

2)、發生時各項記憶體使用狀況

3)、發生時App的當前場景

4)、記憶體異常率

記憶體 UV 異常率 = PSS 超過 400MB 的 UV / 採集UV
PSS 獲取:呼叫 Debug.MemoryInfo 的 API 即可

如果出現新的記憶體使用不當或記憶體洩漏的場景,這個指標會有所上漲

5)、觸頂率

記憶體 UV 觸頂率 = Java 堆佔用超過最大堆限制的 85% 的 UV / 採集UV

計算觸頂率的程式碼如下所示:

long javaMax = Runtime.maxMemory();
long javaTotal = Runtime.totalMemory();
long javaUsed = javaTotal - runtime.freeMemory();
float proportion = (float) javaUsed / javaMax;

如果超過85% 最大堆的限制,GC會變得更加頻發,容易造成OOM 和 卡頓

4、小結

在具體實現的時候,客戶端儘量只負責上報資料,而指標值的計算可以由後臺來計算。這樣便可以通過版本對比監控是否有新增記憶體問題。因此,建立線上記憶體監控的完整方案至少需要包含以下四點

  • 1)、待機記憶體、重點模組記憶體、OOM率
  • 2)、整體及重點模組 GC 次數、GC 時間
  • 3)、增強的 LeakCanry 自動化記憶體洩漏分析
  • 4)、低記憶體監控模組的設定

4、建立全域性的執行緒監控元件

每個執行緒初始化都需要 mmap 一定的棧大小,在預設情況下初始化一個執行緒需要 mmap 1MB 左右的記憶體空間

32bit的應用中有4g 的 vmsize實際能使用的有3g+,這樣一個程序最大能建立的執行緒數可以達到3000個,但是,linux 對每個程序可建立的執行緒數也有一定的限制(/proc/pid/limits),並且,不同廠商也能修改這個限制,超過該限制就會 OOM。

因此,對執行緒數量的限制,在一定程度上可以有效地避免 OOM 的發生。那麼,實現一套全域性的執行緒監控元件便是刻不容緩的了。

全域性執行緒監控元件的實現原理

線上下或灰度的環境下通過一個定時器每隔 10分鐘 dump 出應用所有的執行緒相關資訊,當執行緒數超過當前閾值時,則將當前的執行緒資訊上報並預警

5、GC 監控元件搭建

通過Debug.startAllocCounting來監控GC情況,注意有一定效能影響

Android 6.0 之前可以拿到記憶體分配次數和大小以及 GC 次數,其對應的程式碼如下所示:

long allocCount = Debug.getGlobalAllocCount();
long allocSize = Debug.getGlobalAllocSize();
long gcCount = Debug.getGlobalGcInvocationCount();

並且,在Android 6.0 及之後可以拿到更精準GC資訊:

Debug.getRuntimeStat("art.gc.gc-count");
Debug.getRuntimeStat("art.gc.gc-time");
Debug.getRuntimeStat("art.gc.blocking-gc-count");
Debug.getRuntimeStat("art.gc.blocking-gc-time");

對於GC 資訊的排查,我們一般關注阻塞式GC的次數和耗時,因為它會暫停執行緒,可能導致應用發生卡頓。建議僅對重度場景使用

6、建立線上 OOM 監控元件:Probe

美團的 Android記憶體洩漏自動化鏈路分析元件ProbeOOM時會生成Hprof 記憶體快照,然後,它會通過單獨程序對這個檔案做進一步分析

Probe 元件的缺陷及解決方案

它的缺點比較多,具體為如下幾點:

  • 1、在崩潰的時候生成記憶體快照容易導致二次崩潰
  • 2、部分手機生成 Hprof 快照比較耗時
  • 3、部分 OOM 是由虛擬記憶體不足導致

在實現自動化鏈路分析元件 Probe 的過程中主要要解決兩個問題,如下所示:

1、鏈路分析時間過長

  • 1)、使用鏈路歸併:將具有相同層級與結構的鏈路進行合併
  • 2)、使用自適應擴容法通過不斷比較現有鏈路和新鏈路,結合擴容因子,逐漸完善為完整的洩漏鏈路

2、分析程序佔用記憶體過大

分析程序佔用的記憶體記憶體快照檔案的大小不成正相關,而跟記憶體快照檔案的 Instance 數量正相關。所以在開發過程中我們應該儘可能排除不需要的Instance例項

Prope 分析流程揭祕

Prope 的總體架構圖如下所示:

而它的整個分析流程具體可以細分為八個步驟,如下所示:

1、hprof 對映到記憶體 => 解析成 Snapshot & 計數壓縮:

解析後的 Snapshot 中的 Heap 有四種類型,具體為:

  • 1)、DefaultHeap
  • 2)、ImageHeap
  • 3)、App Heap:包括ClassInstance、ClassObj、ArrayInstance、RootObj
  • 4)、System Heap

解析完後使用了計數壓縮策略,對相同的 Instance使用計數,以減少佔用記憶體。超過計數閾值的需要計入計數桶(計數桶記錄了 丟棄個數 和 每個 Instance 的大小)

2、生成 Dominator Tree

3、計算 RetainSize

4、生成 Reference 鏈 && 基礎資料型別增強:

如果物件是基礎資料型別,會將自身的 RetainSize 累加到父節點上,將懷疑物件替換為它的父節點

5、鏈路歸併

6、計數桶補償 & 基礎資料型別和父節點融合

使用計數補償策略計算 RetainSize,主要是 判斷物件是否在計數桶中,如果在的話則將 丟棄的個數和大小補償到物件上,累積計算RetainSize,最後對 RetainSize 排序以查詢可疑物件

7、排序擴容

8、查詢洩露鏈路

7、實現 單機版 的 Profile Memory 自動化記憶體分析

在配置的時候要注意兩個問題:

  • 1、liballoc-lib.so在構建後工程的 build => intermediates => cmake 目錄下。將對應的 cpu abi 目錄拷貝到新建的 libs 目錄下

  • 2、在 DumpPrinter Java 庫的 build.gradle 中的 jar 閉包中需要加入以下程式碼以識別原始碼路徑:
    sourceSets.main.java.srcDirs = ['src']

使用步驟

具體的使用步驟如下所示:

1、首先,點選 ”開始記錄“ 按鈕可以看到觸發物件分配的記錄,說明物件已經開始記錄物件的分配,log如下所示:

12-26 10:54:03.963 30450-30450/com.dodola.alloctrack I/AllocTracker: ====current alloc count 388=====

2、然後,點選多次 ”生成1000個物件“ 按鈕,當物件達到設定的最大數量的時候觸發記憶體dump,會得到儲存資料路徑的日誌。如下所示:

12-26 10:54:03.963 30450-30450/com.dodola.alloctrack I/AllocTracker: ====current alloc count 388=====
12-26 10:56:45.103 30450-30450/com.dodola.alloctrack I/AllocTracker: saveARTAllocationData write file to /storage/emulated/0/crashDump/1577329005

3、此時,可以看到資料儲存在 sdk 下的 crashDump 目錄下。

4、接著,通過 gradle task :buildAlloctracker 任務編譯出存放在 tools/DumpPrinter-1.0.jar 的 dump 工具,然後採用如下命令來將資料解析 到dump_log.txt 檔案中。

java -jar tools/DumpPrinter-1.0.jar dump檔案路徑 > dump_log.txt

5、最後,就可以在 dump_log.txt 檔案中看到解析出來的資料,如下所示:

Found 4949 records:
tid=1 byte[] (94208 bytes)
    dalvik.system.VMRuntime.newNonMovableArray (Native method)
    android.graphics.Bitmap.nativeCreate (Native method)
    android.graphics.Bitmap.createBitmap (Bitmap.java:975)
    android.graphics.Bitmap.createBitmap (Bitmap.java:946)
    android.graphics.Bitmap.createBitmap (Bitmap.java:913)
    android.graphics.drawable.RippleDrawable.updateMaskShaderIfNeeded (RippleDrawable.java:776)
    android.graphics.drawable.RippleDrawable.drawBackgroundAndRipples (RippleDrawable.java:860)
    android.graphics.drawable.RippleDrawable.draw (RippleDrawable.java:700)
    android.view.View.getDrawableRenderNode (View.java:17736)
    android.view.View.drawBackground (View.java:17660)
    android.view.View.draw (View.java:17467)
    android.view.View.updateDisplayListIfDirty (View.java:16469)
    android.view.ViewGroup.recreateChildDisplayList (ViewGroup.java:3905)
    android.view.ViewGroup.dispatchGetDisplayList (ViewGroup.java:3885)
    android.view.View.updateDisplayListIfDirty (View.java:16429)
    android.view.ViewGroup.recreateChildDisplayList (ViewGroup.java:3905)

8、搭建線下 Native 記憶體洩漏監控體系

Android 8.0 及之後,可以使用Address Sanitizer、Malloc 除錯和 Malloc 鉤子進行native 記憶體分析

對於線下 Native 記憶體洩漏監控的建立,主要針對是否能重編 so 的情況來記錄分配的記憶體資訊。

針對無法重編so的情況

  • 1)、首先,使用PLT Hook 攔截庫的記憶體分配函式,然後,重定向到我們自己的實現後去記錄分配的 記憶體地址、大小、來源so庫路徑等資訊。
  • 2)、最後,定期 掃描分配與釋放 的配對記憶體塊,對於 不配對的分配 輸出上述記錄的資訊

針對可重編的so情況

  • 1)、首先,通過GCC”-finstrument-functions“引數給所有函式插樁,然後,在樁中模擬呼叫棧的入棧與出棧操作
  • 2)、接著,通過ld”--warp“引數攔截記憶體分配和釋放函式,重定向到我們自己的實現後記錄分配的 記憶體地址、大小、來源so以及插樁呼叫棧此刻的內容
  • 3)、最後,定期掃描分配與釋放是否配對,對於不配對的分配輸出我們記錄的資訊

9、設定記憶體兜底策略

設定記憶體兜底策略的目的,是為了在使用者無感知的情況下,在接近觸發系統異常前,選擇合適的場景殺死程序並將其重啟,從而使得應用記憶體佔用回到正常情況

通常執行記憶體兜底策略時至少需要滿足六個條件,如下所示:

  • 1)、是否在主介面退到後臺且位於後臺時間超過 30min
  • 2)、當前時間為早上 2~5 點
  • 3)、不存在前臺服務(通知欄、音樂播放欄等情況)
  • 4)、Java heap 必須大於當前程序最大可分配的85% || native記憶體大於800MB
  • 5)、vmsize 超過了4G(32bit)的85%
  • 6)、非大量的流量消耗(不超過1M/min) && 程序無大量CPU排程情況

只有在滿足了以上條件之後,我們才會去殺死當前主程序並通過 push 程序重新拉起及初始化

10、更深入的記憶體優化策略

除了在 Android效能優化之記憶體優化 => 優化記憶體空間中講解過的一些常規的記憶體優化策略以外,在下面列舉了一些更深入的記憶體優化策略。

1、使 bitmap 資源在 native 中分配

對於 Android 2.x 系統,使用反射將 BitmapFactory.Options 裡面隱藏的 inNativeAlloc 開啟

對於 Android 4.x 系統,使用或借鑑 Fresco 將 bitmap 資源在 native 中分配的方式

2、圖片載入時的降級處理

使用 Glide、Fresco 等圖片載入庫,通過定製,在載入 bitmap 時,若發生 OOM,則使用 try catch 將其捕獲,然後清除圖片 cache,嘗試降低 bitmap format(ARGB8888、RGB565、ARGB4444、ALPHA8)。

需要注意的是,OOM 是可以捕獲的,只要 OOM 是由 try 語句中的物件宣告所導致的,那麼在 catch 語句中,是可以釋放掉這些物件,解決 OOM 的問題的。

3、前臺每隔 3 分鐘去獲取當前應用記憶體佔最大記憶體的比例,超過設定的危險閾值(如80%)則主動釋放應用 cache(Bitmap 為大頭),並且顯示地除去應用的 memory,以加速記憶體收集的過程。

計算當前應用記憶體佔最大記憶體的比例的程式碼如下:

max = Runtime.getRuntime().maxMemory();
available = Runtime.getRuntime.totalMemory() - Runtime.getFreeMemory();
ratio = available / max;

顯示地除去應用的 memory,以加速記憶體收集過程的程式碼如下所示:

WindowManagerGlobal.getInstance().startTrimMemory(TRIM_MEMORY_COMPLETE);

4、由於 webview 存在記憶體系統洩漏,還有 相簿佔用記憶體過多 的問題,可以採用單獨的程序。

5、當UI隱藏時釋放記憶體

當用戶切換到其它應用並且你的應用 UI 不再可見時,應該釋放應用 UI 所佔用的所有記憶體資源。這能夠顯著增加系統快取程序的能力,能夠提升使用者體驗。

在所有 UI 元件都隱藏的時候會接收到 Activity 的 onTrimMemory() 回撥並帶有引數 TRIM_MEMORY_UI_HIDDEN

6、Activity 的兜底記憶體回收策略

在 Activity 的 onDestory 中遞迴釋放其引用到的 Bitmap、DrawingCache 等資源,以降低發生記憶體洩漏時對應用記憶體的壓力。

7、使用類似 Hack 的方式修復系統記憶體洩漏

LeakCanary 的 AndroidExcludeRefs 列出了一些由於系統原因導致引用無法釋放的例子,可使用類似 Hack 的方式去修復。

8、當應用使用的Service不再使用時應該銷燬它,建議使用 IntentServcie。

9、謹慎使用第三方庫,避免為了使用其中一兩個功能而匯入一個大而全的解決方案。

很感謝您閱讀這篇文章,希望您能將它分享給您的朋友或技術群,這對我意義重大。

文章轉自 https://juejin.cn/post/6844904099998089230 如有侵權,請聯絡刪除。

相關視訊推薦:

Android 效能優化學習【一】:APK瘦身優化_嗶哩嗶哩_bilibili

Android 效能優化學習【二】:APP啟動速度優化_嗶哩嗶哩_bilibili

Android 效能優化學習【三】:如何解決OOM問題_嗶哩嗶哩_bilibili

Android 效能優化學習【四】:UI卡頓優化_嗶哩嗶哩_bilibili