【朝花夕拾】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原理及使用

posted @ 2021-08-02 21:28  宋者为王  阅读(3437)  评论(0编辑  收藏  举报