【朝花夕拾】Android性能篇之(八)Android內存溢出/泄漏常見案例分析及優化方案最佳實踐總結


       轉載請申明,轉自【https://www.cnblogs.com/andy-songwei/p/15091806.html】,謝謝!

       內存溢出是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、第三方庫使用不當造成的內存泄漏

      使用第三方庫的時候,務必要按照官方文檔指定的步驟來做,否則使用不當也可能產生內存泄漏,比如:

    (1)EventBus,也是使用觀察者模式實現的,同樣注冊和反注冊要成對出現。

    (2)Rxjava中,上下文銷毀時,Disposable沒有調用dispose()方法。

    (3)Glide中,在子線程中大量使用Glide.with(applicationContext),可能導致內存溢出

 

  9、系統bug之InputMethodManager導致內存泄漏

    這點可以閱讀文章:Android InputMethodManager內存泄漏 了解一下。

 

  10、ThreadLocal使用不當產生的內存泄漏

       ThreadLocal使用不當也容易產生內存泄漏,不過這個類平時大家基本不怎么用,這里就不多介紹了。 

 

六、Android內存管理最佳實踐

      Android設備內存有限,為了適應有限的內存空間,Android SDK中引入了不少比JavaSE更省內存消耗的使用方案,這里簡單介紹幾個。

    1、使用SparseArray存儲數據

    2、使用Parceable代替Serializable

    3、Android官方的內存使用建議

        以下是Android官方提供的內存管理文檔,可以參照來合理使用內存:

       內存管理概覽

       管理應用內存 

       進程間的內存分配

 

七、使用工具分析內存分配情況

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

       官網文檔:使用內存性能分析器查看應用的內存使用情況

    2、使用MAT工具

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

    4、LeakCanary原理及使用


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM