摘要:爲了避免上述OOM分析時候出現的弊端,我們開發了一套對於內存快照(hprof文件)的線下分析工具,只需要開發者將生成的hprof文件上傳即可, 該工具會進行內存快照的解析、支配樹的生成、RetainSize 的計算、引用鏈路的構造得到內存分析結果,具體的實現流程如下:。我們知道在android中GC(垃圾回收機制)會不斷的清理應用在運行期間產生的不再使用的對象,即我們在編程中常說的對象不再使用的時候要及時的釋放掉或者置爲null,GC的過程中會回收混雜在連續內存空間中的不再使用的對象,這樣就導致了可用的內存空間不再連續,當應用再次向系統申請空間的時候,沒有一段連續的內存空間滿足條件的時候就會拋出OOM的異常,當然如果本來就沒有任何空間可以使用就會立即拋出OOM異常。

背景

在我們日常開發工作中,遇到的崩潰問題大多說是可以通過堆棧日誌信息找到問題的關鍵所在,但是內存的泄漏和不合理的使用導致的OOM(Out-Of-Memory)卻無法在堆棧日誌中定位到問題,通過dump內存得到內存鏡像對內存的使用進行分析需要專業的人員,爲了解決這些弊端,我們研發了一款用於開發者線下使用的內存分析系統。

OOM問題發生的常見場景

移動應用在運行的時候系統會分給應用指定大小的內存空間,在不同的設備上大小可能不一樣。應用在運行期間不斷的申請內存,當應用佔有的內存即將達到系統分配的閾值,應用再次申請大於剩餘空間的時候,這時候會導致應用直接閃退,在日誌中可以看到發生了OOM(Out-Of-Memory)的異常,當然這些都是表象,在android系統中導致OOM的情況大致分爲以下4種情況:

  • 系統分派的空間不足或者沒有連續足夠可用的空間(堆內存)

我們知道在android中GC(垃圾回收機制)會不斷的清理應用在運行期間產生的不再使用的對象,即我們在編程中常說的對象不再使用的時候要及時的釋放掉或者置爲null,GC的過程中會回收混雜在連續內存空間中的不再使用的對象,這樣就導致了可用的內存空間不再連續,當應用再次向系統申請空間的時候,沒有一段連續的內存空間滿足條件的時候就會拋出OOM的異常,當然如果本來就沒有任何空間可以使用就會立即拋出OOM異常。

  • FD的數量超過了系統所允許的最大值

FD是File Descriptor的縮寫,在Linux系統中,所以的操作都是對文件的操作,而文件操作是通過FD來實現的,當FD的數量超過了系統所允許的最大值,那麼也會拋出OOM。

  • 線程數超過了系統允許的最大值

手機所允許的最大線程數也是和廠商有關係,不同的手機廠商設置的最大線程閾值也是不相同的,當創建的線程超過了最大值,系統也會拋出OOM的異常。

  • 虛擬內存不足

這裏的虛擬內存指的是應用運行時候所產生的一塊與內核態內存映射的用戶態虛擬內存空間, 用戶通過該內存實現用戶態與內核態的調用操作,即native層方法的調用,如果該內存空間耗盡也會導致OOM。

發生OOM異常一般情況下都是由於情況1中的堆內存的空間不夠使用,雖然也遇到過線程數超過了系統最大限制的情況, 但是很少見到,其他兩種情況基本沒有見到過,所以接下來的分析中我們的側重點就是對於堆內存的解析。

OOM問題的常用分析方式

OOM與其他的崩潰的分析方法大相徑庭。一般的異常我們可以通過異常堆棧就可以分析定位原因,但是OOM導致崩潰時候的堆棧並不能準確的反饋出問題的關鍵,此時的堆棧反饋出來的只是壓死駱駝的最後一根稻草,也許是一個正常的操作。業界的分析方案一般爲以下幾種情況:

  • 開發期間集成LeakCanary,在開發人員自測的時候檢測內存泄漏情況

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

  • 通過命令獲取應用運行期間的內存快照,通過JVM內存分析工具MAT解析出當時的內存使用情況

在應用運行期間通過調用Debug.dumpHprofData(String fileName)函數得到當前進程的Java內存快照文件,再通過MAT工具導入hprof文件,使用該工具的解析結果分析android運行時候的內存使用情況。雖然MAT可以分析出內存快照中內存的使用情況,但是它是針對於java的一個內存分析工具且有一定的學習成本,對android的分析還有一些小的區別,並不是完全的符合android的內存分析。

Shooter系統內存分析工具介紹

爲了避免上述OOM分析時候出現的弊端,我們開發了一套對於內存快照(hprof文件)的線下分析工具,只需要開發者將生成的hprof文件上傳即可, 該工具會進行內存快照的解析、支配樹的生成、RetainSize 的計算、引用鏈路的構造得到內存分析結果,具體的實現流程如下:

1

內存快照的解析

1.1 內存快照hprof文件的獲取以及結構

  • 我們可以使用命令行來獲取內存快照,也可以通過Android Studio自帶的 Monitor 工具獲取。

  • Hprof 文件存儲了當前時刻堆的情況,主要包括類信息、棧幀信息、堆棧信息、堆信息。其中堆信息是我們關注的重點,其中包括了所有的對象:線程對象、類對象、實例對象、對象數組對象、原始類型數組對象。

1.2 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 的對象的頭上添加一個超級源點同時作爲他們的父親的話,其實我們就得到了一個以這個”超級源點“爲根的引用關係”樹“ 。(引號的原因是,實際情況裏引用之間可能存在環,嚴格的講它不一定是個樹)。

1.3 鏈路構建的起點GCRoot

GC Root 是虛擬機認定的。衆所周知,GC Roots 是 ART VM 垃圾回收算法設定的根,代表了從這些根出發,順着強引用關係所能達到對象是絕不會被回收的。 GC Roots 必須是對於當前 GC 堆的一組活躍的引用, 這是顯然的,因爲引用是活躍的,那麼引用直接或間接引用的對象們必然是有用的,是不能被回收的。知道了誰是不能回收的,也就知道了誰是能被回收的,GC 的目標也就找到了。這便是是 GC Roots 存在的意義。 GC Roots 分許多類型,比如:

Stack Local:Java 方法中的局部變量或者方法形參;

Thread:存活的線程;

JNI Local:JNI方法中的變量或者方法形參;

JNI Global:全局 JNI引用;

分代收集中,從非收集代指向收集代的引用等等;

同時,需要知道的是,GC Roots 是動態變化的,我們本次分析的時候這個引用是一個 GC Root , 但是下次同樣運行環境得到的hprof文件,這個引用卻不是一個 GC Root 了。

解析工作其實就是讀取文件中以上提到的信息,並將其映射至內存的過程。最終映射的結果是一個叫做堆快照的數據結構。

1.4 支配樹的生成與 RetainSize 的計算

1.4.1 支配點與支配樹

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

比如下圖中:

  • A 支配 B、C、D、E、F, 而 B 支配 D、E 不支配 F;

  • E 的直接支配點是 B;

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

1.4.2  Shallow Size 與 Retained Size

某個對象的 Shallow Size 是對象本身的大小,不包含其引用的對象,其實就是該對象所能維護和保有的大小,換句話說它代表了 如果回收掉該對象虛擬機所能收回掉的內存大小。

具體舉例如下:

public class A {

int a;

B b;

}

class A 的Shallow Size應該爲: 12(對象頭)+ 4(int a)+ 4(B b)+ 4(對齊) = 24 (關於對象頭,字段在內存中排列,對齊等不展開討論)

這裏重點關注字段 B 只計算了一個引用的大小:4 byte,而不管這個 B 有多少字段,每個字段是什麼。

某個對象的 Retained Size 是其支配的所有節點的 Shallow Size 之和。各個對象的 Retained Size大小是我們分析內存使用情況的重要指標,當某些對象的Retained Size 過大時,可能代表着不合理的內存使用或者泄露。

1.4.3 支配樹的生成

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

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

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

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

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

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

1.4.4 Retained Size 計算

有了支配樹,Retained Size 計算就是個累加過程。 遍歷每個點,將其 Shallow Size 加至支配樹裏其所有祖先身上去。 當遍歷完的時候,所有點的 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。

1.5 引用鏈路的構造

通過 Retained Size 大小找到懷疑對象之後,需要找到它被引用的鏈路。對象的被引用路徑其實就是個樹,從懷疑對象開始,一層一層展開,樹的葉子們就是 GC Root 。

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

注意到每一條路徑中的任意相鄰的點構成的線段實際上就代表了我們最終構造的樹中的父子關係,遍歷這些線段,完成這個有向圖的存儲即可。

2

內存分析的關注點

在上述的分析中我們主要進行的是鏈路的構造工作以及鏈路中每個節點在內存中佔用的空間大小的計算,通過將內存中的對象按照引用構造好我們就可以直觀的查看到是什麼佔用了大量的內存。

根據Android的特性,我們按照如下維度進行了彙總。

2.1 Activity/Fragment對象的引用鏈路

與用戶交互的具有生命週期的控件是否有內存泄漏,這個主要是觀察一個Activity/Fragment是否有多個實例對象存在於內存中,如果有多個實例對象存在於內存中,說明該控件可能被其他對象所引用,沒有及時的得到釋放,需要重點關注。

2.2 圖片信息以及引用鏈路

圖片一直是Android內存中重量級選手,各種圖片框架都通過三級緩存等複用機制來降低對內存的佔用,內存不夠使用甚至導致OOM很大一部分都是圖片導致的,可能圖片的尺寸不對,縮放的大小不合理,像素點佔用的大小不合理等問題導致的,所以這裏我們將圖片按照佔用內存由大到小展示出來,如圖所示:

2.3 對象的引用鏈路展示

上述圖片中每條記錄點擊LeakTrace即可查看當前對象的引用鏈路:

2.4  內存快照中各對象的空間佔用情況

上述兩種情況是一般內存泄漏以及導致OOM的關鍵點,當然可能不是上述兩種情況導致的,那麼就可能是某個對象創建的次數太多,在內存中佔有的空間太大,這樣就可以根據對象在內存中的個數以及佔用空間的大小確認導致OOM的問題了。

通過上述流程查詢一遍,基本就可以找到出現OOM的問題所在了:

結語

Shooter 系統致力於應用性能的監控工作,內存監控監控只是其中的一個點,還包括網絡,webview,圖片等各種監控方案。內存分析工具本意是做成一個線下的工具,但是由於Dump時候系統的GC會導致卡死,hprof文件映射到內存會佔用大量的空間而沒有完全的實現,我們會努力尋找相關的解決方案,不斷的完善線下內存分析。

相關文章