1. 程式人生 > >安卓記憶體洩露成因和解決辦法

安卓記憶體洩露成因和解決辦法

記憶體管理的目的就是讓我們在開發中怎麼有效的避免我們的應用出現記憶體洩漏的問題。記憶體洩漏大家都不陌生了,簡單粗俗的講,就是該被釋放的物件沒有釋放,一直被某個或某些例項所持有卻不再被使用導致 GC 不能回收。最近自己閱讀了大量相關的文件資料,打算做個 總結 沉澱下來跟大家一起分享和學習,也給自己一個警示,以後 coding 時怎麼避免這些情況,提高應用的體驗和質量。
我會從 java 記憶體洩漏的基礎知識開始,並通過具體例子來說明 Android 引起記憶體洩漏的各種原因,以及如何利用工具來分析應用記憶體洩漏,最後再做總結。
篇幅有些長,大家可以分幾節來看!

Java 記憶體分配策略

Java 程式執行時的記憶體分配策略有三種,分別是靜態分配

,棧式分配,和堆式分配,對應的,三種儲存策略使用的記憶體空間主要分別是靜態儲存區(也稱方法區)、棧區堆區
靜態儲存區(方法區):主要存放靜態資料、全域性 static 資料和常量。這塊記憶體在程式編譯時就已經分配好,並且在程式整個執行期間都存在。
棧區 :當方法被執行時,方法體內的區域性變數都在棧上建立,並在方法執行結束時這些區域性變數所持有的記憶體將會自動被釋放。因為棧記憶體分配運算內置於處理器的指令集中,效率很高,但是分配的記憶體容量有限。
堆區 : 又稱動態記憶體分配,通常就是指在程式執行時直接 new 出來的記憶體。這部分記憶體在不使用時將會由 Java 垃圾回收器來負責回收。
棧與堆的區別:

在方法體內定義的(區域性變數)一些基本型別的變數和物件的引用變數都是在方法的棧記憶體中分配的。當在一段方法塊中定義一個變數時,Java 就會在棧中為該變數分配記憶體空間,當超過該變數的作用域後,該變數也就無效了,分配給它的記憶體空間也將被釋放掉,該記憶體空間可以被重新使用。
堆記憶體用來存放所有由 new 建立的物件(包括該物件其中的所有成員變數)和陣列。在堆中分配的記憶體,將由 Java 垃圾回收器來自動管理。在堆中產生了一個數組或者物件後,還可以在棧中定義一個特殊的變數,這個變數的取值等於陣列或者物件在堆記憶體中的首地址,這個特殊的變數就是我們上面說的引用變數。我們可以通過這個引用變數來訪問堆中的物件或者陣列。

舉個例子:
這裡寫圖片描述
Sample 類的區域性變數 s2 和引用變數 mSample2 都是存在於棧中,但 mSample2 指向的物件是存在於堆上的。
mSample3 指向的物件實體存放在堆上,包括這個物件的所有成員變數 s1 和 mSample1,而它自己存在於棧中。
結論
區域性變數的基本資料型別和引用儲存於棧中,引用的物件實體儲存於堆中。—— 因為它們屬於方法中的變數,生命週期隨方法而結束。
成員變數全部儲存與堆中(包括基本資料型別,引用和引用的物件實體)—— 因為它們屬於類,類物件終究是要被new出來使用的。
瞭解了 Java 的記憶體分配之後,我們再來看看 Java 是怎麼管理記憶體的。

Java是如何管理記憶體

Java的記憶體管理就是物件的分配和釋放問題。在 Java 中,程式設計師需要通過關鍵字 new 為每個物件申請記憶體空間 (基本型別除外),所有的物件都在堆 (Heap)中分配空間。另外,物件的釋放是由 GC 決定和執行的。在 Java 中,記憶體的分配是由程式完成的,而記憶體的釋放是由 GC 完成的,這種收支兩條線的方法確實簡化了程式設計師的工作。但同時,它也加重了JVM的工作。這也是 Java 程式執行速度較慢的原因之一。因為,GC 為了能夠正確釋放物件,GC 必須監控每一個物件的執行狀態,包括物件的申請、引用、被引用、賦值等,GC 都需要進行監控。
監視物件狀態是為了更加準確地、及時地釋放物件,而釋放物件的根本原則就是該物件不再被引用
為了更好理解 GC 的工作原理,我們可以將物件考慮為有向圖的頂點,將引用關係考慮為圖的有向邊,有向邊從引用者指向被引物件。另外,每個執行緒物件可以作為一個圖的起始頂點,例如大多程式從 main 程序開始執行,那麼該圖就是以 main 程序頂點開始的一棵根樹。在這個有向圖中,根頂點可達的物件都是有效物件,GC將不回收這些物件。如果某個物件 (連通子圖)與這個根頂點不可達(注意,該圖為有向圖),那麼我們認為這個(這些)物件不再被引用,可以被 GC 回收。
以下,我們舉一個例子說明如何用有向圖表示記憶體管理。對於程式的每一個時刻,我們都有一個有向圖表示JVM的記憶體分配情況。以下右圖,就是左邊程式執行到第6行的示意圖。
這裡寫圖片描述
Java使用有向圖的方式進行記憶體管理,可以消除引用迴圈的問題,例如有三個物件,相互引用,只要它們和根程序不可達的,那麼GC也是可以回收它們的。這種方式的優點是管理記憶體的精度很高,但是效率較低。另外一種常用的記憶體管理技術是使用計數器,例如COM模型採用計數器方式管理構件,它與有向圖相比,精度行低(很難處理迴圈引用的問題),但執行效率很高。

什麼是Java中的記憶體洩露

在Java中,記憶體洩漏就是存在一些被分配的物件,這些物件有下面兩個特點,首先,這些物件是可達的,即在有向圖中,存在通路可以與其相連;其次,這些物件是無用的,即程式以後不會再使用這些物件。如果物件滿足這兩個條件,這些物件就可以判定為Java中的記憶體洩漏,這些物件不會被GC所回收,然而它卻佔用記憶體。
在C++中,記憶體洩漏的範圍更大一些。有些物件被分配了記憶體空間,然後卻不可達,由於C++中沒有GC,這些記憶體將永遠收不回來。在Java中,這些不可達的物件都由GC負責回收,因此程式設計師不需要考慮這部分的記憶體洩露。
通過分析,我們得知,對於C++,程式設計師需要自己管理邊和頂點,而對於Java程式設計師只需要管理邊就可以了(不需要管理頂點的釋放)。通過這種方式,Java提高了程式設計的效率。
這裡寫圖片描述
因此,通過以上分析,我們知道在Java中也有記憶體洩漏,但範圍比C++要小一些。因為Java從語言上保證,任何物件都是可達的,所有的不可達物件都由GC管理。
對於程式設計師來說,GC基本是透明的,不可見的。雖然,我們只有幾個函式可以訪問GC,例如執行GC的函式System.gc(),但是根據Java語言規範定義, 該函式不保證JVM的垃圾收集器一定會執行。因為,不同的JVM實現者可能使用不同的演算法管理GC。通常,GC的執行緒的優先級別較低。JVM呼叫GC的策略也有很多種,有的是記憶體使用到達一定程度時,GC才開始工作,也有定時執行的,有的是平緩執行GC,有的是中斷式執行GC。但通常來說,我們不需要關心這些。除非在一些特定的場合,GC的執行影響應用程式的效能,例如對於基於Web的實時系統,如網路遊戲等,使用者不希望GC突然中斷應用程式執行而進行垃圾回收,那麼我們需要調整GC的引數,讓GC能夠通過平緩的方式釋放記憶體,例如將垃圾回收分解為一系列的小步驟執行,Sun提供的HotSpot JVM就支援這一特性。
同樣給出一個 Java 記憶體洩漏的典型例子,
這裡寫圖片描述
在這個例子中,我們迴圈申請Object物件,並將所申請的物件放入一個 Vector 中,如果我們僅僅釋放引用本身,那麼 Vector 仍然引用該物件,所以這個物件對 GC 來說是不可回收的。因此,如果物件加入到Vector 後,還必須從 Vector 中刪除,最簡單的方法就是將 Vector 物件設定為 null。

Android中常見的記憶體洩漏彙總

  • 集合類洩漏

集合類如果僅僅有新增元素的方法,而沒有相應的刪除機制,導致記憶體被佔用。如果這個集合類是全域性性的變數 (比如類中的靜態屬性,全域性性的 map 等即有靜態引用或 final 一直指向它),那麼沒有相應的刪除機制,很可能導致集合所佔用的記憶體只增不減。比如上面的典型例子就是其中一種情況,當然實際上我們在專案中肯定不會寫這麼爛的程式碼,但稍不注意還是很容易出現這種情況,比如我們都喜歡通過 HashMap 做一些快取之類的事,這種情況就要多留一些心眼。

  • 單例造成的記憶體洩漏

由於單例的靜態特性使得其生命週期跟應用的生命週期一樣長,所以如果使用不恰當的話,很容易造成記憶體洩漏。比如下面一個典型的例子,
這裡寫圖片描述
這是一個普通的單例模式,當建立這個單例的時候,由於需要傳入一個Context,所以這個Context的生命週期的長短至關重要:

  1. 如果此時傳入的是 Application 的 Context,因為 Application
    的生命週期就是整個應用的生命週期,所以這將沒有任何問題。
  2. 如果此時傳入的是 Activity 的 Context,當這個 Context 所對應的 Activity 退出時,由於該
    Context 的引用被單例物件所持有,其生命週期等於整個應用程式的生命週期,所以當前 Activity
    退出時它的記憶體並不會被回收,這就造成洩漏了。

正確的方式應該改為下面這種方式:
這裡寫圖片描述
或者這樣寫,連 Context 都不用傳進來了:
這裡寫圖片描述

  • 匿名內部類/非靜態內部類和非同步執行緒

    非靜態內部類建立靜態例項造成的記憶體洩漏

有的時候我們可能會在啟動頻繁的Activity中,為了避免重複建立相同的資料資源,可能會出現這種寫法:
這裡寫圖片描述
這樣就在Activity內部建立了一個非靜態內部類的單例,每次啟動Activity時都會使用該單例的資料,這樣雖然避免了資源的重複建立,不過這種寫法卻會造成記憶體洩漏,因為非靜態內部類預設會持有外部類的引用,而該非靜態內部類又建立了一個靜態的例項,該例項的生命週期和應用的一樣長,這就導致了該靜態例項一直會持有該Activity的引用,導致Activity的記憶體資源不能正常回收。正確的做法為:
將該內部類設為靜態內部類或將該內部類抽取出來封裝成一個單例,如果需要使用Context,請按照上面推薦的使用Application 的 Context。當然,Application 的 context 不是萬能的,所以也不能隨便亂用,對於有些地方則必須使用 Activity 的 Context,對於Application,Service,Activity三者的Context的應用場景如下:
這裡寫圖片描述
其中: NO1表示 Application 和 Service 可以啟動一個 Activity,不過需要建立一個新的 task 任務佇列。而對於 Dialog 而言,只有在 Activity 中才能建立
匿名內部類
android開發經常會繼承實現Activity/Fragment/View,此時如果你使用了匿名類,並被非同步執行緒持有了,那要小心了,如果沒有任何措施這樣一定會導致洩露
這裡寫圖片描述
ref1和ref2的區別是,ref2使用了匿名內部類。我們來看看執行時這兩個引用的記憶體:
這裡寫圖片描述
可以看到,ref1沒什麼特別的。
但ref2這個匿名類的實現物件裡面多了一個引用:
this$0這個引用指向MainActivity.this,也就是說當前的MainActivity例項會被ref2持有,如果將這個引用再傳入一個非同步執行緒,此執行緒和此Acitivity生命週期不一致的時候,就造成了Activity的洩露。

  • Handler 造成的記憶體洩漏

Handler 的使用造成的記憶體洩漏問題應該說是最為常見了,很多時候我們為了避免 ANR 而不在主執行緒進行耗時操作,在處理網路任務或者封裝一些請求回撥等api都藉助Handler來處理,但 Handler 不是萬能的,對於 Handler 的使用程式碼編寫一不規範即有可能造成記憶體洩漏。另外,我們知道 Handler、Message 和 MessageQueue 都是相互關聯在一起的,萬一 Handler 傳送的 Message 尚未被處理,則該 Message 及傳送它的 Handler 物件將被執行緒 MessageQueue 一直持有。
由於 Handler 屬於 TLS(Thread Local Storage) 變數, 生命週期和 Activity 是不一致的。因此這種實現方式一般很難保證跟 View 或者 Activity 的生命週期保持一致,故很容易導致無法正確釋放。
舉個例子:
這裡寫圖片描述
在該 SampleActivity 中聲明瞭一個延遲10分鐘執行的訊息 Message,mLeakyHandler 將其 push 進了訊息佇列 MessageQueue 裡。當該 Activity 被 finish() 掉時,延遲執行任務的 Message 還會繼續存在於主執行緒中,它持有該 Activity 的 Handler 引用,所以此時 finish() 掉的 Activity 就不會被回收了從而造成記憶體洩漏(因 Handler 為非靜態內部類,它會持有外部類的引用,在這裡就是指 SampleActivity)。
修復方法:在 Activity 中避免使用非靜態內部類,比如上面我們將 Handler 宣告為靜態的,則其存活期跟 Activity 的生命週期就無關了。同時通過弱引用的方式引入 Activity,避免直接將 Activity 作為 context 傳進去,見下面程式碼:
這裡寫圖片描述
綜述,即推薦使用靜態內部類 + WeakReference 這種方式。每次使用前注意判空。
前面提到了 WeakReference,所以這裡就簡單的說一下 Java 物件的幾種引用型別。
Java對引用的分類有 Strong reference, SoftReference, WeakReference, PhatomReference 四種。
這裡寫圖片描述
在Android應用的開發中,為了防止記憶體溢位,在處理一些佔用記憶體大而且宣告週期較長的物件時候,可以儘量應用軟引用和弱引用技術。
軟/弱引用可以和一個引用佇列(ReferenceQueue)聯合使用,如果軟引用所引用的物件被垃圾回收器回收,Java虛擬機器就會把這個軟引用加入到與之關聯的引用佇列中。利用這個佇列可以得知被回收的軟/弱引用的物件列表,從而為緩衝器清除已失效的軟/弱引用。
假設我們的應用會用到大量的預設圖片,比如應用中有預設的頭像,預設遊戲圖示等等,這些圖片很多地方會用到。如果每次都去讀取圖片,由於讀取檔案需要硬體操作,速度較慢,會導致效能較低。所以我們考慮將圖片快取起來,需要的時候直接從記憶體中讀取。但是,由於圖片佔用記憶體空間比較大,快取很多圖片需要很多的記憶體,就可能比較容易發生OutOfMemory異常。這時,我們可以考慮使用軟/弱引用技術來避免這個問題發生。以下就是高速緩衝器的雛形:
首先定義一個HashMap,儲存軟引用物件。
這裡寫圖片描述
再來定義一個方法,儲存Bitmap的軟引用到HashMap。
這裡寫圖片描述
使用軟引用以後,在OutOfMemory異常發生之前,這些快取的圖片資源的記憶體空間可以被釋放掉的,從而避免記憶體達到上限,避免Crash發生。
如果只是想避免OutOfMemory異常的發生,則可以使用軟引用。如果對於應用的效能更在意,想盡快回收一些佔用記憶體比較大的物件,則可以使用弱引用。
另外可以根據物件是否經常使用來判斷選擇軟引用還是弱引用。如果該物件可能會經常使用的,就儘量用軟引用。如果該物件不被使用的可能性更大些,就可以用弱引用。
ok,繼續回到主題。前面所說的,建立一個靜態Handler內部類,然後對 Handler 持有的物件使用弱引用,這樣在回收時也可以回收 Handler 持有的物件,但是這樣做雖然避免了 Activity 洩漏,不過 Looper 執行緒的訊息佇列中還是可能會有待處理的訊息,所以我們在 Activity 的 Destroy 時或者 Stop 時應該移除訊息佇列 MessageQueue 中的訊息。
下面幾個方法都可以移除 Message:
這裡寫圖片描述
儘量避免使用 static 成員變數

  • 如果成員變數被宣告為 static,那我們都知道其生命週期將與整個app程序生命週期一樣。
    這會導致一系列問題,如果你的app程序設計上是長駐記憶體的,那即使app切到後臺,這部分記憶體也不會被釋放。按照現在手機app記憶體管理機制,佔記憶體較大的後臺程序將優先回收,yi’wei如果此app做過程序互保保活,那會造成app在後臺頻繁重啟。當手機安裝了你參與開發的app以後一夜時間手機被消耗空了電量、流量,你的app不得不被使用者解除安裝或者靜默。

這裡修復的方法是:
不要在類初始時初始化靜態成員。可以考慮lazy初始化。
架構設計上要思考是否真的有必要這樣做,儘量避免。如果架構需要這麼設計,那麼此物件的生命週期你有責任管理起來。

  • 避免 override finalize()

1、finalize 方法被執行的時間不確定,不能依賴與它來釋放緊缺的資源。時間不確定的原因是:
2、finalize 方法只會被執行一次,即使物件被複活,如果已經執行過了 finalize 方法,再次被 GC 時也不會再執行了,原因是:
含有 finalize 方法的 object 是在 new 的時候由虛擬機器生成了一個 finalize reference 在來引用到該Object的,而在 finalize 方法執行的時候,該 object 所對應的 finalize Reference 會被釋放掉,即使在這個時候把該 object 復活(即用強引用引用住該 object ),再第二次被 GC 的時候由於沒有了 finalize reference 與之對應,所以 finalize 方法不會再執行。
3、含有Finalize方法的object需要至少經過兩輪GC才有可能被釋放。
虛擬機器呼叫GC的時間不確定
Finalize daemon執行緒被排程到的時間不確定

  • 資源未關閉造成的記憶體洩漏

對於使用了BraodcastReceiver,ContentObserver,File,遊標 Cursor,Stream,Bitmap等資源的使用,應該在Activity銷燬時及時關閉或者登出,否則這些資源將不會被回收,造成記憶體洩漏。

  • 一些不良程式碼造成的記憶體壓力

有些程式碼並不造成記憶體洩露,但是它們,或是對沒使用的記憶體沒進行有效及時的釋放,或是沒有有效的利用已有的物件而是頻繁的申請新記憶體。
比如:

  • Bitmap 沒呼叫 recycle()方法,對於 Bitmap 物件在不使用時,我們應該先呼叫 recycle()
    釋放記憶體,然後才它設定為 null. 因為載入 Bitmap 物件的記憶體空間,一部分是 java 的,一部分 C 的(因為 Bitmap
    分配的底層是通過 JNI 呼叫的 )。 而這個 recyle() 就是針對 C 部分的記憶體釋放。
  • 構造 Adapter 時,沒有使用快取的 convertView ,每次都在建立新的 converView。這裡推薦使用
    ViewHolder。

總結

  • 對 Activity 等元件的引用應該控制在 Activity 的生命週期之內; 如果不能就考慮使用
    getApplicationContext 或者 getApplication,以避免 Activity
    被外部長生命週期的物件引用而洩露。
  • 儘量不要在靜態變數或者靜態內部類中使用非靜態外部成員變數(包括context
    ),即使要使用,也要考慮適時把外部成員變數置空;也可以在內部類中使用弱引用來引用外部類的變數。
  • 對於生命週期比Activity長的內部類物件,並且內部類中使用了外部類的成員變數,可以這樣做避免記憶體洩漏:
    將內部類改為靜態內部類

    靜態內部類中使用弱引用來引用外部類的成員變數

  • Handler 的持有的引用物件最好使用弱引用,資源釋放時也可以清空 Handler 裡面的訊息。比如在 Activity onStop
    或者 onDestroy 的時候,取消掉該 Handler 物件的 Message和 Runnable.

  • 在 Java 的實現過程中,也要考慮其物件釋放,最好的方法是在不使用某物件時,顯式地將此物件賦值為 null,比如使用完Bitmap
    後先呼叫 recycle(),再賦為null,清空對圖片等資源有直接引用或者間接引用的陣列(使用 array.clear() ;
    array = null)等,最好遵循誰建立誰釋放的原則。

  • 正確關閉資源,對於使用了BraodcastReceiver,ContentObserver,File,遊標
    Cursor,Stream,Bitmap等資源的使用,應該在Activity銷燬時及時關閉或者登出。

  • 保持對物件生命週期的敏感,特別注意單例、靜態物件、全域性性集合等的生命週期。