Android性能優化系列之內存優化

在Java中,內存的分配是由程序完成的,而內存的釋放是由垃圾收集器(Garbage Collection,GC)完成的,程序員不需要通過調用函數來釋放內存,但也隨之帶來了內存泄漏的可能,上篇博客,我介紹了 Android性能優化系列之佈局優化,本篇博客,我將介紹內存優化的相關知識。

內存的分配策略概述

程序運行時的內存分配有三種策略,分別是靜態的,棧式的,和堆式的,對應的,三種存儲策略使用的內存空間主要分別是靜態存儲區(也稱方法區)、堆區和棧區。

靜態存儲區(方法區):內存在程序編譯的時候就已經分配好,這塊內存在程序整個運行期間都存在。它主要存放靜態數據、全局static數據和常量。

棧區:在執行函數時,函數內局部變量的存儲單元都可以在棧上創建,函數執行結束時這些存儲單元自動被釋放。棧內存分配運算內置於處理器的指令集中,效率很高,但是分配的內存容量有限。

堆區:亦稱動態內存分配。程序在運行的時候用malloc或new申請任意大小的內存,程序員自己負責在適當的時候用free或delete釋放內存(Java則依賴垃圾回收器)。動態內存的生存期可以由我們決定,如果我們不釋放內存,程序將在最後才釋放掉動態內存。 但是,良好的編程習慣是:如果某動態內存不再使用,需要將其釋放掉。

堆和棧的區別: 
在函數中(說明是局部變量)定義的一些基本類型的變量和對象的引用變量都是在函數的棧內存中分配。當在一段代碼塊中定義一個變量時,java就在棧中爲這個變量分配內存空間,當超過變量的作用域後,java會自動釋放掉爲該變量分配的內存空間,該內存空間可以立刻被另作他用。

堆內存用於存放所有由new創建的對象(內容包括該對象其中的所有成員變量)和數組。在堆中分配的內存,由java虛擬機自動垃圾回收器來管理。在堆中產生了一個數組或者對象後,還可以在棧中定義一個特殊的變量,這個變量的取值等於數組或者對象在堆內存中的首地址,在棧中的這個特殊的變量就變成了數組或者對象的引用變量,以後就可以在程序中使用棧內存中的引用變量來訪問堆中的數組或者對象,引用變量相當於爲數組或者對象起的一個別名,或者代號

堆是不連續的內存區域(因爲系統是用鏈表來存儲空閒內存地址,自然不是連續的),堆大小受限於計算機系統中有效的虛擬內存(32bit系統理論上是4G),所以堆的空間比較靈活,比較大。棧是一塊連續的內存區域,大小是操作系統預定好的,windows下棧大小是2M(也有是1M,在編譯時確定,VC中可設置)。

對於堆,頻繁的new/delete會造成大量內存碎片,使程序效率降低。對於棧,它是先進後出的隊列,進出一一對應,不產生碎片,運行效率穩定高。 
這裏寫圖片描述

所以我們可以得出結論: 
1.局部變量的基本數據類型和引用存儲於棧中,引用的對象實體存儲於堆中。因爲它們屬於方法中的變量,生命週期隨方法而結束。

2.成員變量全部存儲與堆中(包括基本數據類型,引用和引用的對象實體),因爲它們屬於類,類對象終究是要被new出來使用的。

3.我們所說的內存泄露,只針對堆內存,他們存放的就是引用指向的對象實體。

內存泄露產生的原因

在Java中,內存的分配是由程序完成的,而內存的釋放是由垃圾收集器(Garbage Collection,GC)完成的,程序員不需要通過調用函數來釋放內存,但它只能回收無用並且不再被其它對象引用的那些對象所佔用的空間。

Java的內存垃圾回收機制是從程序的主要運行對象(如靜態對象/寄存器/棧上指向的堆內存對象等)開始檢查引用鏈,當遍歷一遍後得到上述這些無法回收的對象和他們所引用的對象鏈,組成無法回收的對象集合,而其他孤立對象(集)就作爲垃圾回收。GC爲了能夠正確釋放對象,必須監控每一個對象的運行狀態,包括對象的申請、引用、被引用、賦值等,GC都需要進行監控。監視對象狀態是爲了更加準確地、及時地釋放對象,而釋放對象的根本原則就是該對象不再被引用。

在Java中,這些無用的對象都由GC負責回收,因此程序員不需要考慮這部分的內存泄露。雖然,我們有幾個函數可以訪問GC,例如運行GC的函數System.gc(),但是根據Java語言規範定義,該函數不保證JVM的垃圾收集器一定會執行。因爲不同的JVM實現者可能使用不同的算法管理GC。通常GC的線程的優先級別較低。JVM調用GC的策略也有很多種,有的是內存使用到達一定程度時,GC纔開始工作,也有定時執行的,有的是平緩執行GC,有的是中斷式執行GC。但通常來說,我們不需要關心這些。

但是我們仍然可以去監聽系統的GC過程,以此來分析我們應用程序當前的內存狀態。那麼怎樣才能去監聽系統的GC過程呢?其實非常簡單,系統每進行一次GC操作時,都會在LogCat中打印一條日誌,我們只要去分析這條日誌就可以了,日誌的基本格式如下所示:

1.D/dalvikvm: <GC_Reason> <Amount_freed>, <Heap_stats>,  <Pause_time>  
  • 1

首先第一部分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操作的開始和結束的時候會短暫阻塞一段時間,不過優化到這種程度,用戶已經是完全無法察覺到了。

我們來看看Java中需要被回收的垃圾:

{
Person p1 = new Person();
……
}
  • 1
  • 2
  • 3
  • 4

引用句柄p1的作用域是從定義到“}”處,執行完這對大括號中的所有代碼後,產生的Person對象就會變成垃圾,因爲引用這個對象的句柄p1已超過其作用域,p1失效,在棧中被銷燬,因此堆上的Person對象不再被任何句柄引用了。 因此person變爲垃圾,會被回收。

這裏我們需要講述一個關鍵詞:引用,通過A能調用並訪問到B,那就說明A持有B的引用,或A就是B的引用,B的引用計數+1.

(1)比如 Person p1 = new Person();通過P1能操作Person對象,因此P1是Person的引用; 
(2)比如類O中有一個成員變量是I類對象,因此我們可以使用o.i的方式來訪問I類對象的成員,因此o持有一個i對象的引用。

GC過程與對象的引用類型是嚴重相關的,我們來看看Java對引用的分類Strong reference, SoftReference, WeakReference, PhatomReference

這裏寫圖片描述

在Android應用的開發中,爲了防止內存溢出,在處理一些佔用內存大而且聲明週期較長的對象時候,可以儘量應用軟引用和弱引用技術。

軟/弱引用可以和一個引用隊列(ReferenceQueue)聯合使用,如果軟引用所引用的對象被垃圾回收器回收,Java虛擬機就會把這個軟引用加入到與之關聯的引用隊列中。利用這個隊列可以得知被回收的軟/弱引用的對象列表,從而爲緩衝器清除已失效的軟/弱引用。

假設我們的應用會用到大量的默認圖片,比如應用中有默認的頭像,默認遊戲圖標等等,這些圖片很多地方會用到。如果每次都去讀取圖片,由於讀取文件需要硬件操作,速度較慢,會導致性能較低。所以我們考慮將圖片緩存起來,需要的時候直接從內存中讀取。但是,由於圖片佔用內存空間比較大,緩存很多圖片需要很多的內存,就可能比較容易發生OutOfMemory異常。這時,我們可以考慮使用軟/弱引用技術來避免這個問題發生。以下就是高速緩衝器的雛形:

首先定義一個HashMap,保存軟引用對象。

1.private Map<String, SoftReference<Bitmap>> imageCache = new HashMap<String, SoftReference<Bitmap>>();
  • 1

再來定義一個方法,保存Bitmap的軟引用到HashMap

public class CacheSoftRef {

    //首先定義一個HashMap,保存引用對象
    private Map<String, SoftReference<Bitmap>> imageCache = new HashMap<String, SoftReference<Bitmap>>();

    //再來定義一個方法,保存Bitmap的軟引用到HashMap
    public void addBitmapToCache(String path) {
        //強引用的Bitmap對象
        Bitmap bitmap = BitmapFactory.decodeFile(path);
        //軟引用的Bitmap對象
        SoftReference<Bitmap> softBitmap = new SoftReference<Bitmap>(bitmap);
        //添加該對象到Map中使其緩存
        imageCache.put(path, softBitmap);
    }

    //獲取的時候,可以通過SoftReference的get()方法得到Bitmap對象
    public Bitmap getBitmapByPath(String path) {
        //從緩存中取軟引用的Bitmap對象
        SoftReference<Bitmap> softBitmap = imageCache.get(path);
        //判斷是否存在軟引用
        if (softBitmap == null) {
            return null;
        }
        //通過軟引用取出Bitmap對象,如果由於內存不足Bitmap被回收,將取得空,如果未被回收,
        //則可重複使用,提高速度。
        Bitmap bitmap = softBitmap.get();
        return bitmap;
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29

使用軟引用以後,在OutOfMemory異常發生之前,這些緩存的圖片資源的內存空間可以被釋放掉的,從而避免內存達到上限,避免Crash發生。

如果只是想避免OutOfMemory異常的發生,則可以使用軟引用。如果對於應用的性能更在意,想盡快回收一些佔用內存比較大的對象,則可以使用弱引用。

另外可以根據對象是否經常使用來判斷選擇軟引用還是弱引用。如果該對象可能會經常使用的,就儘量用軟引用。如果該對象不被使用的可能性更大些,就可以用弱引用。

所以我們得出內存泄漏的原因:堆內存中的長生命週期的對象持有短生命週期對象的強/軟引用,儘管短生命週期對象已經不再需要,但是因爲長生命週期對象持有它的引用而導致不能被回收,這就是Java中內存泄露的根本原因。

內存泄漏的檢測

說了那麼多關於內存分配的知識,接下來我們就看看Android給我們提供了哪些工具來解決內存泄漏的問題

Allocation Tracker(Device Monitor)

Allocation Tracker位於Android Device Monitor中 
這裏寫圖片描述

Allocation Tracker面板 
這裏寫圖片描述

各名稱的含義如下: 
這裏寫圖片描述

Allocation Tracker操作 
1.首先進入你要追蹤的界面 
2.點擊Start Tracking按鈕,開始跟蹤內存分配軌跡 
3.操作你的界面,儘量時間短點 
4.點擊Get Allocations按鈕,抓去內存分配軌跡信息,顯示在右邊的面板中,默認以內存大小排序,你可以以分配順序排序或者仍以列排序。 
5.logcat中會顯示出這次的軌跡共抓到內存分配軌跡記錄數,可以簡單的理解分配了多少次內存,這個數值和Alloc order的最大值是相等的 
6.如果你不想看那麼多亂七八糟的,你可以使用Filter來過濾,輸入包名就可以了。

跟蹤內存軌跡 
如果這個時候我們想單獨獲取某次操作的內存軌跡,首先一定要記得Stop Tracking再Start Tracking一下,讓追蹤點初始化一下,然後就進行我們需要觀察內存變化的操作,然後點擊Get Allocations,這個時候我們從首頁進入一個詳情頁,看一下我們的內存分配軌跡: 
這裏寫圖片描述

追蹤到的內存分配20293次,看着是不是有點無從下手,沒關係,用Filter過濾下: 
這裏寫圖片描述

過濾後,就剩下了跟我們App源碼有關係的分配軌跡,我們隨便選擇一欄,可以看到其trace信息: 
這裏寫圖片描述

上圖中,我們可以看出來,在第635次內存分配中,分配的是IntroduceFragment對象,佔用內存224字節,處理線程Id爲3245,在java.lang.Class的newInstance方法中分配的。從trace信息可以看出來該方法一步一步被調用的信息。

DDMS的Heap Viewer

Heap Viewer能做什麼? 
實時查看App分配的內存大小和空閒內存大小 
發現Memory Leaks

在Devices 中,點擊要監控的程序。 
點擊Devices視圖界面中最上方一排圖標中的“Update Heap” 
點擊Heap視圖 
點擊Heap視圖中的“Cause GC”按鈕 
到此爲止需檢測的進程就可以被監視。 
這裏寫圖片描述

按上圖的標記順序按下,我們就能看到內存的具體數據,右邊面板中數值會在每次GC時發生改變,包括App自動觸發或者你來手動觸發。 
ok,現在來解釋下面板中的名詞

總覽 
這裏寫圖片描述

這裏寫圖片描述

詳情 
這裏寫圖片描述

這裏寫圖片描述

下面是每一個對象都有的列名含義: 
這裏寫圖片描述

當我們點擊某一行時,可以看到如下的柱狀圖: 
這裏寫圖片描述

橫座標是對象的內存大小,這些值隨着不同對象是不同的,縱座標是在某個內存大小上的對象的數量

Heap Viewer的使用 
我們說Heap Viewer適合發現內存泄漏的問題,那麼如何檢測呢? 
那麼如何檢測呢? 
進入某應用,不斷的操作該應用,同時注意觀察data object的Total Size值,正常情況下Total Size值都會穩定在一個有限的範圍內,也就是說由於程序中的的代碼良好,沒有造成對象不被垃圾回收的情況。

所以說雖然我們不斷的操作會不斷的生成很多對象,而在虛擬機不斷的進行GC的過程中,這些對象都被回收了,內存佔用量會會落到一個穩定的水平;反之如果代碼中存在沒有釋放對象引用的情況,則data object的Total Size值在每次GC後不會有明顯的回落。隨着操作次數的增多Total Size的值會越來越大,直到到達一個上限後導致進程被殺掉。

MAT工具

那麼通過上面DDMS工具,現在我們已經可以比較輕鬆地發現應用程序中是否存在內存泄露的現象了。 
我們應該怎麼定位到具體是哪裏出的內存泄露呢?這就需要藉助一個內存分析工具了,叫做Eclipse Memory Analyzer(MAT)。下載地址是:http://eclipse.org/mat/downloads.php。 
爲了使用該工具,我們需要hprof文件。但是該文件不能直接被MAT使用,需要進行一步轉化,可以使用hprof-conv命令來轉化,但是Android Studio可以直接轉化,轉化方法如下:

1.選擇一個hprof文件,點擊右鍵選擇Export to standard .hprof選項。 
這裏寫圖片描述

2.填寫更改後的文件名和路徑: 
這裏寫圖片描述

點擊OK按鈕後,MAT工具所需的文件就生成了,下面我們用MAT來打開該工具: 
1.打開MAT後選擇File->Open File選擇我們剛纔生成的doctorq.hprof文件 
2.選擇該文件後,MAT會有幾秒種的時間解析該文件,有的hprof文件可能過大,會有更長的時間解析,解析後,展現在我們的面前的界面如下:

這裏寫圖片描述

上圖最中央的那個餅狀圖展示了最大的幾個對象所佔內存的比例,這張圖中提供的內容並不多,我們可以忽略它。在這個餅狀圖的下方就有幾個非常有用的工具了,我們來學習一下。

Histogram可以列出內存中每個對象的名字、數量以及大小。 
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對象經過層層引用之後,到了MainActivityLeakClass這個對象,然後在圖標的左下角有個點,就說明在這裏可以被GCRoots訪問到了,並且這是由我們自己創建的Thread,並不是SystemClass了,那麼由於MainActivityLeakClass這個對象,然後在圖標的左下角有個點,就說明在這裏可以被GCRoots訪問到了,並且這是由我們自己創建的Thread,並不是SystemClass了,那麼由於MainActivityLeakClass能被GC Roots訪問到導致不能被回收,導致它所持有的其它引用也無法被回收了,包括MainActivity,也包括MainActivity中所包含的圖片。

通過這種方式,我們就成功地將內存泄漏的原因找出來了。

Histogram的用法 
用的最多的功能是 Histogram,點擊 Actions下的 Histogram項將得到 Histogram結果: 
這裏寫圖片描述

它按類名將所有的實例對象列出來,可以點擊表頭進行排序,在表的第一行可以輸入正則表達式來匹配結果 : 
這裏寫圖片描述

在某一項上右鍵打開菜單選擇 list objects ->with incoming refs 將列出該類的實例: 
這裏寫圖片描述

它展示了對象間的引用關係,比如展開後的第一個子項表示這個 HomePage(0x420ca5b0)被HomePageContainer(0x420c9e40)中的 mHomePage屬性所引用. 
快速找出某個實例沒被釋放的原因,可以右健 Path to GC Roots–>exclue all phantom/weak/soft etc. reference : 
這裏寫圖片描述 
得到的結果是: 
這裏寫圖片描述 
從表中可以看出 PreferenceManager -> … ->HomePage這條線路就引用着這個 HomePage實例。用這個方法可以快速找到某個對象的 GC Root,一個存在 GC Root的對象是不會被 GC回收掉的.

Histogram 對比

爲查找內存泄漏,通常需要兩個 Dump結果作對比,打開 Navigator History面板,將兩個表的 Histogram結果都添加到 Compare Basket中去 :

這裏寫圖片描述

添加好後,打開 Compare Basket面板,得到結果: 
這裏寫圖片描述

點擊右上角的 ! 按鈕,將得到比對結果: 
這裏寫圖片描述 
注意,上面這個對比結果不利於查找差異,可以調整對比選項: 
這裏寫圖片描述

再把對比的結果排序,就可得到直觀的對比結果: 
這裏寫圖片描述

也可以對比兩個對象集合,方法與此類似,都是將兩個 Dump結果中的對象集合添加到Compare Basket中去對比。找出差異後用 Histogram查詢的方法找出 GC Root,定位到具體的某個對象上。

LeakCanary

有別於MAT和AndroidStudio中Monitors的實時內存佔用圖,使用LeakCanary分析內存泄露就簡單多了LeakCanary是Square開源了一個內存泄露自動探測神器 。這是項目的github倉庫地址:https://github.com/square/leakcanary 。使用非常簡單,在build.gradle中引入包依賴:

debugCompile 'com.squareup.leakcanary:leakcanary-
android:1.5'
releaseCompile 'com.squareup.leakcanary:leakcanary-
android-no-op:1.5'
testCompile 'com.squareup.leakcanary:leakcanary-
android-no-op:1.5'
在Application中的onCreate方法中增加初始化代碼:
if (LeakCanary.isInAnalyzerProcess(this)) {
    // This process is dedicated to LeakCanary for
    // heap analysis.
    // You should not init your app in this process.
    return;
}
LeakCanary.install(this);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

集成後什麼都不用做,按照正常測試,當有內存泄漏發生後,應用會通過系統通知欄發出通知,點擊通知就可以進入查看內存泄漏的具體信息。在這裏舉個實踐中的例子。把LeakCanary集成到項目中後,等App啓動後一會,系統通知到了,點擊通知,跳轉到泄漏的詳情頁面進行查看:

這裏寫圖片描述

很明顯,WebSiteQueryActivity泄露了。首先,static 的MaskHeadView.fLayout變量引用了FrameLayout.mContext對象,這個對象的引用就是指向了WebSiteQueryActivity的實例,導致了它的泄漏,在第二節中我們說過static對象是內部的static對象是比較容易造成內存泄漏的,檢查代碼發現,MaskHeadView直接在WebSiteQueryActivity的xml文件中使用了,因此持有WebSiteQueryActivity的實例,因爲fLayout對象是靜態的,因此它的生命週期與Application同樣長,因此WebSiteQueryActivity退出後,它的實例引用依然被fLayout持有,導致它無法被回收從而內存泄露了。仔細檢查代碼,發現fLayout並沒有被外部使用到,應該是之前的開發者手抖加了個static字段上去或者是現在不用了,但是沒有去掉,在這裏我直接去掉了這個修飾符,在此build代碼,這個內存泄漏的現象消失了。

//去掉static修飾符,避免static對象引起的內存泄漏
private static FrameLayout fLayout;


public MaskHeadView(Context context, AttributeSet attrs) {
   super(context, attrs);
   this.context=context;
   initView(context);
}

private void initView(Context context2) {
   view = LayoutInflater.from(context).inflate(R.layout.
   mask_head_view, this);
   fLayout=(FrameLayout) view.findViewById(R.id.
   mask_container);
}

這只是個極簡單的例子,但方法是一樣的。順便提一句,其實無論是MAT工具的內存分析,還是AndroidStudio中自帶的分析工具亦或是LeakCanary,原理都是一樣的,都是dump java heap出來進行分析,找到泄漏的問題,只是LeakCanary幫我們把分析的工作做了。但值得一提的是,LeakCanary並不是萬能的,有些內存泄漏,它也無法檢測出來。

好了,關於內存泄露的相關內容就介紹到這,關於一些可能導致內存泄漏的原因,可以參考我的另外一篇博客Android中常見的內存泄露

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章