雲音樂 Android 記憶體監控探索篇

雲音樂技術團隊發表於2023-02-09
圖片來自:https://unsplash.com
本文作者: zgy

背景

隨著雲音樂不斷的對線上崩潰治理,目前崩潰率已經達到了行業內較低水平。但線上還存在很多 OOM 的崩潰,這種崩潰大多是因為編碼不規範導致的記憶體異常問題(比如記憶體洩露、大物件、大圖等不合理的記憶體使用)。記憶體問題難發現、難復現和難排查。這就需要我們透過一些監控手段和一些工具去協助開發人員更好的排查此類問題。 接下來就是雲音樂在記憶體監控方面的一些探索和實踐,主要從以下幾個方面介紹

概覽圖

記憶體洩露監控

談到記憶體問題,我們最先想到的應該就是記憶體洩露。簡單來說記憶體洩露就是某些不再使用的物件被其他生命週期更長的 GC Root 直接或者間接以強引用的方式持有,導致記憶體不能及時釋放,從而引發記憶體問題。

記憶體洩露容易增加應用記憶體峰值提高 OOM 的機率,屬於錯誤型問題,同時也是相對比較容易監控的型別。但是對於業務同學一般開發任務比較重,開發過程中一般不太會主動去關注本地檢測的洩露問題。這就需要我們去建立一套自動化工具監控記憶體洩露,並自動生成任務單派發到對應開發,從而推動開發人員像解決崩潰問題的流程一樣解決 APP 中的洩露問題。

記憶體監控方案

首先說到記憶體洩露檢測大家肯定都能想到 LeakCanary,Leakcanary 是 Square 開源的 Java 記憶體洩漏分析工具,主要用於開發階段檢測 Android 應用中常見的記憶體洩露。

LeakCanary 的優勢是能給出可讀性很好的效能檢測結果,並且能給出一些常見的解決方案,所以相比其他本地分析工具( MAT 等)更加高效。 LeakCanary 的核心原理是主要透過 Android 生命週期的 api 來監聽 activities 和 fragments 什麼時候被銷燬,被銷燬的物件會被傳遞給一個 ObjectWatcher,它持有它們的弱引用,預設等待5秒後觀察弱引用是否進入關聯的引用佇列,是則說明未發生洩露,否則說明可能發生洩漏。

LeakCanary 的核心流程如下:

流程圖

Leakcanary 在測試環境能基本滿足我們本地的洩露監控,但是由於 LeakCanary 本身檢測會主動觸發 GC 造成卡頓,並且預設直接使用的是 Debug.dumpHprofData(),在 Dump 的過程中會有較長時間的應用凍結時間,不太適合生產環境。

對於這點快手團隊在開源框架 Koom 中提出了最佳化方案:它利用 Copy-on-write 機制 fork 子程式 dump Java Heap,解決了 dump 過程中 App 長時間凍結的問題。Koom 的核心原理是週期性查詢 Java 堆記憶體、執行緒數、檔案描述符數等資源佔用情況,當連續多次觸發設定的閾值或者突發性連續快速突破高閾值時,觸發映象採集,映象採集採用虛擬機器 supend->fork 虛擬機器程式 -> 虛擬機器 resume->dump 記憶體映象的策略,同時基於 shark 執行映象解析離線記憶體洩露判定與引用鏈查詢,並生成分析報告。

Koom 的核心流程圖如下:

Koom

經過對兩個開源庫的分析比對,為了做到更全面的監控,我們決定從線上線下兩個維度來建立我們的監控系統,再結合我們的平臺對分析出的記憶體洩漏、大物件等問題按照引用鏈自動聚合歸因,並且按照聚合後的問題排序,後續透過自動建單的方式推動業務側開發去解決問題。

整體流程:

流程圖

線上我們建立一個相對嚴苛的條件(記憶體連續觸頂、記憶體突增,執行緒數或者 FD 數連續幾次達到閾值等條件,並且單個使用者在一定週期內只會觸發一次),當使用者觸發這些條件後,會 dump 記憶體生成 HPORF 檔案,然後對 HPORF 檔案進行分析,分析出記憶體洩露和大物件(大物件閾值透過線上配置可動態調整)等資訊,同時分析大圖佔用以及圖片總佔用等資訊,最後將分析的結果上報到後臺服務。為了降低對線上使用者的影響,前期我們暫時先不上傳 HPORF 檔案,後期再根據需要按照取樣的方式上報裁剪後的 HPORF 檔案。關於 shark 對 HPORF 檔案的分析,網上都有較為詳細的資料,這裡就不展開了。

線下我們主要結合自動化測試以及在測試環境下,監控 Activity、Fragent 洩露數量達到一定閾值或者記憶體觸頂等多種情況下觸發 dump,並且會輸出 HPORF 檔案分析結果,同時上報到後臺服務。

平臺側根據客戶端上報的問題,將大資料問題完成聚合消費後,按照使用者的洩露次數、影響使用者數、平均記憶體洩露率等維度進行排序,後續可以透過自動化建單的方式,分發給對應的開發,從而推動業務側解決。

目前我們主要支援以下物件的洩露:

  • 已經 destroyed 和 finished 的 activity
  • fragment manager 已經為空的 fragment
  • 已經 destroyed 的 window
  • 超過閾值大小的 bitmap
  • 超過閾值大小的基本型別陣列
  • 超過閾值大小的物件個數的任意 class
  • 已經清理的 ViewModel 例項
  • 已經從 window manager 移除的 RootView

大圖監控

我們都知道 Bitmap 一直是 Android App 總記憶體消耗佔比最大的部分,在很多 java 或者 native 記憶體問題的背後都能看到不少很大的 Bitmap 的影子,所以大圖治理是記憶體治理必不可少的一步,那麼我們做記憶體監控也必然少不了大圖監控。

針對大圖監控,我們主要分為線上圖片庫載入的大圖和本地資源大圖的監控。

線上大圖監控

目前我們主要是對網路載入的圖片做了統一的監控, 由於我們業務載入圖片都統一使用的是同一個圖片框架,所以我們只需要在載入圖片時判斷載入的圖片是否超過一定的閾值或者超過 view 的大小,超過則進行記錄和上報。我們改造了當前的圖片庫,新增圖片資訊的獲取從而回撥給監控 sdk,可以拿到載入圖片的寬、高、檔案大小等資訊,同時也獲取當前 view 的大小,然後我們會對比當前 view 的圖片大小或者圖片佔用記憶體是否達到一定的閾值(這裡支援線上配置),最終上報到我們的監控平臺。為了方便分析定位,並且減少效能消耗,我們線上上不會抓取堆疊資訊,只會獲取當前 view 的層級資訊,為了防止 view 層級過大,我們只獲取5層資料,目前來看當前的資訊已經足夠我們定位到當前的 view。同時我們也結合了自研的曙光埋點系統,算出當前 Oid 頁面的大圖率,這樣也可以方便我們監控一些 p0 級頁面的大圖率。

本地圖片資源監控

除了線上的大圖,我們還會對本地的資源圖片做一些把控,同時也能防止圖片資源過大導致包體積快速增長的問題。具體實現是透過卡點流程去做一些本地資源的檢測,透過外掛在 mergeResources 任務後,遍歷圖片資源,蒐集超過閾值的圖片資源,輸出一個列表,然後上報後臺服務,透過自動建單的方式,找到對應的開發,在發版前修復掉。

流程

記憶體大小監控

除了發現監控洩露的問題和大圖的問題,我們還需要建立一個記憶體大盤,以便我們能更好的瞭解當前 App 線上的記憶體佔用問題,方便我們更好的監控 App 的記憶體使用情況。我們的記憶體大盤主要分為應用啟動記憶體(Pss)和執行中記憶體(Pss)、Java 記憶體、執行緒等。

啟動記憶體、執行記憶體和 Java 記憶體監控

我們發現在 App 啟動的時候,如果遇到需要使用的記憶體過大,這時候在 App 側會出現較大的體驗問題,系統不斷的回收記憶體,同時 App 執行啟動,在記憶體不足的情況下啟動會更慢,因此我們需要監控啟動記憶體佔用情況,來方便我們後續的記憶體治理。Android 系統中,需要我們關注兩類記憶體的使用情況,實體記憶體和虛擬記憶體。通常我們使用 Android Memory Profiler 的方式檢視 APP 的記憶體使用情況。

android

我們可以檢視當前程式總記憶體佔用、JavaHeap、NativeHeap,以及 Graphics、Stack、Code 等細分型別的記憶體分配情況。那麼我們需要線上執行時獲取這些記憶體資料該怎麼獲取呢?這裡我們主要透過獲得所有程式的 Debug.MemoryInfo 資料(注意:這個介面在低端機型中可能耗時較久,不能在主執行緒中呼叫)。透過 Debug.MemoryInfo 的 getMemoryStat 方法(需要 23 版本及以上),我們可以獲得等價於 Memory Profiler 預設檢視中的多項資料,從而不斷獲取啟動完成以及執行過程中細分記憶體使用情況。

應用啟動完成時記憶體的獲取我們結合了我們之前啟動監控的完成時間節點來採集當前的記憶體情況。我們在啟動時就會啟動多個程式,根據我們之前的分析,APP 啟動的時候如果需要使用記憶體越多,越容易導致 APP 啟動出現問題。所以我們會統計所有程式的資料,為後續的程式治理做好鋪墊。

執行記憶體則是每隔一段時間去非同步獲取當前記憶體的使用情況,同時也是獲取整個應用所有程式的記憶體佔用情況。我們把所有采集的資料上報到平臺端,所有計算都在後臺處理,這樣可以做到靈活多變。後臺可以計算出啟動完成、執行中平均 PSS 等指標,它們可以反映整個 APP 記憶體的大概情況。

此外,我們還可以透過 RunTime 來獲取 Java 記憶體。我們透過採集的資料計算出 Java 記憶體觸頂(預設記憶體佔用超過 85% 算觸頂)的情況,再根據我們的平臺的彙總算出一個觸頂率,可以很好的反映 App 的 Java 記憶體的使用情況。一般如果超過 85% 最大堆限制,GC 會變得更加頻繁,容易造成 OOM 和卡頓。因此 Java 觸頂率是我們需要關注的一個很重要的指標。

在監控 Java 記憶體觸頂的同時,我們在採集資料時也加了 Java 記憶體不足的回撥。對於系統函式 onLowMemory 等函式是針對整個系統的記憶體回撥,對於單程式來說,Java 記憶體的使用沒有回撥函式供我們及時釋放記憶體。我們在做觸頂的時候,剛好可以實時監控程式的堆記憶體使用率,達到閾值即可通知相關模組進行記憶體釋放,這樣也可以在一定程度上降低 OOM 的機率。

執行緒監控

除了由記憶體洩露或者申請大量記憶體導致的常見的 OOM 問題。我們也會遇到類似如下錯誤

java.lang.OutOfMemoryError: {CanCatch}{main} pthread_create (1040KB stack) failed: Out of memory

這裡的原因大家應該都知道,根本原因是因為記憶體不足導致的,直接的原因是在建立執行緒時初始 stack size 的時候,分配不到記憶體導致的。這裡就不具體去分析 pthread_create 的原始碼了。除了 vmsize 對最大執行緒數的限制外,在 linux 中對每個程式可建立的執行緒數也有一定的限制(/proc/pid/limits)而實際測試中,我們也發現不同廠商對這個限制也有所不同,而且當超過系統程式執行緒數限制時,同樣會丟擲這個型別的 OOM。這裡特別指出的是華為的 emui 系統的某些機型,將最大執行緒數限制為 500 個。

為了瞭解我們當前的執行緒的使用情況,我們對雲音樂的執行緒數進行了監控統計,執行緒數超過一定閾值時,將當前的執行緒資訊上報平臺。這裡平臺也計算出了一個執行緒觸頂率,透過這個觸頂率可以衡量我們整體的執行緒健康情況,也為我們後續收斂應用執行緒做好鋪墊。

除此之外,我們還借鑑了 KOOM 對執行緒洩露做了監控,主要監控 native 執行緒的幾個生命週期方法: pthread_create、 pthread_detach、 pthread_join、 pthread_exit。 hook 以上幾個方法,用於記錄執行緒生命週期和堆疊、名稱等資訊,當發現一個 joinable 的執行緒在沒有 detach 或者 join 的情況下,執行了 pthread_exit,則記錄下洩露執行緒資訊,然後在合適的時機上報執行緒洩露。

總結

雲音樂的記憶體監控相比業內起步較晚,所以可以站在巨人的肩膀上,結合雲音樂現狀做更適合我們當前場景下的監控和最佳化。記憶體監控是一個持續完善的課題,我們並不能一步到位的做完所有事情。更重要的是我們能持續發現問題,持續做精細化的監控,而不是一直對處於"對當前記憶體現狀不瞭解,一邊填坑又一邊挖坑"的階段。我們的目標是建立合理的平臺為開發人員解決問題或者及時發現問題。當前雲音樂記憶體監控還屬於不斷探索和不斷完善的階段,我們還需要在未來的時間裡配合開發人員不斷的最佳化和迭代。

參考資料

本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!

相關文章