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

       記憶體溢出是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原理及使用

 

參考閱讀:

記憶體管理概覽

管理應用記憶體

進程間的記憶體分配

百度百科:記憶體溢出

Exit mobile version