Android記憶體溢位、記憶體洩漏常見案例分析及最佳實踐總結

宋者為王發表於2021-08-02

       記憶體溢位是Android開發中一個老大難的問題,相關的知識點比較繁雜,絕大部分的開發者都零零星星知道一些,但難以全面。本篇文件會盡量從廣度和深度兩個方面進行整理,幫助大家梳理這方面的知識點(基於Java)。

 

一、Java記憶體的分配

  這裡先了解一下我們無比關心的記憶體,到底是指的哪一塊區域:

 

       如上圖,整個程式執行過程中,JVM會用一段空間來儲存執行期間需要用到的資料和相關資訊,這段空間一般被稱作Runtime Data Area (執行時資料區),這就是我們們常說的JVM記憶體,我們常說到的記憶體管理就是針對這段空間進行管理。Java虛擬機器在執行Java程式時會把記憶體劃分為若干個不同的資料區域,根據《Java虛擬機器規範(Java SE 7版)》的規定,Java虛擬機器所管理的記憶體包含了5個區域:程式計數器,虛擬機器棧,本地方法棧,GC堆,方法區。如下圖所示:

各個區域的作用和包含的內容大致為:

    (1)程式計數器:是一塊較小的記憶體空間,也有的稱為PC暫存器。它儲存的是程式當前執行的指令的地址,用於指示執行哪條指令。這塊記憶體中儲存的資料所佔空間的大小不會隨程式的執行而發生改變,所以,此記憶體區域不會發生記憶體溢位(OutOfMemory)問題。

    (2)Java虛擬機器棧:簡稱為Java棧,也就是我們常常說的棧記憶體,它是Java方法執行的記憶體模型。Java棧中存放的是一個個的棧幀,每個棧幀對應的是一個被呼叫的方法。每一個棧幀中包括瞭如下部分:區域性變數表、運算元棧、方法返回地址等資訊。每一個方法從呼叫直至執行完成的過程,就對應著一個棧幀在虛擬機器棧中入棧到出棧的過程。在Java虛擬機器規範中,對Java棧區域規定了兩種異常狀況:1)如果執行緒請求的棧深度大於虛擬機器所允許的深度,將丟擲棧記憶體溢位(StackOverflowError)異常,所以使用遞迴的時候需要注意這一點;2)如果虛擬機器棧可以動態擴充套件,而且擴充套件時無法申請到足夠的記憶體,就會丟擲OutOfMemoryError異常。

    (3)本地方法棧:本地方法棧與Java虛擬機器棧的作用和原理非常相似,區別在與前者為執行Nativit方法服務的,而後者是為執行Java方法服務的。與Java虛擬機器棧一樣,本地方法棧區域也會丟擲StackOverflowError和OutOfMemoryError異常。

    (4)GC堆:也就是我們常說的堆記憶體,是記憶體中最大的一塊,被所有執行緒共享,此記憶體區域的唯一目的就是存放物件例項,幾乎所有的物件例項都在這裡分配。它是Java的垃圾收集器管理的主要區域,所以被稱為“GC堆”。當無法再擴充套件時,將會丟擲OutOfMemoryError異常。

    (5)方法區:它與堆一樣,也是被執行緒共享的區域,一般用來儲存不容易改變的資料,所以一般也被稱為“永久代”。在方法區中,儲存了每個類的資訊(包括類名,方法資訊,欄位資訊)、靜態變數、常量以及編譯器編譯後的程式碼等內容。Java的垃圾收集器可以像管理堆區一樣管理這部分割槽域,當方法區無法滿足記憶體分配需求時,將丟擲OutOfMemoryError異常。

       我這裡只做了一些簡單的介紹,如果想詳細瞭解每個區域包含的內容及作用,可以閱讀這篇文章:【朝花夕拾】Android效能篇之(二)Java記憶體分配

 

二、Java垃圾回收

       垃圾回收,即GC(Garbage Collection),回收無用記憶體空間,使其對未來例項可用的過程。由於裝置的記憶體空間是有限的,為了防止記憶體空間被佔滿導致應用程式無法執行,就需要對無用物件佔用的記憶體進行回收,也稱垃圾回收。 垃圾回收過程中除了會清理廢棄的物件外,還會清理記憶體碎片,完成記憶體整理。

   1、判斷物件是否存活的方法

       GC堆記憶體中存放著幾乎所有的物件(方法區中也儲存著一部分),垃圾回收器在對該記憶體進行回收前,首先需要確定這些物件哪些是“活著”,哪些已經“死去”,記憶體回收就是要回收這些已經“死去”的物件。那麼如何其判斷一個物件是否還“活著”呢?方法主要由如下兩種:

    (1)引用計數法,該演算法由於無法處理物件之間相互迴圈引用的問題,在Java中並未採用該演算法,在此不做深入探究;

    (2)根搜尋演算法(GC ROOT Tracing),Java中採用了該演算法來判斷物件是否是存活的,這裡重點介紹一下。

       演算法思想:通過一系列名為“GC Roots” 的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈(Reference Chain),當一個物件到GC Roots沒有任何引用鏈相連(用圖論來說就是從GC Roots到這個物件不可達)時,則證明物件是不可用的,即該物件是“死去”的,同理,如果有引用鏈相連,則證明物件可以,是“活著”的。如下圖所示:      

          那麼,哪些可以作為GC Roots的物件呢?Java 語言中包含了如下幾種:

          1)虛擬機器棧(棧幀中的本地變數表)中的引用的物件。

          2)方法區中的類靜態屬性引用的物件。

          3)方法區中的常量引用的物件。

          4)本地方法棧中JNI(即一般說的Native方法)的引用的物件。

          5)執行中的執行緒

          6)由引導類載入器載入的物件

          7)GC控制的物件

          擴充閱讀:

           Java中什麼樣的物件才能作為gc root,gc roots有哪些呢?

            

2、物件回收的分代演算法

       已經找到了需要回收的物件,那這些物件是如何被回收的呢?現代商用虛擬機器基本都採用分代收集演算法來進行垃圾回收,當然這裡的分代演算法是一種混合演算法,不同時期採用不同的演算法來回收,具體演算法我後面會推薦一篇文章較為詳細地介紹,這裡僅大致介紹一下分代演算法。

       由於不同的物件的生命週期不一樣,分代的垃圾回收策略正式基於這一點。因此,不同生命週期的物件可以採取不同的回收演算法,以便提高回收效率。該演算法包含三個區域:年輕代(Young Generation)、年老代(Old Generation)、持久代(Permanent Generation)。

  

     1)年輕代(Young Generation)

  • 所有新生成的物件首先都是放在年輕代中。年輕代的目標就是儘可能快速地回收哪些生命週期短的物件。
  • 新生代記憶體按照8:1:1的比例分為一個Eden區和兩個survivor(survivor0,survivor1)區。Eden區,字面意思翻譯過來,就是伊甸區,人類生命開始的地方。當一個例項被建立了,首先會被儲存在該區域內,大部分物件在Eden區中生成。Survivor區,倖存者區,字面理解就是用於儲存倖存下來物件。回收時先將Eden區存活物件複製到一個Survivor0區,然後清空Eden區,當這個Survivor0區也存放滿了後,則將Eden和Survivor0區中存活物件複製到另外一個survivor1區,然後清空Eden和這個Survivor0區,此時的Survivor0區就也是空的了。然後將Survivor0區和Survivor1區交換,即保持Servivor1為空,如此往復。
  • 當Survivor1區不足以存放Eden區和Survivor0的存活物件時,就將存活物件直接放到年老代。如果年老代也滿了,就會觸發一次Major GC(即Full GC),即新生代和年老代都進行回收。
  • 新生代發生的GC也叫做Minor GC,MinorGC發生頻率比較高,不一定等Eden區滿了才會觸發。

      2)年老代(Old Generation)

  • 在新生代中經歷了多次GC後仍然存活的物件,就會被放入到年老代中。因此,可以認為年老代中存放的都是一些生命週期較長的物件。
  • 年老代比新生代記憶體大很多(大概比例2:1?),當年老代中存滿時觸發Major GC,即Full GC,Full GC發生頻率比較低,年老代物件存活時間較長,存活率比較高。
  • 此處採用Compacting演算法,由於該區域比較大,而且通常物件生命週期比較長,compaction需要一定的時間,所以這部分的GC時間比較長。

      3)持久代(Permanent Generation)

       持久代用於存放靜態檔案,如Java類、方法等,該區域比較穩定,對GC沒有顯著影響。這一部分也被稱為執行時常量,有的版本說JDK1.7後該部分從方法區中移到GC堆中,有的版本卻說,JDK1.7後該部分被移除,有待考證。

 

3、記憶體抖動

       不再使用的記憶體被回收是好事,但也會產生一定的負面影響。在 Android Android 2.2及更低版本上,當發生垃圾回收時,應用的執行緒會停止,這會導致延遲,從而降低效能。在Android 2.3開始新增了併發垃圾回收功能,也就是有獨立的GC執行緒來完成垃圾回收工作,但即便如此,系統執行GC的過程中,仍然會佔用一定的cpu資源。頻繁地分配和回收記憶體空間,可能會出現記憶體抖動現象。

      記憶體抖動是指在短時間內記憶體空間大量地被分配和回收,記憶體佔用率馬上升高到較高水平,然後又馬上回收到較低水平,然後再次上升到較高水平...這樣迴圈往復的現象。體現在程式碼中,就是短時間內大量建立和銷燬物件。記憶體抖動嚴重時會造成肉眼可見的卡頓,甚至造成記憶體溢位(記憶體回收不及時也會造成記憶體溢位),導致app崩潰。

      那麼,如何在程式碼層面避免記憶體抖動的發生呢?

       當呼叫Sytem.gc()時,程式只會顯示地通知系統需要進行垃圾回收了,但系統並不一定會馬上執行gc,系統可能只會在後續某個合適的時機再做gc操作。所以對於開發者來說,無法控制物件的回收,所以在做優化時可以從物件的建立上入手,這裡提幾點避免發生記憶體抖動的建議:

  • 儘量避免在較大次數的迴圈中建立物件,應該把物件建立移到迴圈體外。
  • 避免在繪製view時的onDraw方法中建立物件,實際上Android官方也是這樣建議的。
  • 如果確實需要使用到大量某類物件,儘量做到複用,這一點可以考慮使用設計模式中的享元模式,建立物件池。

在網上看到一個我們平時很容易忽略的不良程式碼示例,這裡摘抄下來加深大家的認識:

1 public static String changeListToString(List<String> list) {
2         String result = "";
3         for (String str : list) {
4             result += (str + ";");
5         }
6         return result;
7     }

我們知道,String的底層實現是陣列,不能進行擴容,拼裝字串的時候會重新生成一個String物件,所以第4行程式碼執行一次就會生成一個新的String物件,這段程式碼執行完成後就會產生list.size()個物件。下面是優化後的程式碼:

1 public static String changeListToString2(List<String> list) {
2         StringBuilder result = new StringBuilder();
3         for (String str : list) {
4             result.append(str + ";");
5         }
6         return result.toString();
7     }

 StringBuilder執行append方法時,會在原有例項基礎上操作,不會生成新的物件,所以上述程式碼執行完成後就只會產生一個StringBuilder物件。當list的size比較大的時候,這段優化程式碼的效果就會比較明顯了。    

       在文章:「記憶體抖動」?別再嚇唬面試者們了行嗎 中對記憶體抖動講解得比較清晰,大家可以去讀一讀。

 

4、物件的四種引用方式

       為了便於物件被回收,常常需要根據實際需要與物件建立不同程度的引用,後文在介紹記憶體洩漏時,需要用到這方面的知識,這裡簡單介紹一下。Java中的物件引用方式有如下4種:

       對於強引用的物件,即使是記憶體不夠用了,GC時也不會被JVM作為垃圾回收掉,只會丟擲OutOfMemmory異常,所以我們在解決記憶體洩漏的問題時,很多情況下需要處理強引用的問題。

        這一節對垃圾回收相關的知識做了簡單介紹,想更詳細瞭解的可以閱讀:【朝花夕拾】Android效能篇之(三)Java記憶體回收

 

三、記憶體溢位

       記憶體溢位(Out Of Memory,簡稱OOM)是各個語言中均會出現的問題,也是軟體開發史一直存在的令開發者頭疼的現象。

       1、基本概念

       記憶體溢位是指應用系統中存在無法回收的記憶體或使用的記憶體過多,最終使得程式執行時需要用到的記憶體大於能提供的最大記憶體,此時程式就執行不了,系統會提示記憶體溢位,有時候會自動關閉軟體,重啟電腦或者軟體後釋放掉一部分記憶體又可以正常執行該軟體,而由系統配置、資料流、使用者程式碼等原因而導致的記憶體溢位錯誤,即使使用者重新執行任務依然無法避免。

       2、Android系統裝置中的記憶體

       在Android中,google原生OS虛擬機器(Android 5.0之前是Dalvik,5.0及之後是ART)預設給每個app分配的記憶體大小為16M(?),不同的廠商在不同的裝置上會設定預設的上限值,可以通過在AndroidManifest的application節點中設定屬性Android:largeHeap=”true”來突破這個上限。我們可以在/system/build.prop檔案中查詢到這些資訊(需要有root許可權,當然也可以通過程式碼的方式獲取,這裡不做介紹了),以下以我手頭上的一臺車機為例:

主要欄位含義如下(這裡說到的記憶體包括native和dalvik兩部分,dalvik就是我們普通的Java使用記憶體):

  • dalvik.vm.heapstartsize為app啟動時初始分配的記憶體
  • dalvik.vm.heapgrowthlimit就是一個普通應用的記憶體限制
  • dalvik.vm.heapsize是在manifest中設定了largeHeap=true 之後,可以使用的最大記憶體值

      我們知道,為了能夠使得Android應用程式安全且快速的執行,Android的每個應用程式都會使用一個專有的虛擬機器例項來執行,它是由Zygote服務程式孵化出來的,也就是說每個應用程式都是在屬於自己的程式及記憶體區域中執行的,所以Android的一個應用程式的記憶體溢位對別的應用程式影響不大。

    3、 記憶體溢位產生的原因

       從記憶體溢位的定義中可以看出,導致記憶體溢位的原因有兩個:

    (1)當前使用的物件過大或過多,這些物件所佔用的記憶體超過了剩餘的可用空間。

    (2)記憶體洩漏;

    4、記憶體洩漏

       應用中長期保持對某些物件的引用,導致垃圾收集器無法回收這些物件所佔的記憶體,這種現象被稱為記憶體洩漏。準確地說,程式執行分配的物件回收不及時或者無法被回收都會導致記憶體洩漏。記憶體洩漏不一定導致記憶體溢位,只有當這些回收不及時或者無法被回收的物件累積佔用太多的記憶體,導致app佔用的記憶體超過了系統允許的範圍(也就是前面提到的記憶體限制)時,才會導致記憶體溢位。

       分類:

 

四、當前使用記憶體過多導致記憶體溢位的常見案例舉例及優化方案

    1、Bitmap物件太大造成的記憶體溢位

      Bitmap代表一張點陣圖檔案,它是非壓縮格式,顯示效果較好,但缺點就是需要佔用大量的儲存空間。

    (1)Bitmap佔用大量記憶體的原因

       Bitmap是windows標準格式圖形檔案,由點組成,每一個點代表一個畫素。每個點可以由多種色彩表示,包括2、4、8、16、24和32位色彩,色彩越高,顯示效果越好,但所佔用的位元組數也就越大。計算一張Bitmap所佔記憶體大小的方式為:大小=影像長度*圖片寬度*單位畫素佔用的位元組數。單位畫素佔用位元組數其大小由BitmapFactory.Options的inPreferredConfig變數決定,inPreferredConfig為Bitmap.Config型別,是個列舉型別,檢視Android系統原始碼可以找到如下資訊:

 1 public class BitmapFactory {
 2    ......
 3    public static class Options {
 4        ......
 5        public Bitmap.Config inPreferredConfig = Bitmap.Config.ARGB_8888;
 6        ......
 7    }
 8     ......
 9 }
10 
11 public final class Bitmap implements Parcelable {
12     ......
13       public enum Config {
14           ......
15           ALPHA_8     (1),
16           RGB_565     (3),
17           @Deprecated
18           ARGB_4444   (4),
19           ARGB_8888   (5),
20           ......
21       }
22     ......
23 }

       可見inPreferredConfig的預設值為ARGB_8888,對於一張1080*1920px的Bitmap,載入到Android記憶體中時佔用的記憶體大小預設為:1080 * 1920 * 4 = 8294400B = 8100KB = 7.91MB。一張普通的bmp圖片,就能夠佔用7.91M的記憶體,可見Bitmap是非常耗記憶體的。所以,對於需要大量使用Bitmap的地方,需要特別注意其對記憶體的使用情況。

    (2)優化建議

       針對上述原因,這裡總結了一些使用Bitmap時的優化建議:

  • 根據實際需要設定Bitmap的解碼格式,也就是上面提到的BitmapFactory.Options的inPreferredConfig變數,不能一味地使用預設的ARGB_8888。下面列舉了Android中Bitmap常見的4種解碼格式圖片佔用記憶體的大小的情況對比:
圖片格式(Bitmap.Config) 含義說明 每個畫素點所佔位數 佔用記憶體計算方法 一張100*100的圖片所佔記憶體大小
ALPHA_8 用8位表示透明度 8位(1位元組) 圖片長度*圖片寬度*1 100*100*1 = 10000位元組
ARGB_4444 用4位表示透明度,4位表示R,4位表示G,4位表示B 4+4+4+4=16位(2位元組) 圖片長度*圖片寬度*2 100*100*2 = 20000位元組
ARGB_8888 用4位表示透明度,8位表示R,8位表示G,8位表示B 8+8+8+8=32位(4位元組) 圖片長度*圖片寬度*4 100*100*4 = 40000位元組
RGB_565 用5位表示R,6位表示G,5位表示B 5+6+5=16位(2位元組) 圖片長度*圖片寬度*2 100*100*2 = 20000位元組

 如果採用RGB_565的解碼格式,那麼佔用的記憶體大小將會比預設的少一半。

  • 當只需要獲取圖片的寬高等屬性值時,可以將BitmapFactory.Options的inJustDecodeBounds屬性設定為true,這樣可以使圖片不用載入到記憶體中仍然可以獲取的其寬高等屬性。
  • 對圖片尺寸進行壓縮。如果一張圖片大小為1080 * 1920px,但我們在裝置上需要顯示的區域大小隻有540 * 960px,此時無需將原圖載入到記憶體中,而是先計算出一個合適的縮放比例(這裡寬高均為原圖的一半,所以縮放比例為2),並賦值給BitmapFactory.Options的inSampleSize屬性,也就是設定其取樣率,這樣可以使得佔用的記憶體為原來的1/4。
  • 建立Bitmap物件池,複用Bitmap物件。比如某個頁面需要顯示100張相同寬高及解碼格式的圖片,但螢幕上最多隻能顯示10張,那麼就只需要建立一個只有10個Bitmap物件的物件池,在滑動過程中將剛剛隱藏的圖片對應的bitmap物件複用,而無需建立100個Bitmap物件。這樣可以避免一次佔用太多的記憶體以及避免記憶體抖動。
  • 對圖片質量進行壓縮,也就是降低圖片的清晰度。程式碼如下:
bitmap.compress(Bitmap.CompressFormat.JPEG, 20, new FileOutputStream("sdcard/1.jpg"));

通過如上的幾種常見的方法後,同樣一張bitmap圖片載入到記憶體後大小隻有原來的1/8不到了。

    3、程式碼參考 

下面給出前三種方案的參考程式碼:

 1 /**
 2  *   根據檔案路徑得到壓縮的圖片
 3  * @param filePath   檔案路徑
 4  * @param reqHeight  目標高
 5  * @param reqWidth   目標寬
 6  * @return
 7  */
 8 public static Bitmap  getThumbnail(String filePath,int reqHeight,int reqWidth){
 9     BitmapFactory.Options opt=new BitmapFactory.Options();
10     opt.inJustDecodeBounds=true; //不會將圖片載入到記憶體
11     BitmapFactory.decodeFile(filePath, opt);
12     opt.inSampleSize = calacteInSampleSize(opt,reqHeight,reqWidth); //設定壓縮比例
13     opt.inPreferredConfig=Config.RGB_565; //設定解碼格式
14     opt.inPurgeable = true;
15     opt.inInputShareable = true;
16     opt.inJustDecodeBounds=false; //獲取壓縮後的bitmap後就可以載入到記憶體了
17     Bitmap bitmap = BitmapFactory.decodeFile(filePath, opt);
18     return  bitmap;
19 }
20 
21 /**
22      * 計算出壓縮比
23      * @param options
24      * @param reqWith
25      * @param reqHeight
26      * @return
27      */
28     public int calculateInSampleSize(BitmapFactory.Options options,int reqWidth,int reqHeight)
29     {
30         //通過引數options來獲取真實圖片的寬、高
31         int width = options.outWidth;
32         int height = options.outHeight;
33         int inSampleSize = 1;//初始值是沒有壓縮的
34         if(width > reqWidth || height > reqHeight)
35         {
36             //計算出原始寬與現有寬,原始高與現有高的比率
37             int widthRatio = Math.round((float)width/(float)reqWidth);
38             int heightRatio = Math.round((float)height/(float)reqHeight);
39             //選出兩個比率中的較小值,這樣的話能夠保證圖片顯示完全
40             inSampleSize = widthRatio < heightRatio ? widthRatio:heightRatio;
41         }
42         return inSampleSize;
43     }

除此之外還有如下一些Bitmap使用建議,比如使用已有的圖片處理框架或工具,如Glide、LruCache等;直接使用我們所需尺寸的圖片等。

       由於Bitmap比較佔用記憶體,而且實際開發中Bitmap的使用頻率比較搞,Android官網中給了不少使用建議和規範用於管理記憶體,為了更好的理解這一節的內容以及更好地使用Bitmap,為了更好地使用Bitmap,建議閱讀如下的官方文件: 

    處理點陣圖 

    高效載入大型點陣圖

    快取點陣圖

    管理點陣圖記憶體

 

    2、使用ListView/GridView時Adapter沒有複用convertView

    (1)佔用太多記憶體的原因

       在ListView/GridView中每個convertView對應展示一個資料項,如果不採用複用convertView的方案,當需要展示的資料非常多時,就需要建立大量的convertView物件,導致物件太多,如果每個convertView上還需要展示bitmap這樣耗記憶體的資源時,就很容易一次性使用太多記憶體導致記憶體溢位。

    (2)優化方案

         一般新手可能會犯這樣的錯,有一定工作經驗的開發者基本都知道需要複用convertView,  這裡就不貼程式碼了。另外可以使用Recycleview替代ListView/GridView,自帶回收功能。

 

    3、從資料庫中取出大量資料造成的記憶體溢位

    (1)佔用記憶體太多的原因

       當查詢資料庫時,會一次性返回所有滿足條件的資料,載入到記憶體當中,如果資料太多,就會佔用太多的記憶體。一般而言,如果一次取十萬條記錄到記憶體,就可能引起記憶體溢位。該問題比較隱蔽,在測試階段,資料庫中資料較少,通常執行正常,應用或者網站正式使用時,資料庫中資料增多,一次查詢即有可能引起記憶體溢位。

    (2)優化方案

       因此,對於資料庫查詢,儘量採用分頁的方式查詢。

 

    4、應用中存在太多的物件導致的記憶體溢位

    (1)佔用記憶體太多的原因

        這個現象在大量使用者訪問伺服器時容易出現,短時間內會出現非常多的物件,及程式中出現死迴圈或者次數很大的迴圈體中建立物件時,都可能導致記憶體溢位。

    (2)優化方案

       使用設計模式中的“享元模式”來建立物件池,重複使用物件,比如執行緒池、常量池等就是典型的例子。另外就是要避免“垃圾”程式碼的出現。

 

五、常見的記憶體洩漏案例及優化方案

    1、Bitmap物件使用完成後不釋放資源

       幾乎所有講記憶體洩漏的文章中都提到,使用完Bitmap後需要呼叫recycle()方法回收資源,否則會發生記憶體洩漏。程式碼樣例如下:

1 private void useBitmap() {
2         Bitmap bitmap = getThumbnail("xxx", 100, 100);
3         ...
4         if (bitmap != null && !bitmap.isRecycled()) {
5             bitmap.recycle();
6         }
7     }

      那麼不呼叫recycle()方法真的會導致記憶體溢位嗎?

如下是android-28(Android9.0)中recycle()方法的原始碼:

 1 /**
 2  * Free the native object associated with this bitmap, and clear the
 3  * reference to the pixel data. This will not free the pixel data synchronously;
 4  * it simply allows it to be garbage collected if there are no other references.
 5  * The bitmap is marked as "dead", meaning it will throw an exception if
 6  * getPixels() or setPixels() is called, and will draw nothing. This operation
 7  * cannot be reversed, so it should only be called if you are sure there are no
 8  * further uses for the bitmap. This is an advanced call, and normally need
 9  * not be called, since the normal GC process will free up this memory when
10  * there are no more references to this bitmap.
11  */
12 public void recycle() {
13     if (!mRecycled && mNativePtr != 0) {
14         if (nativeRecycle(mNativePtr)) {
15             // return value indicates whether native pixel object was actually recycled.
16             // false indicates that it is still in use at the native level and these
17             // objects should not be collected now. They will be collected later when the
18             // Bitmap itself is collected.
19             mNinePatchChunk = null;
20         }
21         mRecycled = true;
22     }
23 }

從上述原始碼的註釋中,我們可以得到如下資訊:

      1)該方法用於釋放與當前bitmap物件相關聯的native物件,並清理對畫素資料的引用。這個方法不能同步地釋放畫素資料,而是在沒有其它引用的時候,簡單地允許畫素資料被作為垃圾回收掉。

      2)這是一個高階呼叫,一般情況下不需要呼叫它,因為在沒有其它物件引用該bitmap物件時,常規的垃圾回收程式將會釋放掉該部分記憶體。

       這裡我們需要先搞清楚,bitmap在記憶體中的儲存分兩部分 :一部分是bitmap物件,另一部分為對應的畫素資料,前者佔據的記憶體較小,而後者才是記憶體佔用的大頭。在google官方開發者文件:管理點陣圖記憶體 有如下的描述:

  • 在 Android 2.3.3(API 級別 10)及更低版本上,點陣圖的後備畫素資料儲存在本地記憶體中。它與儲存在 Dalvik 堆中的點陣圖本身是分開的。本地記憶體中的畫素資料並不以可預測的方式釋放,可能會導致應用短暫超出其記憶體限制並崩潰。從 Android 3.0(API 級別 11)到 Android 7.1(API 級別 25),畫素資料會與關聯的點陣圖一起儲存在 Dalvik 堆上。在 Android 8.0(API 級別 26)及更高版本中,點陣圖畫素資料儲存在原生堆中。

       Java的GC機制只能回收dalvik記憶體中的垃圾,而對native層無效,native記憶體中的畫素資料以不可預測的方式釋放。所以該文章中提到在Android2.3.3及之前的版本中需要呼叫recycle()方法,來回收native記憶體中的畫素資料。

       這裡我有一個疑問,按照我的理解,Android8.0及以上的版本中,畫素資料儲存在native堆中,應該也需要通過呼叫recycle()方法來回收畫素資料才對,但這篇官方文件中,提到Android3.0以上版本的記憶體管理辦法時,並沒有提到要呼叫recycle()方法,這一點我暫時還沒找到答案。

        總的來說,在所用的Android系統版本中,都呼叫recycle()應該都不會有問題,只是是否能避免記憶體洩漏,就需要依不同系統版本而定了。

 

2、 單例模式中context使用不當產生的記憶體洩漏

    這種形式的記憶體洩漏在初級程式設計師的程式碼中比較常見,如下是一種很常見的單例模式寫法:

 1 class SingletonDemo {
 2     private static volatile SingletonDemo sInstance;
 3     private Context mContext;
 4 
 5     private SingletonDemo() {
 6     }
 7 
 8     private SingletonDemo(Context context) {
 9         mContext = context;
10     }
11 
12     public static SingletonDemo getInstance(Context context) {
13         if (sInstance == null) {
14             synchronized (SingletonDemo.class) {
15                 if (sInstance == null) {
16                     sInstance = new SingletonDemo(context);
17                 }
18             }
19         }
20         return sInstance;
21     }
22 }

當在Activity等短生命週期元件中採用如下程式碼呼叫getInstance方法獲取物件時:

SingletonDemo.getInstance(this).xxx;

 如果這是第一次建立物件,Activity例項就會被物件sInstance中的mContext引用,我們知道static變數的生命週期和app程式生命週期一致,所以即使當前Activity退出了,sInstance也會一直持有該activity物件而無法被回收,直達app程式消亡。

 解決辦法有兩種:一是呼叫context.getApplicationContext(),如

 1 class SingletonDemo {
 2     private static volatile SingletonDemo sInstance;
 3     private Context mContext;
 4 
 5     private SingletonDemo() {
 6     }
 7 
 8     private SingletonDemo(Context context) {
 9         mContext = context.getApplicationContext();
10     }
11 
12     public static SingletonDemo getInstance(Context context) {
13         if (sInstance == null) {
14             synchronized (SingletonDemo.class) {
15                 if (sInstance == null) {
16                     sInstance = new SingletonDemo(context);
17                 }
18             }
19         }
20         return sInstance;
21     }
22 }

二是傳入application的例項,如:

 1 class SingletonDemo {
 2     private static volatile SingletonDemo sInstance;
 3     private Context mContext;
 4 
 5     private SingletonDemo() {
 6         mContext = MyApplication.getContext();
 7     }
 8 
 9     public static SingletonDemo getInstance() {
10         if (sInstance == null) {
11             synchronized (SingletonDemo.class) {
12                 if (sInstance == null) {
13                     sInstance = new SingletonDemo();
14                 }
15             }
16         }
17         return sInstance;
18     }
19 }
20 
21 class MyApplication extends Application {
22     private static MyApplication sContext;
23 
24     @Override
25     public void onCreate() {
26         super.onCreate();
27         sContext = this;
28     }
29 
30     public static MyApplication getContext() {
31         return sContext;
32     }
33 }

實際上這兩種方法得到的context是一樣的,檢視系統原始碼時會發現context.getApplicationContext()其實返回的就是application的例項,系統原始碼這裡就不深入分析了,讀者最好能自己去一探究竟,加深理解。

       如果當前Activity物件不大的話,該單例模式的context產生的記憶體洩漏影響也會很小,因為整個app生命週期中單例的context最多隻會持有一個該activity物件,而不會一直累加(個人理解)。

 

3、Handler使用不當產生的記憶體洩漏

這裡我們列舉一種比較常見導致記憶體洩漏的程式碼示例:

 1 public class HandlerDemoActivity extends Activity {
 2     private MyHandler mHandler = new MyHandler();
 3     class MyHandler extends Handler {
 4         @Override
 5         public void handleMessage(Message msg) {
 6             super.handleMessage(msg);
 7             switch (msg.what){
 8                 case 0x001:
 9                     //do something
10                     break;
11                 default:
12                     break;
13             }
14         }
15     }
16 }

實際上對於上述程式碼,Android Studio都會看不下去,會給出如下提示:

    (1)handler工作機制

       首先我簡單介紹一下Handler的工作機制:這裡面主要包含了4個角色Handler、Message、Looper、MessageQueue,Handler通過sendMessage方法傳送Message,Looper中持有MessageQueue,將Handler傳送過來Message加入到MessageQueue當中,然後Looper呼叫looper()按順序處理Message。工作流程如下圖所示:

 如果想詳細瞭解Handler的工作機制,可以閱讀:【朝花夕拾】Handler篇,從原始碼的角度理解其工作流程。

    (2)示例程式碼記憶體洩漏的原因

       示例中的handler預設持有的是主執行緒的looper,且處理message也是在主執行緒中完成的,但是是非同步的。最終MyHandler例項所傳送的Message如果還沒有被處理掉,就會一直持有對應MyHandler的例項,而非靜態內部類MyHandler又持有了外部類HandlerDemoActivity,這就導致MyHandler例項傳送完Message後,若此時HandlerDemoActivity也退出,由於Looper從MessageQueue中獲取Message並處理是非同步的需要排隊,那麼該Activity例項是不會馬上被回收的,會一直延遲到訊息被處理掉,這樣記憶體洩漏就產生了。如下圖所示:

       如果想詳細瞭解原因,這裡推薦閱讀:Android Handler:詳解 Handler 記憶體洩露的原因

    (3)解決辦法

       這裡有兩種解決方式:

       1)當Activity退出時,如果不需要handler傳送的Message繼續被處理(即終止任務),就在onDestroy()回撥方法中清空訊息佇列,具體程式碼如下:

1 @Override
2 protected void onDestroy() {
3     super.onDestroy();
4     mHandler.removeCallbacksAndMessages(null);
5 }

       2)當Activity退出時,如果仍然希望MessageQueue中的Message繼續被處理完,可以將MyHandler定義為靜態內部類。除此之外,還可以在此基礎上使用弱引用來持有外部類,當系統進行垃圾回收時,該弱引用物件就會被回收。具體程式碼如下:

 1 public class HandlerDemoActivity extends Activity {
 2     private MyHandler mHandler;
 3     @Override
 4     protected void onCreate(@Nullable Bundle savedInstanceState) {
 5         super.onCreate(savedInstanceState);
 6         mHandler = new MyHandler(this);
 7     }
 8 
 9     private static class MyHandler extends Handler {
10         private WeakReference<HandlerDemoActivity> mActivity;
11         public MyHandler(HandlerDemoActivity activity){
12             mActivity = new WeakReference<>(activity);
13         }
14         @Override
15         public void handleMessage(Message msg) {
16             HandlerDemoActivity activity = mActivity.get();
17             super.handleMessage(msg);
18             switch (msg.what){
19                 case 0x001:
20                     //do something
21                     if (activity != null){
22                         //do something
23                     }
24                     //do something
25                     break;
26                 default:
27                     break;
28             }
29         }
30     }
31 }

 

    4、子執行緒使用不當產生的記憶體洩漏

      在Android中使用子執行緒來執行耗時操作的方式比較多,如使用Thread,Runnable,AsyncTask(最新的Android sdk中已經去掉了)等,產生記憶體洩漏的原因和Handler基本相同,使用匿名內部類或者非靜態內部類時預設持有對外部類例項的引用,當外部類如Activity退出時,子執行緒中的任務還沒有執行完,該Activity例項就無法被gc回收,產生記憶體洩漏。

      解決方案也和Handler類似,也分兩種情況:

   (1)如果希望Activity退出後當前執行緒的任務仍然繼續執行完,可以將匿名內部類或非靜態內部類定義為靜態內部類,還可以結合弱引用來實現,如果耗時很長,可以啟動Service結合子執行緒來完成。

   (2)Activity退出時,該子執行緒終止執行,如下為示例程式碼:

 1 public class ThreadDemoActivity extends AppCompatActivity {
 2 
 3     private MyThread mThread = new MyThread();
 4 
 5     @Override
 6     protected void onCreate(Bundle savedInstanceState) {
 7         super.onCreate(savedInstanceState);
 8         setContentView(R.layout.activity_thread_demo);
 9         mThread.start();
10     }
11 
12     private static class MyThread extends Thread {
13         @Override
14         public void run() {
15             super.run();
16             if (isInterrupted()) {
17                 return;
18             }
19             //耗時操作
20         }
21     }
22 
23     @Override
24     protected void onDestroy() {
25         super.onDestroy();
26         mThread.interrupt();
27     }
28 }

至於執行緒中斷方式的選擇和為什麼要用紅色字型的方式來實現執行緒中斷,這裡不做延伸,推薦閱讀:Java終止執行緒的三種方式

 

    5、集合類長期儲存物件導致的記憶體洩漏

       集合類使用不當導致的記憶體洩漏,這裡分兩種情況來討論:

       1)集合類新增物件後不移除的情況

        對於所有的集合類,如果儲存了物件,如果該集合類例項的生命週期比裡面儲存的元素還長,那麼該集合類將一直持有所儲存的短生命週期物件的引用,那麼就會產生記憶體洩漏,尤其是使用static修飾該集合類物件時,問題將更嚴重,我們知道static變數的生命週期和應用的生命週期是一致的,如果新增物件後不移除,那麼其所儲存的物件將一直無法被gc回收。解決辦法就是根據實際使用情況,儲存的物件使用完後將其remove掉,或者使用完集合類後清空集合,原理和操作都比較簡單,這裡就不舉例了。

       2)根據hashCode的值來儲存資料的集合類使用不當造成的記憶體洩漏

       以HashSet為例子,當一個物件被儲存進HashSet集合中以後,就不能再修改該物件中參與計算hashCode的欄位值了,否則,原本儲存的物件將無法再找到,導致無法被單獨刪除,除非清空集合,這樣記憶體洩漏就發生了。

這裡我們舉個例子:

 1 public class Test {
 2     public static void main(String[] args) {
 3 
 4         Set<Student> set = new HashSet<>();
 5         Student s1 = new Student("zhang");
 6         set.add(s1);
 7         System.out.println(s1.hashCode());
 8         System.out.println(set.size());
 9 
10         s1.setName("haha");
11         set.remove(s1);
12         System.out.println(s1.hashCode());
13         System.out.println(set.size());
14     }
15 }
16 
17 class Student {
18     private String name;
19 
20     public Student(String name) {
21         this.name = name;
22     }
23 
24     public Student() {
25 
26     }
27 
28     public void setName(String name) {
29         this.name = name;
30     }
31 
32     public String getName() {
33         return name;
34     }
35 
36     @Override
37     public boolean equals(Object o) {
38         if (this == o) return true;
39         if (o == null || getClass() != o.getClass()) return false;
40         Student student = (Student) o;
41         return Objects.equals(name, student.name);
42     }
43 
44     @Override
45     public int hashCode() {
46         return Objects.hash(name);
47     }
48 }

 

如下為執行的結果:

115864587
1
3194833
1

name為參與計算hashCode的屬性,同一個物件修改name值前後的hashCode值已經不相同了,而HashSet中查詢儲存物件就是通過hashCode來定位的,所以在第11行中刪除s1物件失效了。

原因找到後,解決方法就容易了,物件儲存到HashSet後就不要再修改參與計算hashCode的欄位值,或者在集合物件使用完後清空集合。

  HashMap也是我們經常使用的集合類,HashSet的底層實現就是對HashMap的封裝,也是一樣的原因導致記憶體洩漏。

 1 public class Test1{
 2     public static void main(String[] args) {
 3 
 4         Map<Student,String> map = new HashMap<>();
 5         Student s1 = new Student("zhangsan");
 6         map.put(s1,"ShenZhen");
 7         System.out.println(map.get(s1));
 8 
 9         System.out.println(s1.hashCode());
10         s1.setName("lisi");
11         System.out.println(s1.hashCode());
12         System.out.println(map.get(s1));
13     }
14 }

測試結果為:

ShenZhen
115864587
3322034
null

和HashSet一樣,hashCode變了,最初儲存的物件就找不到了,也就沒法再單獨刪除該項記錄了,解決辦法和HashSet一樣。另外,一般建議不要使用自定義類物件作為HashMap的key值,儘量使用final修飾的類物件,比如String、Integer等,以避免做為Key的物件被隨意改動。

 

6、資源未關閉造成的洩漏

    (1)Bitmap用完後沒有呼叫recycle()

       這個前面有探討過,這裡我們暫時先將這一點也歸納到記憶體洩漏中。

    (2)I/O流使用完後沒有close()

       I/O流使用完後沒有顯示地呼叫close()方法,一定會產生記憶體洩漏嗎? 

       參考:未關閉的檔案流會引起記憶體洩露麼?

    (3)Cursor使用完後沒有呼叫close()

        Cursor使用完後沒有顯示地呼叫close()方法,一定會產生記憶體洩漏嗎? 

        參考:(ANDROID 9.0)關於CURSOR的記憶體洩露問題總結

    (4)沒有停止動畫產生的記憶體洩漏

       在屬性動畫中有一類無限迴圈動畫,如果在Activity中播放這類動畫並且在onDestroy中去停止動畫,那麼這個動畫將會一直播放下去,這時候Activity會被View所持有,從而導致Activity無法被釋放。解決此類問題則是需要早Activity中onDestroy去去呼叫objectAnimator.cancel()來停止動畫。 

 

 7、使用觀察者模式註冊監聽後沒有反註冊造成的記憶體洩漏

       (1)BroadcastReceiver沒有反註冊

       我們知道,當我們呼叫context.registerReceiver(BroadcastReceiver, IntentFilter) 的時候,會通過AMS的方式,將所傳入的引數BroadcastReceiver物件和IntentFilter物件通過Binder方式傳遞給系統框架程式中的AMS(ActivityManagerService),這樣AMS持有了BroadcastReceiver物件,BroadcastReceiver物件又持有了外部Activity物件(外部Activity物件也會傳遞到AMS中,在onReceive方法中會返回該Context),如果沒有進行反註冊,外部Activity在退出後,Activity物件,BroadcastReceiver物件,IntentFilter物件均不能被釋放掉,這樣就產生了記憶體洩漏。這部分的原始碼分析如果不清楚的話可以參考:【朝花夕拾】四大元件之(一)Broadcast篇 的第三節。

       我們看看context.unregisterReceiver(BroadcastReceiver)都做了些什麼工作:

 1 //ContextImpl.java
 2 @Override
 3 public void unregisterReceiver(BroadcastReceiver receiver) {
 4     if (mPackageInfo != null) {
 5         IIntentReceiver rd = mPackageInfo.forgetReceiverDispatcher(
 6                 getOuterContext(), receiver);
 7         try {
 8             ActivityManager.getService().unregisterReceiver(rd);
 9         } catch (RemoteException e) {
10             throw e.rethrowFromSystemServer();
11         }
12     } else {
13         throw new RuntimeException("Not supported in system context");
14     }
15 }

從第8行可以看到,這個過程通過Binder的方式轉移到了AMS中,另外getOuterContext()這裡就是外部Acitivity物件了,被封裝到rd物件中一併傳遞給AMS了:

 1 //ActivityManagerService.java
 2 public void unregisterReceiver(IIntentReceiver receiver) {
 3             ......
 4             ReceiverList rl = mRegisteredReceivers.get(receiver.asBinder());
 5             if (rl != null) {
 6                 final BroadcastRecord r = rl.curBroadcast;
 7                 ......
 8                 if (rl.app != null) {
 9                     rl.app.receivers.remove(rl);
10                 }
11                 removeReceiverLocked(rl);
12                 ......
13             }
14         }
15 }
16 
17 void removeReceiverLocked(ReceiverList rl) {
18     mRegisteredReceivers.remove(rl.receiver.asBinder());
19     for (int i = rl.size() - 1; i >= 0; i--) {
20         mReceiverResolver.removeFilter(rl.get(i));
21     }
22 }

上述原始碼中可以看到,在AMS中將BroadcastReceiver物件和IntentFilter物件都清理掉了,同時BroadcastReceiver物件所持有的外部Activity物件也清除了。

所以解決辦法就是在Activity退出時呼叫unregisterReceiver(BroadcastReceiver),其它元件如Service、Application中使用Broadcast也一樣,退出時要反註冊。

     (2)ContentObserver沒有反註冊導致的記憶體洩漏

原因和BroadcastReceiver沒有反註冊類似,將ContentObserver物件通過Binder方式新增到了系統服務ContentService中,如果沒有執行反註冊,系統服務會一直持有ContentObserver物件,而ContentObserver物件如果使用匿名內部類或非靜態內部類的方式,那又會持有Activity的例項,Activity退出是無法被回收,產生記憶體洩漏。解決方法也是新增反註冊,將新增到系統中的ContentObserver物件清除掉。   

    (3)通用觀察者模式程式碼沒有反註冊導致的記憶體洩漏

       實際上BroadcastReceiver和ContentObserver都是觀察者模式的代表,我們平時在使用觀察者模式的時候,比如註冊監聽,使用回撥等,也要特別注意,使用不當就容易產生記憶體洩漏,避免的辦法就是不再使用時執行反註冊。

 

  8、第三方庫使用不當造成的記憶體洩漏

      使用第三方庫的時候,務必要按照官方文件指定的步驟來做,否則使用不當也可能產生記憶體洩漏,比如EventBus,也是使用觀察者模式實現的,同樣註冊和反註冊要成對出現。

 

  9、系統bug之InputMethodManager導致記憶體洩漏

    這點可以閱讀文章:Android InputMethodManager記憶體洩漏 瞭解一下。

 

  10、ThreadLocal使用不當產生的記憶體洩漏

       ThreadLocal使用不當也容易產生記憶體洩漏,不過這個類平時大家基本不怎麼用,這裡就不多介紹了。 

 

六、使用工具分析記憶體分配情況

    1、使用Android Studio自帶的Profiler工具

       官網文件:使用記憶體效能分析器檢視應用的記憶體使用情況

    2、使用MAT工具

    3、使用Jdk自帶的Java VisualVM工具

    4、LeakCanary原理及使用

 

參考閱讀:

記憶體管理概覽

管理應用記憶體

程式間的記憶體分配

百度百科:記憶體溢位

相關文章