Android最佳效能實踐(2):分析記憶體的使用情況

發表於2015-08-16

由於Android是為移動裝置開發的作業系統,我們在開發應用程式的時候應當始終把記憶體問題充分考慮在內。雖然Android系統擁有垃圾自動回收機制,但這並不意味著我們就可以完全忽略何時去分配或釋放記憶體。即使我們全部按照上一篇文章中給出的程式設計建議來去編寫程式,還是會很有可能出現記憶體洩露或其它型別的記憶體問題。所以,唯一能夠解決問題的辦法,就是嘗試去分析應用程式的記憶體使用情況,那麼本篇文章就會教大家如何進行分析。如果你還沒有看過前面一篇文章,建議先去閱讀 Android最佳效能實踐(一)——合理管理記憶體 。

雖說現在的手機記憶體都已經非常大了,但是我們大家都知道,系統是不可能將所有的記憶體都分配給我們的應用程式的。沒錯,每個程式都會有可使用的記憶體上限,這被稱為堆大小(Heap Size)。不同的手機,堆大小也不盡相同,隨著現在硬體裝置不斷提高,堆大小也已經由Nexus One時的32MB,變成了Nexus 5時的192MB。如果大家想要知道自己手機的堆大小是多少,可以呼叫如下程式碼:

結果是以MB為單位進行返回的,我們在開發應用程式時所使用的記憶體不能超出這個限制,否則就會出現OutOfMemoryError。因此,比如說我們的程式中需要快取一些資料,就可以根據堆大小來決定快取資料的容量。

下面我們來討論一下Android的GC操作,GC全稱是Garbage Collection,也就是所謂的垃圾回收。Android系統會在適當的時機觸發GC操作,一旦進行GC操作,就會將一些不再使用的物件進行回收。那麼哪些物件會被認為是不再使用,並且可以被回收的呢?我們來看下面一張圖:

上圖當中,每個藍色的圓圈就代表一個記憶體當中的物件,而圓圈之間的箭頭就是它們的引用關係。這些物件有些是處於活動狀態的,而有些就已經不再被使用了。那麼GC操作會從一個叫作Roots的物件開始檢查,所有它可以訪問到的物件就說明還在使用當中,應該進行保留,而其它的物件就表示已經不再被使用了,如下圖所示:

可以看到,目前所有黃色的物件仍然會被系統繼續保留,而藍色的物件就會在GC操作當中被系統回收掉了,這大概就是Android系統一次簡單的GC流程。

那麼什麼時候會觸發GC操作呢?這個通常都是由系統去決定的,我們一般情況下都不需要主動通知系統應該去GC了(雖然我們確實可以這麼做,下面會講到),但是我們仍然可以去監聽系統的GC過程,以此來分析我們應用程式當前的記憶體狀態。那麼怎樣才能去監聽系統的GC過程呢?其實非常簡單,系統每進行一次GC操作時,都會在LogCat中列印一條日誌,我們只要去分析這條日誌就可以了,日誌的基本格式如下所示:

注意這裡我仍然是以dalvik虛擬機器來進行說明,art情況下列印的內容也是基本類似的。。

首先第一部分GC_Reason,這個是觸發這次GC操作的原因,一般情況下一共有以下幾種觸發GC操作的原因:

  • GC_CONCURRENT:   當我們應用程式的堆記憶體快要滿的時候,系統會自動觸發GC操作來釋放記憶體。
  • GC_FOR_MALLOC:   當我們的應用程式需要分配更多記憶體,可是現有記憶體已經不足的時候,系統會進行GC操作來釋放記憶體。
  • GC_HPROF_DUMP_HEAP:   當生成HPROF檔案的時候,系統會進行GC操作,關於HPROF檔案我們下面會講到。
  • GC_EXPLICIT:   這種情況就是我們剛才提到過的,主動通知系統去進行GC操作,比如呼叫System.gc()方法來通知系統。或者在DDMS中,通過工具按鈕也是可以顯式地告訴系統進行GC操作的。

接下來第二部分Amount_freed,表示系統通過這次GC操作釋放了多少記憶體。

然後Heap_stats中會顯示當前記憶體的空閒比例以及使用情況(活動物件所佔記憶體 / 當前程式總記憶體)。

最後Pause_time表示這次GC操作導致應用程式暫停的時間。關於這個暫停的時間,Android在2.3的版本當中進行過一次優化,在2.3之前GC操作是不能併發進行的,也就是系統正在進行GC,那麼應用程式就只能阻塞住等待GC結束。雖說這個阻塞的過程並不會很長,也就是幾百毫秒,但是使用者在使用我們的程式時還是有可能會感覺到略微的卡頓。而自2.3之後,GC操作改成了併發的方式進行,就是說GC的過程中不會影響到應用程式的正常執行,但是在GC操作的開始和結束的時候會短暫阻塞一段時間,不過優化到這種程度,使用者已經是完全無法察覺到了。

下面是一次GC操作在LogCat中列印的日誌:

可以看出,和我們上面所介紹的格式是完全一致的,最後的暫停時間31ms+7ms,一次就是GC開始時的暫停時間,一次是結束時的暫停時間。另外可以根據程式id來區分這是哪個程式中進行的GC操作,那麼從上圖就可以看出這條GC日誌是屬於24699這個程式的。

那麼這是使用dalvik執行環境時所列印的GC日誌,而自Android 4.4版本之後加入了art執行環境,在art中列印GC日誌基本和dalvik是相同的,如下圖所示:

相信沒有什麼難理解的地方吧,art中只是內容顯示的格式有了稍許變化,列印的主體內容仍然是不變的。

好的,通過日誌的方式我們可以簡單瞭解到系統的GC工作情況,但是如果我們想要更加清楚地實時知曉當前應用程式的記憶體使用情況,只通過日誌就有些力不從心了,我們需要通過DDMS中提供的工具來實現。

開啟DDMS介面,在左側皮膚中選擇你要觀察的應用程式程式,然後點選Update Heap按鈕,接著在右側皮膚中點選Heap標籤,之後不停地點選Cause GC按鈕來實時地觀察應用程式記憶體的使用情況即可,如下圖所示:

接著繼續操作我們的應用程式,然後繼續點選Cause GC按鈕,如果你發現反覆操作某一功能會導致應用程式記憶體持續增高而不會下降的話,那麼就說明這裡很有可能發生記憶體洩漏了。

好了,討論完了GC,接下來我們討論一下Android中記憶體洩漏的問題。大家需要知道的是,Android中的垃圾回收機制並不能防止記憶體洩漏的出現,導致記憶體洩漏最主要的原因就是某些長存物件持有了一些其它應該被回收的物件的引用,導致垃圾回收器無法去回收掉這些物件,那也就出現記憶體洩漏了。比如說像Activity這樣的系統元件,它又會包含很多的控制元件甚至是圖片,如果它無法被垃圾回收器回收掉的話,那就算是比較嚴重的記憶體洩漏情況了。

下面我們來模擬一種Activity記憶體洩漏的場景,內部類相信大家都有用過,如果我們在一個類中又定義了一個非靜態的內部類,那麼這個內部類就會持有外部類的引用,如下所示:

目前來看,程式碼還是沒有問題的,因為雖然LeakClass這個內部類持有MainActivity的引用,但是隻要它的存活時間不會長於MainActivity,就不會阻止MainActivity被垃圾回收器回收。那麼現在我們來將程式碼進行如下修改:

這下就有點不太一樣了,我們讓LeakClass繼承自Thread,並且重寫了run()方法,然後在MainActivity的onCreate()方法中去啟動LeakClass這個執行緒。而LeakClass的run()方法中執行了一個死迴圈,也就是說這個執行緒永遠都不會執行結束,那麼LeakClass這個物件就一直不能得到釋放,並且它持有的MainActivity也將無法得到釋放,那麼記憶體洩露就出現了。

現在我們可以將程式執行起來,然後不斷地旋轉手機讓程式在橫屏和豎屏之間切換,因為每切換一次Activity都會經歷一個重新建立的過程,而前面建立的Activity又無法得到回收,那麼長時間操作下我們的應用程式所佔用的記憶體就會越來越高,最終出現OutOfMemoryError。

下面我貼出一張不斷切換橫豎屏時GC日誌列印的結果圖,如下所示:

可以看到,應用程式所佔用的記憶體是在不斷上升的。最可怕的是,這些記憶體一旦升上去了就永遠不會再降下來,直到程式崩潰為止,因為這部分洩露的記憶體一直都無法被垃圾回收器回收掉。

那麼通過上面學習的GC日誌以及DDMS工具這兩種方式,現在我們已經可以比較輕鬆地發現應用程式中是否存在記憶體洩露的現象了。但是如果真的出現了記憶體洩露,我們應該怎麼定位到具體是哪裡出的問題呢?這就需要藉助一個記憶體分析工具了,叫做Eclipse Memory Analyzer(MAT)。我們需要先將這個工具下載下來,下載地址是:http://eclipse.org/mat/downloads.php。這個工具分為Eclipse外掛版和獨立版兩種,如果你是使用Eclipse開發的,那麼可以使用外掛版MAT,非常方便。如果你是使用Android Studio開發的,那麼就只能使用獨立版的MAT了。

下載好了之後下面我們開始學習如何去分析記憶體洩露的原因,首先還是進入到DDMS介面,然後在左側皮膚選中我們要觀察的應用程式程式,接著點選Dump HPROF file按鈕,如下圖所示:

點選這個按鈕之後需要等待一段時間,然後會生成一個HPROF檔案,這個檔案記錄著我們應用程式內部的所有資料。但是目前MAT還是無法開啟這個檔案的,我們還需要將這個HPROF檔案從Dalvik格式轉換成J2SE格式,使用hprof-conv命令就可以完成轉換工作,如下所示:

hprof-conv命令檔案存放於<Android Sdk>/platform-tools目錄下面。另外如果你是使用的外掛版的MAT,也可以直接在Eclipse中開啟生成的HPROF檔案,不用經過格式轉換這一步。

好的,接下來我們就可以來嘗試使用MAT工具去分析記憶體洩漏的原因了,這裡需要提醒大家的是,MAT並不會準確地告訴我們哪裡發生了記憶體洩漏,而是會提供一大堆的資料和線索,我們需要自己去分析這些資料來去判斷到底是不是真的發生了記憶體洩漏。那麼現在執行MAT工具,然後選擇開啟轉換過後的converted-dump.hprof檔案,如下圖所示:

MAT中提供了非常多的功能,這裡我們只要學習幾個最常用的就可以了。上圖最中央的那個餅狀圖展示了最大的幾個物件所佔記憶體的比例,這張圖中提供的內容並不多,我們可以忽略它。在這個餅狀圖的下方就有幾個非常有用的工具了,我們來學習一下。

Histogram可以列出記憶體中每個物件的名字、數量以及大小。

Dominator Tree會將所有記憶體中的物件按大小進行排序,並且我們可以分析物件之間的引用結構。

一般最常用的就是以上兩個功能了,那麼我們先從Dominator Tree開始學起。

現在點選Dominator Tree,結果如下圖所示:

這張圖包含的資訊非常多,我來帶著大家一起解析一下。首先Retained Heap表示這個物件以及它所持有的其它引用(包括直接和間接)所佔的總記憶體,因此從上圖中看,前兩行的Retained Heap是最大的,我們分析記憶體洩漏時,記憶體最大的物件也是最應該去懷疑的。

另外大家應該可以注意到,在每一行的最左邊都有一個檔案型的圖示,這些圖示有的左下角帶有一個紅色的點,有的則沒有。帶有紅點的物件就表示是可以被GC Roots訪問到的,根據上面的講解,可以被GC Root訪問到的物件都是無法被回收的。那麼這就說明所有帶紅色的物件都是洩漏的物件嗎?當然不是,因為有些物件系統需要一直使用,本來就不應該被回收。我們可以注意到,上圖當中所有帶紅點的物件最右邊都有寫一個System Class,說明這是一個由系統管理的物件,並不是由我們自己建立並導致記憶體洩漏的物件。

那麼上圖中就無法看出記憶體洩漏的原因了嗎?確實,記憶體洩漏本來就不是這麼容易找出的,我們還需要進一步進行分析。上圖當中,除了帶有System Class的行之外,最大的就是第二行的Bitmap物件了,雖然Bitmap物件現在不能被GC Roots訪問到,但不代表著Bitmap所持有的其它引用也不會被GC Roots訪問到。現在我們可以對著第二行點選右鍵 -> Path to GC Roots -> exclude weak references,為什麼選擇exclude weak references呢?因為弱引用是不會阻止物件被垃圾回收器回收的,所以我們這裡直接把它排除掉,結果如下圖所示:

可以看到,Bitmap物件經過層層引用之後,到了MainActivity$LeakClass這個物件,然後在圖示的左下角有個紅色的圖示,就說明在這裡可以被GC Roots訪問到了,並且這是由我們自己建立的Thread,並不是System Class了,那麼由於MainActivity$LeakClass能被GC Roots訪問到導致不能被回收,導致它所持有的其它引用也無法被回收了,包括MainActivity,也包括MainActivity中所包含的圖片。

通過這種方式,我們就成功地將記憶體洩漏的原因找出來了。這是Dominator Tree中比較常用的一種分析方式,即搜尋大記憶體物件通向GC Roots的路徑,因為記憶體佔用越高的物件越值得懷疑。

接下來我們再來學習一下Histogram的用法,回到Overview介面,點選Histogram,結果如下圖所示:

這裡是把當前應用程式中所有的物件的名字、數量和大小全部都列出來了,需要注意的是,這裡的物件都是隻有Shallow Heap而沒有Retained Heap的,那麼Shallow Heap又是什麼意思呢?就是當前物件自己所佔記憶體的大小,不包含引用關係的,比如說上圖當中,byte[]物件的Shallow Heap最高,說明我們應用程式中用了很多byte[]型別的資料,比如說圖片。可以通過右鍵 -> List objects -> with incoming references來檢視具體是誰在使用這些byte[]。

那麼通過Histogram又怎麼去分析記憶體洩漏的原因呢?當然其實也可以用和Dominator Tree中比較相似的方式,即分析大記憶體的物件,比如上圖中byte[]物件記憶體佔用很高,我們通過分析byte[],最終也是能找到記憶體洩漏所在的,但是這裡我準備使用另外一種更適合Histogram的方式。大家可以看到,Histogram中是可以顯示物件的數量的,那麼比如說我們現在懷疑MainActivity中有可能存在記憶體洩漏,就可以在第一行的正規表示式框中搜尋“MainActivity”,如下所示:

可以看到,這裡將包含“MainActivity”字樣的所有物件全部列出了出來,其中第一行就是MainActivity的例項。但是大家有沒有注意到,當前記憶體中是有11個MainActivity的例項的,這太不正常了,通過情況下一個Activity應該只有一個例項才對。其實這些物件就是由於我們剛才不斷地橫豎屏切換所產生的,因為橫豎屏切換一次,Activity就會經歷一個重新建立的過程,但是由於LeakClass的存在,之前的Activity又無法被系統回收,那麼就出現這種一個Activity存在多個例項的情況了。

接下來對著MainActivity右鍵 -> List objects -> with incoming references檢視具體MainActivity例項,如下圖所示:

如果想要檢視記憶體洩漏的具體原因,可以對著任意一個MainActivity的例項右鍵 -> Path to GC Roots -> exclude weak references,結果如下圖所示:

可以看到,我們再次找到了記憶體洩漏的原因,是因為MainActivity$LeakClass物件所導致的。

好了,這大概就是MAT工具最常用的一些用法了,當然這裡還要提醒大家一句,工具是死的,人是活的,MAT也沒有辦法保證一定可以將記憶體洩漏的原因找出來,還是需要我們對程式的程式碼有足夠多的瞭解,知道有哪些物件是存活的,以及它們存活的原因,然後再結合MAT給出的資料來進行具體的分析,這樣才有可能把一些隱藏得很深的問題原因給找出來。

那麼今天也是介紹了挺多內容了,本篇文章的講解就到這裡,由於春節馬上就要到了,這也是今年的最後一篇文章,這裡先給大家拜個早年,祝大家春節快樂。放假期間希望大家可以和我一樣,放下程式碼,好好休息一段時間,因此下篇文章將會在年後更新,介紹一些高效能編碼的技巧,感興趣的朋友請繼續閱讀 Android最佳效能實踐(三)——高效能編碼優化 。

相關文章