Sieve—Android 記憶體分析系統

orangex發表於2018-08-29

[TOC]

原文發表在“京東技術”公眾號

背景

記憶體問題是個老大難,對使用者來說,洩漏或者不合理的記憶體使用最終會反映到效能和體驗上,並且極易造成 OOM( Out Of Memories ) 而閃退, 而對開發者來說更為頭疼:

  • OOM 堆疊價值不大

它是壓倒駱駝的最後一根稻草罷了。

  • 現有工具不夠理想

    LeakCanary 為解決記憶體洩漏而存在,但其實“洩漏”的定性其實是人為的:即你認為該物件不該繼續存在了,結果它仍然被一條鏈路引用著,那我們說這個物件洩漏了。 LeakCanary 幫我們把 物件不該繼續存在了 這個概念繫結為了比如 Activity 這種本身有生命週期的物件的 onDestroy(),這也意味著對於其他一些沒有所謂生命週期的物件,只要它還在記憶體中存在著,那它洩漏與否實際上取決於你認定它該不該活著(但 LeakCanary 不知道你怎麼想的,所以它無法幫你找到這些洩漏)。因此,我們希望能夠單純的提供物件的引用鏈路給你,至於它存在的合理性交由你自己判斷

    MAT 的問題在於它實際上是個專為 Java 虛擬機器做記憶體分析的工具,對於 Android 不夠友好,尤其是 Bitmap 等物件大小不對的問題,這個後面細說,並且功能上來講,部分冗餘部分又達不到一些特定的需求,比較難用。

  • 元凶不止一個

    舉個例子,一個 OOM 背後可能是兩個 Activity 的洩漏,三張超大的圖片等等一系列問題。這些捆稻草都有責任,理想情況是讓這些稻草從重到輕排個序,誰負主要責任一目瞭然。

成果

先上鍊接為敬!Sieve—Android 記憶體分析系統(暫時只面向公司內部使用)

這是一個面向開發者的工具,上傳一份 Hprof 檔案(堆轉儲),系統將為你生成一份分析報告,下面是某一份報告頁的截圖。

總覽

image-20180802152504678

##Activity/Fragment

image-20180802152615062

##Bitmap

image-20180802152707087

##物件的引用路徑

image-20180802152755873

##Class 前三十

image-20180802152852838

實現

實現主要分為堆轉儲的解析、支配樹的生成、RetainSize 的計算、引用鏈路的構造

解析堆轉儲檔案

Android Studio 中 Monitor 工具 Hprof 檔案儲存了當前時刻堆的情況,主要包括類資訊、棧幀資訊、堆疊資訊、堆資訊。其中堆資訊是我們關注的重點,其中包括了所有的物件:執行緒物件、類物件、例項物件、物件陣列物件、原始型別陣列物件。

在所有物件中,部分物件還擁有另一重身份虛擬機器認定的 GC Root(指向的物件)。眾所周知,GC Roots 是 ART VM 垃圾回收演算法設定的根,代表了從這些根出發,順著強引用關係所能達到物件是絕不會被回收的。 GC Roots 必須是對於當前 GC 堆的一組活躍的引用,這是顯然的,因為引用是活躍的,那麼引用直接或間接引用的物件們必然是有用的,是不能被回收的。知道了誰是不能回收的,也就知道了誰是能被回收的,GC 的目標也就找到了。這便是是 GC Roots 存在的意義。

GC Roots 分許多型別(不同虛擬機器甚至更細或者不同的叫法),比如

  • Stack Local:當前活躍的棧幀裡指向 GC 堆中物件的引用(也就是當前呼叫方法的引數、區域性變數)
  • Thread:存活的執行緒
  • JNI Local:Native 棧中的區域性變數
  • JNI Global:Native 全域性引用
  • 分代收集中,從非收集代指向收集代的引用

等等,可以看出,這些分類都與 **"GC Roots 是一組活躍的引用"**的說法相吻合。同時,需要知道的是,GC Roots 是動態變化的,一個引用可能剛剛是 GC Root ,這會又不是了。

解析工作其實就是讀取檔案中以上提到的資訊,並將其對映至記憶體的過程。最終對映的結果是一個叫做堆快照的資料結構。

嚴格來講,GC Root 是引用而不是物件,為了描述方便,文中我們把其指向的物件也叫做 GC Root。

SNAPSHOT

  • Default Heap:對於某物件,系統未指定堆
  • App Heap:對應 ART VM 中的 Allocation Space,其實分裂自 Zygote Space。程式獨享的主堆。
  • Image Heap:對應 ART VM 中的 Image Space,系統啟動映像,包含啟動期間預載入的類, 此處的分配保證絕不會移動或消失。
  • Zygote Heap :對應 ART VM 中的 Zygote Space ,程式共享。在該Hprof 檔案中表示Zygote Space 中屬於該程式的那部分。

在將 Hprof 對映至這份快照的同時,我們通過它提供類的繼承關係、類的欄位資訊等等,在這份 SnapShot 的各個物件之間建立了引用與被引用的關係(可以叫它父子關係,這裡我們只保留強引用關係)。那如果再為所有是 GC Root 的物件的頭上新增一個超級源點同時作為他們的父親的話,其實我們就得到了一個以這個”超級源點“為根的引用關係”樹“。(引號的原因是,實際情況裡引用之間可能存在環,嚴格的講它不一定是個樹)

支配樹的生成與 RetainSize 的計算

先介紹一下相關概念

支配點與支配樹

在有向圖中,如果從源點到 B 點,無論如何都要經過 A 點,則 A 是 B 的支配點,稱 A 支配 B。而距離 B 最近的支配點,稱之為直接支配點。

image-20180803161806199

比如上圖中

  • A 支配 B、C、D、E、F, 而 B 支配 D、E 不支配 F。
  • E 的直接支配點是 B

支配樹是基於原圖生成的一棵樹,其每個點的父親是原圖中這個點的直接支配點。對與上圖來說,支配樹是

image-20180803163448946

Shallow Size 與 Retained Size

某個物件的 Shallow Size 是物件本身的大小,不包含其引用的物件。也就是說比如對於下面這個類:

public class TestA {
	int a;
	TestB b;
}
複製程式碼

其大小應該為:12(物件頭)+4(int a)+4(TestB b)+4(對齊) = 24 (關於物件頭,欄位在記憶體中排列,對齊等不展開討論)

這裡重點關注欄位 TestB 只計算了一個引用的大小:4 byte,而不管這個 TestB 有多少欄位,每個欄位是什麼。

某個物件的 Retained Size 是其支配的所有節點的 Shallow Size 之和。

講到這裡其實就明白了,Retained Size 其實就是某個物件所能維護和保有的大小,換句話說它代表了如果回收掉該物件虛擬機器所能收回掉的記憶體大小。各個物件的 Retained Size 大小是我們分析記憶體使用情況的重要指標,當某些物件的Retained Size 過大時,可能代表著不合理的記憶體使用或者洩露。

支配樹的生成

對於 DAG(有向無環圖)來說,可以按照拓撲序來構建支配樹,記拓撲序中第 x 個點 為 v ,求 v 的直接支配點時,拓撲序中 v 之前的點(拓撲序為 1~x-1的點 )的直接支配點已經求好了(也就是對於這些點,支配樹已經構造好了),接下來對在原圖中 v 的所有父親求在已經構造的支配樹上的最近公共祖先(因為父親們肯定拓撲序小於 x,所以父親們已經在目前構造好的支配樹上了)。舉個栗子,對於下圖(點已按拓撲序標號)

image-20180806103650256

假設走到了求點 8 的直接支配點這一步,則說明 1~7 的支配樹已構造完畢,如下圖

image-20180806104017983

接著,對點 8 的父親,點 5、6、7 求在上圖支配樹中的最近公共祖先,顯而易見他們的最近公共祖先是點 1,因此點 8 的直接支配點就是點 1,繼續新增到支配樹上,得到:

image-20180806104435953

以上就是支配樹的構造過程,這裡是樹在不斷改變並且是線上查詢的情況,我們採用的倍增法,樹的 LCA(最近公共祖先)問題的演算法很多,比如轉化為 RMQ(範圍最值查詢) 問題求解等等,可自行了解。

還沒完呢!細心的你可能發現了,之前提到過,實際的引用關係並不是樹,也不是 DAG ,而僅僅是個有向圖。這意味著有環,意味著拓撲序失去了意義,意味著對每個點的所有父親求在支配樹上的 LCA 時,它的某個父親可能還沒有處理。這裡採取的方式是,如果這個父親沒有處理,那就先跳過,繼續之前的演算法,就當少了一個父親,直至支配樹構造完畢。緊接著,從頭開始重複構造支配樹,之前某點沒有處理的父親,這一次可能就變成處理過的了,所以就可能將該點求出的直接支配點結果”重新整理“。不斷的重複這一過程,直至不存在某個點求出的直接支配點被”重新整理“。也就是說既然環的存在使的拓撲關係不再成立,那就跳過因此導致此時還未處理的父節點,通過不斷迭代的方式使得最終所有求得的支配點”收斂“。

上述演算法的瓶頸在於這個迭代的次數隨著圖的複雜程度爆炸增長,有向圖(有環也行)的支配樹構造其實有更為優秀的演算法,Lengauer-Tarjan演算法。該演算法引入了半支配點的概念,半支配點代表了有潛力成為直接支配點的點,該演算法正是通過修正半支配點得到直接支配點的。詳細可自行了解

Retained Size 計算

有了支配樹,Retained Size 計算就是個累加過程。遍歷每個點,將其 Shallow Size 加至支配樹裡其所有祖先身上去,比如對於上面的那張圖,對於點 5 ,就是將其 Shallow Size 加至點 4 、點 2、點 1。當遍歷完的時候,所有點的 Retained Size 也就計算完畢了。

但如果真就這樣算下來,會發現比如不少 Bitmap 的 RetainSize “根本不對”,如果你用 MAT 檢視,發現經常就幾十位元組,這在直覺上是無法理解的。這就與文初提到的 GC Root 有關了,虛擬機器會將某些物件標記成各種 Type 的 GC Root。直觀的想象一下,這就相當於把某個本是從頂到下的引用關係鏈中的普通節點,被提扯到最頂上去當做根節點,這也是出現環的原因之一

Bitmap 中 mBuffer 成員正是如此,byte[] 型別的 mBuffer 儲存了點陣圖的畫素資料,幾乎佔據了 Bitmap 的全部大小。如果它本本分分,那麼它就是支配樹上的一個葉子,其直接支配點就是其父物件 Bitmap,那就一切正常皆大歡喜。然而事實是在某些情況下,由於它被“提拔”成了 GC Root,它的直接支配點會被支配樹演算法直接置為超級源點。這會導致其 Shallow Size 無法加至其原本的祖先鏈上去。

比如上面圖中,假設點 5 就是那個 mBuffer,點 4 是 Bitmap,因為點 5 的支配點不是點 4 了,所有點 5 的 Shallow Size ,加不到點 4、點 2、點 1 身上去了**。因此我們做些特殊的處理,讓mBuffer 記下其對應的 bitmap 物件,計算 Retained Size 時,碰到 mBuffer ,直接將其 Shallow Size 加至支配樹中其記下的 bitmap 和 bitmap 的祖先鏈上去**。

那 MAT 就是錯的嗎?並不是,按照 retained size 的定義,既然 bitmap 並不是 mBuffer 的直接支配點了,那 bitmap 所支配的大小確確實實就不包含 mBuffer 的大小。只是考慮到 mBuffer 作為 GC Root 的狀態是變化的,而開發者又希望能夠直觀瞭解應用中點陣圖的大小,才產生了這個“修補”策略。

如果某個物件是 GC Root,那麼它的記憶體當然不會被回收。但有時候這個物件就不應該一直還是 GC Root。比如我們常常調侃單例模式其實就是個洩漏,因為靜態成員讓其成為了 GC Root,記憶體永遠無法釋放。所以你應該在不再需要某個物件的時候,斷掉對它的強引用(無論是讓其不再不合理的成為 GC Root或是斷掉其被引用鏈中的一環)。對於圖片來說,如果你選擇自行管理其載入快取等,那你可能還需要及時的 bitmap.recycle() , 該方法會斷掉對 mbuffer 的引用。如果你使用 Fresco ,那你需要確保 DraweeView 的 onAttach 和 onDetach 能夠正確及時的被呼叫。

此外,以上修補策略僅限於 8.0 以下。在 Android 8.0及以上,java 層 Bitmap 不再持有 mBuffer 成員,畫素資料被移至 Zygote Heap。

引用鏈路的構造

通過 Retained Size 大小找到懷疑物件之後,需要找到它被引用的鏈路。物件的被引用路徑其實就是個樹,從懷疑物件開始,一層一層展開,樹的葉子們就是 GC Root 。(見成果展示中的 Leak Trace 附圖)

考慮到實際需要,這裡採用是類似寬搜的方式,維護一個 FIFO 佇列, 從懷疑物件開始,當搜尋到GC Root 時儲存當前的搜尋狀態,並返回路徑。然後無限重複從儲存的狀態繼續搜尋,直到該次搜尋找不到路徑(返回為空)。最終得到若干條”最短路徑“,也就是該物件的一條條的伸展開來的被引用鏈路。

注意到每一條路徑中的任意相鄰的點構成的線段實際上就代表了我們最終構造的樹中的父子關係,遍歷這些線段,完成這個有向圖的儲存即可。這裡用鄰接表的方式存下這個樹,舉個例子,這個資料結構可以長這樣

public final class PathsFromGCRootsTree{
    private String description;
	private ArrayList<PathsFromGCRootsTree> inboundTrees;
}
複製程式碼

Something else

其實這個專案一開始不是一個放在服務端的離線的面向開發者的分析系統,我們想在客戶端線上搞定這個事情。在接近 OOM 的時候 dump hprof,另起程式分析,分析完上報,這樣會解決很多痛點。不理想的是

  • Debug.dumpHprof()會造成 GC,在接近 OOM 時的 GC 更是卡頓的讓人無法接受。
  • dump 下來的 hprof 檔案如果對映至記憶體,動輒兩三百兆(經觀察發現它與 hprof 中物件的多少正相關),這顯然會直接讓分析程式就 OOM 了。
  • 我們嘗試過解決分析程式 OOM 的問題,設定一個閾值,捨棄同型別例項中的數量大於閾值的那一部分,邊瘦身邊對映。這樣確實可以繼續分析了,然而由於不少例項被捨棄,引用關係這張圖變的殘缺,也就導致引用鏈路構造變得不準確。

另外,研發過程中我們碰到並解決了巨多細節上的坑和問題,限於篇幅,感興趣的同學可以私下交流探討。

相關文章