【Android 效能優化】—— 詳解記憶體優化的來龍去脈

Smilyyy發表於2017-04-13

APP記憶體的使用,是評價一款應用效能高低的一個重要指標。雖然現在智慧手機的記憶體越來越大,但是一個好的應用應該將效率發揮到極致,精益求精。
本文是【Android 效能優化】系列的第二篇文章,我們在第一篇【Android 效能優化】—— UI篇中主要介紹了Android介面的優化的原理以及方法,這一篇中我們將著重介紹Android的記憶體優化。本文的篇幅很長,但是請不要嫌煩,因為每看一節,你就多了一份在面試官面前裝X的資本。

1. 記憶體與記憶體分配策略概述

1.1 什麼是記憶體

通常情況下我們說的記憶體是指手機的RAM,它主要包括一下幾個部分:
- 暫存器(Registers讀音:[ˈrɛdʒɪstɚ])
速度最快的儲存場所,因為暫存器位於處理器內部,所以在程式中我們無法控制。
- 棧(Stack)
存放基本型別的物件和引用,但是物件本身不存放在棧中,而是存放在堆中。
變數其實是分為兩部分的:一部分叫變數名,另外一部分叫變數值,對於區域性變數(基本型別的變數和物件的引用變數)而言,統一都存放在棧中,但是變數值中儲存的內容就有在一定差異了:Java中存在8大基本型別,他們的變數值中存放的就是具體的數值,而其他的型別都叫做引用型別(物件也是引用型別,你只要記住除了基本型別,都是引用型別)他們的變數值中存放的是他們在堆中的引用(記憶體地址)。
在函式執行的時候,函式內部的區域性變數就會在棧上建立,函式執行結束的時候這些儲存單元會被自動釋放。棧記憶體分配運算內建於處理器的指令集中是一塊連續的記憶體區域,效率很高,速度快,但是大小是作業系統預定好的所以分配的記憶體容量有限。
堆(Heap)
在堆上分配記憶體的過程稱作 記憶體動態分配過程。在java中堆用於存放由new建立的物件和陣列。堆中分配的記憶體,由java虛擬機器自動垃圾回收器(GC)來管理(可見我們要進行的記憶體優化主要就是對堆記憶體進行優化)。堆是不連續的記憶體區域(因為系統是用連結串列來儲存空閒記憶體地址,自然不是連續的),堆大小受限於計算機系統中有效的虛擬記憶體(32bit系統理論上是4G)
靜態儲存區/方法區(Static Field)
是指在固定的位置上存放應用程式執行時一直存在的資料,java在記憶體中專門劃分了一個靜態儲存區域來管理一些特殊的資料變數如靜態的資料變數。
常量池(Constant Pool)
顧名思義專門存放常量的。注意 String s = “java”中的“java”也是常量。JVM虛擬機器為每個已經被轉載的型別維護一個常量池。常量池就是該型別所有用到地常量的一個有序集合包括直接常量(基本型別,String)和對其他型別、欄位和方法的符號引用。
總結:
定義一個區域性變數的時候,java虛擬機器就會在棧中為其分配記憶體空間,區域性變數的基本資料型別和引用儲存於棧中,引用的物件實體儲存於堆中。因為它們屬於方法中的變數,生命週期隨方法而結束。
成員變數全部儲存與堆中(包括基本資料型別,引用和引用的物件實體),因為它們屬於類,類物件終究是要被new出來使用的。當堆中物件的作用域結束的時候,這部分記憶體也不會立刻被回收,而是等待系統GC進行回收。
所謂的記憶體分析,就是分析Heap中的記憶體狀態。

1.2 Android中的沙盒機制

大家可能都聽說過IOS中有沙盒機制(sandbox),但是我們的Android系統中也存在沙盒機制,只不過沒有IOS中的嚴格,所以常常被人忽略。
由於Android是建立在Linux系統之上的,所以Android系統繼承了Linux的 類Unix繼承程式隔離機制與最小許可權原則,並且在原有Linux的程式管理基礎上對UID的使用做了改進,形成了Android應用的”沙箱“機制。
普通的Linux中啟動的應用通常和登陸使用者相關聯,同一使用者的UID相同。但是Android中給不同的應用都賦予了不同的UID,這樣不同的應用將不能相互訪問資源。對應用而言,這樣會更加封閉,安全。
引文來自Android的SandBox(沙箱)
在Android系統中,應用(通常)都在一個獨立的沙箱中執行,即每一個Android應用程式都在它自己的程式中執行,都擁有一個獨立的Dalvik虛擬機器例項。Dalvik經過優化,允許在有限的記憶體中同時高效地執行多個虛擬機器的例項,並且每一個Dalvik應用作為一個獨立的Linux程式執行。Android這種基於Linux的程式“沙箱”機制,是整個安全設計的基礎之一。
引文來自淺析Android沙箱模型
簡單點說就是在Android的世界中每一個應用相當與一個Linux中的使用者,他們相互獨立,不能相互共享與訪問,(這也就解釋了Android系統中為什麼需要程式間通訊),正是由於沙盒機制的存在最大程度的保護了應用之間的安全,但是也帶來了每一個應用所分配的記憶體大小是有限制的問題。

2. Generational Heap Memory記憶體模型的概述

在Android和Java中都存在著一個Generational(讀音:[ˌdʒenəˈreɪʃənl]) Heap Memory模型,系統會根據記憶體中不同的記憶體資料型別分別執行不同的GC操作。Generational Heap Memory模型主要由:Young Generation(新生代)、Old Generation(舊生代)、Permanent(讀音:[ˈpɜ:rmənənt]) Generation三個區域組成,而且這三個區域存在明顯的層級關係。所以此模型也可以成為三級Generation的記憶體模型
這裡寫圖片描述
其中Young Generation區域存放的是最近被建立物件,此區域最大的特點就是建立的快,被銷燬的也很快。當物件在Young Generation區域停留的時間到達一定程度的時候,它就會被移動到Old Generation區域中,同理,最後他將會被移動到Permanent Generation區域中。這裡寫圖片描述
在三級Generation記憶體模型中,每一個區域的大小都是有固定值的,當進入的物件總大小到達某一級記憶體區域閥值的時候就會觸發GC機制,進行垃圾回收,騰出空間以便其他物件進入。
這裡寫圖片描述
不僅如此,不同級別的Generation區域GC是需要的時間也是不同的。同等物件數目下,Young Generation GC所需時間最短,Old Generation次之,Permanent Generation 需要的時間最長。當然GC執行的長短也和當前Generation區域中的物件數目有關。遍歷查詢20000個物件比起遍歷50個物件自然是要慢很多的。

3. GC機制概述

與C++不用,在Java中,記憶體的分配是由程式完成的,而記憶體的釋放是由垃圾收集器(Garbage Collection,GC)完成的,程式設計師不需要通過呼叫函式來釋放記憶體,但也隨之帶來了記憶體洩漏的可能。簡單點說:對於 C++ 來說,記憶體洩漏就是new出來的物件沒有 delete,俗稱野指標;而對於 java 來說,就是 new 出來的 Object 放在 Heap 上無法被GC回收。
Android使用的主要開發語言是Java所以二者的GC機制原理也大同小異,所以我們只對於常見的JVM GC機制的分析,就能達到我們的目的。我還是先看看那二者的不同之處吧。

3.1 Dalvik 和標準Java虛擬機器的區別

3.1.1 Dalvik 和標準Java虛擬機器的主要區別

Dalvik虛擬機器(DVM)是Android系統在java虛擬機器(JVM)基礎上優化得到的,DVM是基於暫存器的,而JVM是基於棧的,由於暫存器高效快速的特性,DVM的效能相比JVM更好。
3.1.2 Dalvik 和 java 位元組碼的區別
Dalvik執行.dex格式的位元組碼檔案,JVM執行的是.class格式的位元組碼檔案,Android程式在編譯之後產生的.class 檔案會被aapt工具處理生成R.class等檔案,然後dx工具會把.class檔案處理成.dex檔案,最終資原始檔和.dex檔案等打包成.apk檔案。

3.2 分別對Young Generation(新生代)和Old Generation(舊生代)採用的兩種垃圾回收機制?

採用的兩種垃圾回收機制?

3.2.1 對於Young Generation(新生代)的GC

由於Young Generation通常存活的時間比較短,所以Young Generation採用了Copying演算法進行回收,Copying演算法就是掃描出存活的物件,並複製到一塊新的空間中,這個過程就是下圖Eden與Survivor Space之間的複製過程。Young Generation採用空閒指標的方式來控制GC觸發,指標儲存最後一個分配在Young Generation中分配空間地物件的位置。當有新的物件要分配記憶體空間的時候,就會主動檢測空間是否足夠,不夠的情況下就出觸發GC,當連續分配物件時,物件會逐漸從Eden移動到Survivor,最後移動到Old Generation。
這裡寫圖片描述

3.2.2 對於Old Generation(舊生代)的GC

Old Generation與Young Generation不同,物件存活的時間比較長,比較穩固,因此採用標記(Mark)演算法來進行回收。所謂標記就是掃描出存活的物件,然後在回收未必標記的物件。回收後的剩餘空間要麼進行合併,要麼標記出來便於下次進行分配,總之就是要減少記憶體碎片帶來的效率損耗。

3.4 如何判斷物件是否可以被回收

從上面的一小節中我們知道了不同的區域GC機制是有所不同的,那麼這些垃圾是如何被發現的呢?下面我們就看一下兩種常見的判斷方法:引用計數、物件引用遍歷。

3.4.1引用計數器

引用計數器是垃圾收集器中的早起策略。這種方法中,每個物件實體(不是它的引用)都有一個引用計數器。當一個物件建立的時候,且將該物件分配給一個每分配給一個變數,計數器就+1,當一個物件的某個引用超過了生命週期或者被設定一個新值時,物件計數器就-1,任何引用計數器為 0 的物件可以被當作垃圾收集。當一個物件被垃圾收集時,引用的任何物件技術 - 1。
優點:執行快,交織在程式執行中,對程式不被長時間打斷的實時環境比較有利。
缺點:無法檢測出迴圈引用。比如:物件A中有物件B的引用,而B中同時也有A的引用。

3.4.2 跟蹤收集器

現在的垃圾回收機制已經不太使用引用計數器的方法判斷是否可回收,而是使用跟蹤收集器方法。
現在大多數JVM採用物件引用遍歷機制從程式的主要執行物件(如靜態物件/暫存器/棧上指向的堆記憶體物件等)開始檢查引用鏈,去遞迴判斷物件收否可達,如果不可達,則作為垃圾回收,當然在便利階段,GC必須記住那些物件是可達的,以便刪除不可到達的物件,這稱為標記(marking)物件。
下一步,GC就要刪除這些不可達的物件,在刪除時未必標記的物件,釋放它們的記憶體的過程叫做清除(sweeping),而這樣會造成記憶體碎片化,佈局已分配給新的物件,但是他們集合起來還很大。所以很多GC機制還要重新組織記憶體中的物件,並進行壓縮,形成大塊、可利用的空間。
為了達到這個目的,GC需要停止程式的其他活動,阻塞程式。這裡我們要注意的是:不要頻繁的引發GC,執行GC操作的時候,任何執行緒的任何操作都會需要暫停,等待GC操作完成之後,其他操作才能夠繼續執行, 故而如果程式頻繁GC, 自然會導致介面卡頓. 通常來說,單個的GC並不會佔用太多時間,但是大量不停的GC操作則會顯著佔用幀間隔時間(16ms。可參見《【Android 效能優化】—— UI篇》)。如果在幀間隔時間裡面做了過多的GC操作,那麼自然其他類似計算,渲染等操作的可用時間就變得少了。

4. Android記憶體洩漏分析

4.1 什麼記憶體洩漏

對於 C++ 來說,記憶體洩漏就是new出來的物件沒有 delete,俗稱野指標;而對於 java 來說,就是 new 出來的 Object 放在 Heap 上無法被GC回收

4.2 為什麼不能被回收

GC過程與物件的引用型別是嚴重相關的,下面我們就看看Java中(Android中存在差異)對於引用的四種分類:
- 強引用(Strong Reference):JVM寧願丟擲OOM,也不會讓GC回收的物件
- 軟引用(Soft Reference) :只有記憶體不足時,才會被GC回收。
- 弱引用(weak Reference):在GC時,一旦發現弱引用,立即回收
- 虛引用(Phantom Reference):任何時候都可以被GC回收,當垃圾回收器準備回收一個物件時,如果發現它還有虛引用,就會在回收物件的記憶體之前,把這個虛引用加入到與之關聯的引用佇列中。程式可以通過判斷引用佇列中是否存在該物件的虛引用,來了解這個物件是否將要被回收。可以用來作為GC回收Object的標誌。
這裡寫圖片描述

注意Android中存在的差異
但是在2.3以後版本中,系統會優先將SoftReference的物件提前回收掉, 即使記憶體夠用,其他和Java中是一樣的。所以谷歌官方建議用LruCache(least recentlly use 最少最近使用演算法)。會將記憶體控制在一定的大小內, 超出最大值時會自動回收, 這個最大值開發者自己定。其實LruCache就是用了很多的HashMap,三百多行的程式碼
在開發過程中,儲存物件,這時我很可以直接使用LruCache來代替,Bitmap物件:
在Android開發過程中,我們常常使用HasMap儲存物件,但是為了防止記憶體洩漏,在儲存記憶體佔用較大、生命週期較長的物件的時候,儘量使用LruCache代替HasMap用於儲存物件。

//指定最大快取空間
    private static final int MAX_SIZE = (int) (Runtime.getRuntime().maxMemory() / 8);
    LruCache<String,Bitmap> mBitmapLruCache = new LruCache<>(MAX_SIZE);

而造成不能回收的根本原因就是:堆記憶體中長生命週期的物件持有短生命週期物件的強/軟引用,儘管短生命週期物件已經不再需要,但是因為長生命週期物件持有它的引用而導致不能被回收。

4.3 如何的監聽系統發生GC

那麼怎樣才能去監聽系統的GC過程呢?其實非常簡單,系統每進行一次GC操作時,都會在LogCat中列印一條日誌,我們只要去分析這條日誌就可以了,日誌的基本格式如下所示:
DVM中

D/dalvikvm(30615): GC FOR ALLOC freed 4442K, 25% free 20183K/26856K, paused 24ms , total 24ms

ART中

I/art(198): Explicit concurrent mark sweep GC freed 700(30KB) AllocSpace objects, 0(0B) LOS objects, 792% free, 18MB/21MB, paused 186us total 12.763ms

D/dalvikvm: <GC_Reason> <Amount_freed>, <Heap_stats>,  <Pause_time>  

原因,一般情況下一共有以下幾種觸發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操作的開始和結束的時候會短暫阻塞一段時間,不過優化到這種程度,使用者已經是完全無法察覺到了。**

4.4 導致GC頻繁執行有兩個原因:

由於GC會阻塞程式,所以我們不避免頻繁的GC。
1. Memory Churn(記憶體抖動),記憶體抖動是因為大量的物件被建立又在短時間內馬上被釋放。
2. 瞬間產生大量的物件會嚴重佔用Young Generation的記憶體區域,當達到閥值,剩餘空間不夠的時候,也會觸發GC。即使每次分配的物件佔用了很少的記憶體,但是他們疊加在一起會增加 Heap的壓力,從而觸發更多其他型別的GC。這個操作有可能會影響到幀率,並使得使用者感知到效能問題。“
這裡寫圖片描述
解決上面的問題有簡潔直觀方法,如果你在Memory Monitor裡面檢視到短時間發生了多次記憶體的漲跌,這意味著很有可能發生了記憶體抖動。
這裡寫圖片描述
記憶體洩漏的檢測與處理
幹說不練假把式,說這麼多的記憶體知識,下面就讓我們看看Android給我們提供了那些工具來解決記憶體洩漏的問題。例如
熟悉Android Studio介面
工欲善其事,必先利其器。我們接下來先來熟悉下Android Studio的介面
這裡寫圖片描述
一般分析記憶體洩露, 首先執行程式,開啟日誌控制檯,有一個標籤Memory ,我們可以在這個介面分析當前程式使用的記憶體情況, 一目瞭然, 我們再也不需要苦苦的在logcat中尋找記憶體的日誌了。
圖中藍色區域,就是程式使用的記憶體, 灰色區域就是空閒記憶體, 當然,Android記憶體分配機制是對每個應用程式逐步增加, 比如你程式當前使用30M記憶體, 系統可能會給你分配40M, 當前就有10M空閒, 如果程式使用了50M了,系統會緊接著給當前程式增加一部分,比如達到了80M, 當前你的空閒記憶體就是30M了。 當然,系統如果不能再給你分配額外的記憶體,程式自然就會OOM(記憶體溢位)了。 每個應用程式最高可以申請的記憶體和手機密切相關,比如我當前使用的華為Mate7,極限大概是200M,算比較高的了, 一般128M 就是極限了, 甚至有的手機只有可憐的16M或者32M,這樣的手機相對於記憶體溢位的概率非常大了。
我們怎麼檢測記憶體洩露呢
首先需要明白一個概念, 記憶體洩露就是指,本應該回收的記憶體,還駐留在記憶體中。 一般情況下,高密度的手機,一個頁面大概就會消耗20M記憶體,如果發現退出介面,程式記憶體遲遲不降低的話,可能就發生了嚴重的記憶體洩露。 我們可以反覆進入該介面,然後點選dump Java heap 這個按鈕,然後Android Studio就開始幹活了,下面的圖就是正在dump
這裡寫圖片描述
dump成功後會自動開啟 hprof檔案,檔案以Snapshot+時間來命名
這裡寫圖片描述
MAT
通過Android Studio自帶的介面,檢視記憶體洩露還不是很智慧,我們可以藉助第三方工具,常見的工具就是MAT了,下載地址 http://eclipse.org/mat/downloads.php ,這裡我們需要下載獨立版的MAT. 下圖是MAT一開始開啟的介面, 這裡需要提醒大家的是,MAT並不會準確地告訴我們哪裡發生了記憶體洩漏,而是會提供一大堆的資料和線索,我們需要自己去分析這些資料來去判斷到底是不是真的發生了記憶體洩漏。
這裡寫圖片描述
接下來我們需要用MAT開啟記憶體分析的檔案, 上文給大家介紹了使用Android Studio生成了 hprof檔案, 這個檔案在呢, 在Android Studio中的Captrues這個目錄中,可以找到
這裡寫圖片描述
注意,這個檔案不能直接交給MAT, MAT是不識別的, 我們需要右鍵點選這個檔案,轉換成MAT識別的。
這裡寫圖片描述
然後用MAT開啟匯出的hprof(File->Open heap dump) MAT會幫我們分析記憶體洩露的原因
這裡寫圖片描述
這裡寫圖片描述
LeakCanary
上面介紹了MAT檢測記憶體洩露, 再給大家介紹LeakCanary。 專案地址:https://github.com/square/leakcanary
LeakCanary會檢測應用的記憶體回收情況,如果發現有垃圾物件沒有被回收,就會去分析當前的記憶體快照,也就是上邊MAT用到的.hprof檔案,找到物件的引用鏈,並顯示在頁面上。這款外掛的好處就是,可以在手機端直接檢視記憶體洩露的地方,可以輔助我們檢測記憶體洩露
這裡寫圖片描述
用: 在build.gradle檔案中新增,不同的編譯使用不同的引用:

dependencies { 
debugCompile 'com.squareup.leakcanary:leakcanary-android:1.3' 
releaseCompile 'com.squareup.leakcanary:leakcanary-android-no-op:1.3' 
}

在應用的Application onCreate方法中新增LeakCanary.install(this),如下

public class ExampleApplication extends Application 
    @Override 
    public void onCreate() {
        super.onCreate(); 
        LeakCanary.install(this);
     }
 }

應用執行起來後,LeakCanary會自動去分析當前的記憶體狀態,如果檢測到洩漏會傳送到通知欄,點選通知欄就可以跳轉到具體的洩漏分析頁面。 Tips:就目前使用的結果來看,絕大部分洩漏是由於使用單例模式hold住了Activity的引用,比如傳入了context或者將Activity作為listener設定了進去,所以在使用單例模式的時候要特別注意,還有在Activity生命週期結束的時候將一些自定義監聽器的Activity引用置空。 關於LeakCanary的更多分析可以看專案主頁的介紹,還有這裡http://www.liaohuqiu.net/cn/posts/leak-canary-read-me/
追蹤記憶體分配
如果我們想了解記憶體分配更詳細的情況,可以使用Allocation Traker來檢視記憶體到底被什麼佔用了。 用法很簡單:
這裡寫圖片描述
點一下是追蹤, 再點一下是停止追蹤, 停止追蹤後 .alloc檔案會自動開啟,開啟後介面如下:
這裡寫圖片描述
想檢視某個方法的原始碼時,右鍵選擇的方法,點選Jump to source就可以了
查詢方法執行的時間
Android Studio 功能越來越強大了, 我們可以藉助AS觀測各種效能,如下圖:
這裡寫圖片描述
如果我們要觀測方法執行的時間,就需要來到CPU介面
這裡寫圖片描述
點選Start Method Tracking, 一段時間後再點選一次, trace檔案被自動開啟,
這裡寫圖片描述
非獨佔時間: 某函式佔用的CPU時間,包含內部呼叫其它函式的CPU時間。 獨佔時間: 某函式佔用CPU時間,但不含內部呼叫其它函式所佔用的CPU時間。
我們如何判斷可能有問題的方法?
通過方法的呼叫次數和獨佔時間來檢視,通常判斷方法是:
如果方法呼叫次數不多,但每次呼叫卻需要花費很長的時間的函式,可能會有問題。
如果自身佔用時間不長,但呼叫卻非常頻繁的函式也可能會有問題。

6. 常見的記憶體洩漏

6.1 永遠的單例(Singleton)

為了完美解決我們在程式中反覆建立同一物件的問題,我們選用了單例模式,單例在我們的程式中隨處可見,但是由於單例模式的靜態特性,使得它的生命週期和我們的應用一樣長,一不小心讓單例無限制的持有Activity的強引用就會導致記憶體洩漏。例如:

public class SingleTon{
    private Context context;
    private static SingleTon singleTon;

    public static final SingleTon getInstance(Context context){
        this.context = context;
        return SingleHolder.INSTANCE;
    }

    private static class SingleHolder{
        private static final SingleTon INSTANCE = new SingleTon();
    }
}

解決辦法:
這個錯誤很普遍,這個是一個很正常的單利模式,但是由於傳入了一個Context,而這個Context的生命週期就的長短就尤為重要了。如果我們傳入的是某個Activity的Context,而當這個Activity推出的時候,由於該Context的強引用被單例持有,那麼這個Activity就等同於擁有了整個程式的生命週期。這種情況下,當Activity退出的時候記憶體並沒有被回收,這就造成了記憶體洩漏。
正確的做法就是應該把傳入的Context改為同應用生命週期一樣長的Application中的Context。

 public static final SingleTon getInstance(Context context){
        this.context = context.getApplicationContext;
        return SingleHolder.INSTANCE;
    }

當然我們可以直接重寫Application,提供getContext方法,不必在依靠傳入的引數:

public class BaseApplication extends Application{
    private static BaseApplication baseApplication;

    @Override
    public void onCreate(){
        super.onCreate();
        baseApplication = this;
    }

    public static Context getContext{
        baseApplication.getApplicationContext();
    }
}

6.2 Handler引起的記憶體洩漏

Handler引起的記憶體洩漏在我們開發中最為常見的。我們知道Handler、Message、MessageQueue都是相互關聯在一起的,萬一Handler傳送的Message尚未被處理,那麼該Message以及傳送它的Handler物件都會被執行緒MessageQueue一直持有。
由於Handler屬於TLS(Thread Local Storage)變數,生命週期和Activity是不一致的,因此這種實現方式很難保證跟Activity的生命週期一直,所以很容易無法釋放記憶體。比如:

public class HandlerBadActivity extends AppCompatActivity {

    private final Handler handler = new Handler(){
       @Override
      public void handleMessage(Message msg) {
          super.handleMessage(msg);
     }
     };

     @Override
     protected void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
         setContentView(R.layout.activity_handler_bad); 
         // 延遲5min傳送一個訊息
         handler.postDelayed(new Runnable() {
             @Override
             public void run() {
                 // write something
             }
         },1000*60*5);

         this.finish();
     }
 }

我們在例子中生命了一個延時5分鐘執行的Message,當該Activity退出的時候,延時任務(Message)還在主線成的MessageQueue中等待,此時的Message持有Handler的強引用,並且由於Handler是HandlerBadActivity**的非靜態內部類,所以Handler會持有HandlerBadActivity的強引用**,此時HandlerBadActivity退出時無法進行記憶體回收,造成記憶體洩漏。
解決辦法:
將Handler生命為靜態內部類,這樣它就不會持有外部來的引用了。這樣以來Handler的的生命週期就與Activity無關了。不過倘若用到Context等外部類的非static物件,還是應該通過使用Application中與應用同生命週期的Context比較合適。比如:

public class HandlerGoodActivity extends AppCompatActivity {
    private static final class MyHandler extends Handler {
        private Context mActivity;

        public MyHandler(HandlerGoodActivity activity) {
            //使用生命週期與應用同長的getApplicationContext
            this.mActivity = activity.getApplicationContext();
        }

        @Override
        public void handleMessage(Message msg) {
            super.handleMessage(msg);
            if (mActivity != null) {
                // write something
            }
        }
    }

    private final MyHandler myHandler = new MyHandler(this);

    // 匿名內部類在static的時候絕對不會持有外部類的引用
    private static final Runnable RUNNABLE = new Runnable() {
        @Override
        public void run() {

        }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_handler_good);

        myHandler.postDelayed(RUNNABLE, 1000 * 60 * 5);
    }

雖然我們結局了Activity的記憶體洩漏問題,但是經過Handler傳送的延時訊息還在MessageQueue中,Looper也在等待處理訊息,所以我們要在Activity銷燬的時候處理掉佇列中的訊息。

 @Override
    protected void onDestroy() {
        super.onDestroy();
        //傳入null,就表示移除所有Message和Runnable
        myHandler.removeCallbacksAndMessages(null);
    }

6.3 匿名內部類在非同步執行緒中的使用

它們方便卻暗藏殺機。Android開發經常會繼承實現 Activity 或者 Fragment 或者 View。如果你使用了匿名類,而又被非同步執行緒所引用,那得小心,如果沒有任何措施同樣會導致記憶體洩漏的:

public class MainActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_inner_bad);

        Runnable runnable1 = new MyRunnable();
        Runnable runnable2 = new Runnable() {
            @Override
            public void run() {

            }
        };
    }

    private static class MyRunnable implements Runnable{

        @Override
        public void run() {

        }
    }

}

runnable1 和 runnable2的區別就是,runnable2使用了匿名內部類,我們看看引用時的引用記憶體
這裡寫圖片描述
可以看到,runnable1是沒有什麼特別的。但runnable2多出了一個MainActivity的引用,若是這個引用再傳入到一個非同步執行緒,此執行緒在和Activity生命週期不一致的時候,也就造成了Activity的洩露。

6.4 善用static成員變數

從前面的介紹我們知道,static修飾的變數位於記憶體的靜態儲存區,此變數與App的生命週期一致
這必然會導致一系列問題,如果你的app程式設計上是長駐記憶體的,那即使app切到後臺,這部分記憶體也不會被釋放。按照現在手機app記憶體管理機制,佔記憶體較大的後臺程式將優先回收,因為如果此app做過程式互保保活,那會造成app在後臺頻繁重啟。當手機安裝了你參與開發的app以後一夜時間手機被消耗空了電量、流量,你的app不得不被使用者解除安裝或者靜默。
這裡修復的方法是:
不要在類初始時初始化靜態成員。可以考慮lazy初始化(延遲載入)。架構設計上要思考是否真的有必要這樣做,儘量避免。如果架構需要這麼設計,那麼此物件的生命週期你有責任管理起來。

6.5 避免使用

在我們的日常程式碼中,這樣的情況似乎很常見,及直接寫一個class就這麼光禿禿的情況
這裡寫圖片描述
這樣就在Activity內部建立了一個非靜態內部類的單例,每次啟動Activity時都會使用該單例的資料,這樣雖然避免了資源的重複建立,不過這種寫法卻會造成記憶體洩漏,因為非靜態內部類預設會持有外部類的引用,而該非靜態內部類又建立了一個靜態的例項,該例項的生命週期和應用的一樣長,這就導致了該靜態例項一直會持有該Activity的引用,導致Activity的記憶體資源不能正常回收。正確的做法為:
將該內部類設為靜態內部類或將該內部類抽取出來封裝成一個單例,如果需要使用Context,請按照上面推薦的使用Application 的 Context。當然,Application 的 context 不是萬能的,所以也不能隨便亂用,對於有些地方則必須使用 Activity 的 Context,對於Application,Service,Activity三者的Context的應用場景如下:
這裡寫圖片描述
其中: NO1表示 Application 和 Service 可以啟動一個 Activity,不過需要建立一個新的 task 任務佇列。而對於 Dialog 而言,只有在 Activity 中才能建立

6.6 集合引發的記憶體洩漏

我們通常會把一些物件的引用加入到集合容器(比如ArrayList)中,當我們不再需要該物件時,並沒有把它的引用從集合中清理掉,當集合中的內容過於大的時候,並且是static的時候就造成了記憶體洩漏,所有我們最好在onDestory情況並讓其不可達

private List<String> nameList;
    private List<Fragment> list;

    @Override
    public void onDestroy() {
        super.onDestroy();
        if (nameList != null){
            nameList.clear();
            nameList = null;
        }
        if (list != null){
            list.clear();
            list = null;
        }
    }

6.7 webView引發的記憶體洩漏

WebView解析網頁時會申請Native堆記憶體用於儲存頁面元素,當頁面較複雜時會有很大的記憶體佔用。如果頁面包含圖片,記憶體佔用會更嚴重。並且開啟新頁面時,為了能快速回退,之前頁面佔用的記憶體也不會釋放。有時瀏覽十幾個網頁,都會佔用幾百兆的記憶體。這樣載入網頁較多時,會導致系統不堪重負,最終強制關閉應用,也就是出現應用閃退或重啟。
由於佔用的都是Native堆記憶體,所以實際佔用的記憶體大小不會顯示在常用的DDMS Heap工具中(這裡看到的只是Java虛擬機器分配的記憶體,一般即使Native堆記憶體已經佔用了幾百兆,這裡顯示的還只是幾兆或十幾兆)。只有使用adb shell中的一些命令比如dumpsys meminfo 包名,或者在程式中使用Debug.getNativeHeapSize()才能看到。
據說由於WebView的一個BUG,即使它所在的Activity(或者Service)結束也就是onDestroy()之後,或者直接呼叫WebView.destroy()之後,它所佔用這些記憶體也不會被釋放。
解決這個問題最直接的方法是:把使用了WebView的Activity(或者Service)放在單獨的程式裡。然後在檢測到應用佔用記憶體過大有可能被系統幹掉或者它所在的Activity(或者Service)結束後,呼叫System.exit(0),主動Kill掉程式。由於系統的記憶體分配是以程式為準的,程式關閉後,系統會自動回收所有記憶體。
關於WebView的跟多內容請參見 : Android WebView Memory Leak WebView記憶體洩漏

6.8其他常見的引起記憶體洩漏原因

構造Adapter時,沒有使用快取的 convertView
Bitmap在不使用的時候沒有使用recycle()釋放記憶體
非靜態內部類的靜態例項容易造成記憶體洩漏:即一個類中如果你不能夠控制它其中內部類的生命週期(譬如Activity中的一些特殊Handler等),則儘量使用靜態類和弱引用來處理(譬如ViewRoot的實現)。
警惕執行緒未終止造成的記憶體洩露;譬如在Activity中關聯了一個生命週期超過Activity的Thread,在退出Activity時切記結束執行緒。一個典型的例子就是HandlerThread的run方法是一個死迴圈,它不會自己結束,執行緒的生命週期超過了Activity生命週期,我們必須手動在Activity的銷燬方法中中調運thread.getLooper().quit();才不會洩露。
物件的註冊與反註冊沒有成對出現造成的記憶體洩露;譬如註冊廣播接收器、註冊觀察者(典型的譬如資料庫的監聽)等。
建立與關閉沒有成對出現造成的洩露;譬如Cursor資源必須手動關閉,WebView必須手動銷燬,流等物件必須手動關閉等。
不要在執行頻率很高的方法或者迴圈中建立物件(比如onMeasure),可以使用HashTable等建立一組物件容器從容器中取那些物件,而不用每次new與釋放。
避免程式碼設計模式的錯誤造成記憶體洩露;譬如迴圈引用,A持有B,B持有C,C持有A,這樣的設計誰都得不到釋放。

總結:

Android記憶體優化主要是針對堆(Heap)而言的,當堆中物件的作用域結束的時候,這部分記憶體也不會立刻被回收,而是等待系統GC進行回收。
Java中造成記憶體洩漏的根本原因是:堆記憶體中長生命週期的物件持有短生命週期物件的強/軟引用,儘管短生命週期物件已經不再需要,但是因為長生命週期物件持有它的引用而導致不能被回收。
參考:
使用新版Android Studio檢測記憶體洩露和效能
【知識必備】記憶體洩漏全解析,從此拒絕ANR,讓OOM遠離你的身邊,跟記憶體洩漏say byebye
Google 釋出 Android 效能優化典範

原文連結:http://blog.csdn.net/qq_23191031/article/details/63685756

相關文章