高频面试点:Android性能优化之内存优化(上篇)

2020-12-16 15:49:05 浏览数 (1)

作者:jsonchao

链接:https://juejin.im/post/5e72b2d151882549236f9cb8

注:因原文比较长,所以分篇来。

序言

众所周知,内存优化可以说是性能优化中最重要的优化点之一,可以说,如果你没有掌握系统的内存优化方案,就不能说你对Android的性能优化有过多的研究与探索。本篇,笔者将带领大家一起来系统地学习Android中的内存优化。

可能有不少读者都知道,在内存管理上,JVM拥有垃圾内存回收的机制,自身会在虚拟机层面自动分配和释放内存,因此不需要像使用C/C 一样在代码中分配和释放某一块内存。Android系统的内存管理类似于JVM,通过new关键字来为对象分配内存,内存的释放由GC来回收。并且Android系统在内存管理上有一个 Generational Heap Memory模型,当内存达到某一个阈值时,系统会根据不同的规则自动释放可以释放的内存。即便有了内存管理机制,但是,如果不合理地使用内存,也会造成一系列的性能问题,比如 内存泄漏、内存抖动、短时间内分配大量的内存对象 等等。下面,我就先来谈谈Android的内存管理机制。

一、Android内存管理机制

我们都知道,应用程序的内存分配和垃圾回收都是由Android虚拟机完成的,在Android 5.0以下,使用的是Dalvik虚拟机,5.0及以上,则使用的是ART虚拟机

1、Java对象生命周期

Java代码编译后生成的字节码.class文件从从文件系统中加载到虚拟机之后,便有了JVM上的Java对象,Java对象在JVM上运行有7个阶段,如下:

1)Created(创建)

Java对象的创建分为如下几步:

  • 1、为对象分配存储空间。
  • 2、构造对象。
  • 3、从超类到子类对static成员进行初始化,类的static成员的初始化在ClassLoader加载该类时进行。
  • 4、超类成员变量按顺序初始化,递归调用超类的构造方法。
  • 5、子类成员变量按顺序初始化,一旦对象被创建,子类构造方法就调用该对象并为某些变量赋值。

2)InUse(应用)

此时对象至少被一个强引用持有

3)Invisible(不可见)

当一个对象处于不可见阶段时,说明程序本身不再持有该对象的任何强引用,虽然该对象仍然是存在的。简单的例子就是程序的执行已经超出了该对象的作用域了。但是,该对象仍可能被虚拟机下的某些已装载的静态变量线程或JNI等强引用持有,这些特殊的强引用称为“GC Root”被这些GC Root强引用的对象会导致该对象的内存泄漏,因而无法被GC回收

4)Unreachable(不可达)

该对象不再被任何强引用持有

5)Collected(收集)

GC已经对该对象的内存空间重新分配做好准备时,对象进入收集阶段,如果该对象重写了finalize()方法,则执行它。

6)Finalized(终结)

等待垃圾回收器回收该对象空间

7)Deallocated(对象空间重新分配)

GC对该对象所占用的内存空间进行回收或者再分配,则该对象彻底消失。

注意

  • 1、不需要使用该对象时,及时置空。
  • 2、访问本地变量优于访问类中的变量。

2、内存分配

在Android系统中,实际上就是一块匿名共享内存。Android虚拟机仅仅只是把它封装成一个 mSpace由底层C库来管理,并且仍然使用libc提供的函数malloc和free来分配和释放内存

大多数静态数据会被映射到一个共享的进程中。常见的静态数据包括Dalvik Code、app resources、so文件等等。

在大多数情况下,Android通过显示分配共享内存区域(如Ashmem或者Gralloc)来实现动态RAM区域能够在不同进程之间共享的机制。例如,Window Surface在App和Screen Compositor之间使用共享的内存,Cursor Buffers在Content Provider和Clients之间共享内存

上面说过,对于Android Runtime有两种虚拟机,Dalvik 和 ART,它们分配的内存区域块是不同的,下面我们就来简单了解下。

Dalvik

  • Linear Alloc
  • Zygote Space
  • Alloc Space

ART

  • Non Moving Space
  • Zygote Space
  • Alloc Space
  • Image Space
  • Large Obj Space

不管是Dlavik还是ART,运行时堆都分为 LinearAlloc(类似于ART的Non Moving Space)、Zygote Space 和 Alloc Space

Dalvik中的Linear Alloc是一个线性内存空间,是一个只读区域,主要用来存储虚拟机中的类,因为类加载后只需要只读的属性,并且不会改变它。把这些只读属性以及在整个进程的生命周期都不能结束的永久数据放到线性分配器中管理,能很好地减少堆混乱和GC扫描,提升内存管理的性能

Zygote Space在Zygote进程和应用程序进程之间共享,Allocation Space则是每个进程独占。Android系统的第一个虚拟机由Zygote进程创建并且只有一个Zygote Space。但是当Zygote进程在fork第一个应用程序进程之前,会将已经使用的那部分堆内存划分为一部分,还没有使用的堆内存划分为另一部分,也就是Allocation Space。但无论是应用程序进程,还是Zygote进程,当他们需要分配对象时,都是在各自的Allocation Space堆上进行

当在ART运行时,还有另外两个区块,即 ImageSpace和Large Object Space

  • Image Space存放一些预加载类,类似于Dalvik中的Linear Alloc。与Zygote Space一样,在Zygote进程和应用程序进程之间共享
  • Large Object Space离散地址的集合,分配一些大对象,用于提高GC的管理效率和整体性能

注意:Image Space的对象只创建一次,而Zygote Space的对象需要在系统每次启动时,根据运行情况都重新创建一遍。

3、内存回收机制

在Android的高级系统版本中,针对Heap空间有一个Generational Heap Memory的模型,其中将整个内存分为三个区域:

  • Young Generation(年轻代)
  • Old Generation(年老代)
  • Permanent Generation(持久代)

模型示意图如下所示:

1)Young Generation

一个Eden区和两个Survivor区组成,程序中生成的大部分新的对象都在Eden区中,当Eden区满时,还存活的对象将被复制到其中一个Survivor区,当此Survivor区满时,此区存活的对象又被复制到另一个Survivor区,当这个Survivor区也满时,会将其中存活的对象复制到年老代

2)Old Generation

一般情况下,年老代中的对象生命周期都比较长

3)Permanent Generation

用于存放静态的类和方法,持久代对垃圾回收没有显著影响。

4)内存对象的处理过程小结

  • 1、对象创建后在Eden区
  • 2、执行GC后,如果对象仍然存活,则复制到S0区
  • 3、当S0区满时,该区域存活对象将复制到S1区,然后S0清空,接下来S0和S1角色互换
  • 4、当第3步达到一定次数(系统版本不同会有差异)后,存活对象将被复制到Old Generation
  • 5、当这个对象在Old Generation区域停留的时间达到一定程度时,它会被移动到Old Generation,最后累积一定时间再移动到Permanent Generation区域

系统在Young Generation、Old Generation上采用不同的回收机制。每一个Generation的内存区域都有固定的大小。随着新的对象陆续被分配到此区域,当对象总的大小临近这一级别内存区域的阈值时,会触发GC操作,以便腾出空间来存放其他新的对象

此外,执行GC占用的时间与Generation和Generation中的对象数量有关,如下所示:

  • Young Generation < Old Generation < Permanent Generation
  • Generation中的对象数量与执行时间成反比

5)Young Generation GC

由于其对象存活时间短,因此基于Copying算法(扫描出存活的对象,并复制到一块新的完全未使用的控件中)来回收。新生代采用空闲指针的方式来控制GC触发,指针保持最后一个分配的对象在Young Generation区间的位置,当有新的对象要分配内存时,用于检查空间是否足够,不够就触发GC

6Old Generation GC

由于其对象存活时间较长,比较稳定,因此采用Mark(标记)算法(扫描出存活的对象,然后再回收未被标记的对象,回收后对空出的空间要么合并,要么标记出来便于下次分配,以减少内存碎片带来的效率损耗)来回收。

4、GC类型

在Android系统中,GC有三种类型:

  • kGcCauseForAlloc:分配内存不够引起的GC,会Stop World。由于是并发GC,其它线程都会停止,直到GC完成。
  • kGcCauseBackground:内存达到一定阈值触发的GC,由于是一个后台GC,所以不会引起Stop World。
  • kGcCauseExplicit:显示调用时进行的GC,当ART打开这个选项时,使用System.gc时会进行GC。

接下来,我们来学会如何分析Android虚拟机中的GC日志,日志如下:

代码语言:javascript复制
D/dalvikvm(7030):GC_CONCURRENT freed 1049K, 60% free 2341K/9351K, external 3502K/6261K, paused 3ms 3ms

GC_CONCURRENT 是当前GC时的类型,GC日志中有以下几种类型:

  • GC_CONCURRENT:当应用程序中的Heap内存占用上升时(分配对象大小超过384k),避免Heap内存满了而触发的GC。如果发现有大量的GC_CONCURRENT出现,说明应用中可能一直有大于384k的对象被分配,而这一般都是一些临时对象被反复创建,可能是对象复用不够所导致的
  • GC_FOR_MALLOC:这是由于Concurrent GC没有及时执行完,而应用又需要分配更多的内存,这时不得不停下来进行Malloc GC。
  • GC_EXTERNAL_ALLOC:这是为external分配的内存执行的GC。
  • GC_HPROF_DUMP_HEAP:创建一个HPROF profile的时候执行。
  • GC_EXPLICIT:显示调用了System.GC()。(尽量避免)

我们再回到上面打印的日志:

  • freed 1049k:表明在这次GC中回收了多少内存。
  • 60% free 2341k/9351K:表明回收后60%的Heap可用,存活的对象大小为2341kb,heap大小是9351kb。
  • external 3502/6261K:是Native Memory的数据。存放Bitmap Pixel Data(位图数据)或者堆以外内存(NIO Direct Buffer)之类的数据。第一个值说明在Native Memory中已分配3502kb内存,第二个值是一个浮动的GC阈值,当分配内存达到这个值时,会触发一次GC。
  • paused 3ms 3ms:表明GC的暂停时间,如果是Concurrent GC,会看到两个时间,一个开始,一个结束,且时间很短,如果是其他类型的GC,很可能只会看到一个时间,且这个时间是相对比较长的。并且,越大的Heap Size在GC时导致暂停的时间越长。

注意:在ART模式下,多了一个Large Object Space,这部分内存并不是分配在堆上,但还是属于应用程序的内存空间

在Dalvik虚拟机下,GC的操作都是并发的,也就意味着每次触发GC都会导致其它线程暂停工作(包括UI线程)。而在ART模式下,GC时不像Dalvik仅有一种回收算法,ART在不同的情况下会选择不同的回收算法,比如Alloc内存不够时会采用非并发GC,但在Alloc后,发现内存达到一定阈值时又会触发并发GC。所以在ART模式下,并不是所有的GC都是非并发的。

总体来看,在GC方面,与Dalvik相比,ART更为高效,不仅仅是GC的效率,大大地缩短了Pause时间,而且在内存分配上对大内存分配单独的区域,还能有算法在后台做内存整理,减少内存碎片。因此,在ART虚拟机下,可以避免较多的类似GC导致的卡顿问题。

二、优化内存的意义

优化内存的意义不言而喻,总的来说可以归结为如下四点:

  • 1、减少OOM,提高应用稳定性
  • 2、减少卡顿,提高应用流畅度
  • 3、减少内存占用,提高应用后台运行时的存活率
  • 4、减少异常发生和代码逻辑隐患

需要注意的是,出现OOM是因为内存溢出导致,这种情况不一定会发生在相对应的代码处,也不一定是出现OOM的代码使用内存有问题,而是刚好执行到这段代码。

三、避免内存泄漏

1、内存泄漏的定义

Android系统虚拟机的垃圾回收是通过虚拟机GC机制来实现的。GC会选择一些还存活的对象作为内存遍历的根节点GC Roots,通过对GC Roots的可达性来判断是否需要回收。内存泄漏就是在当前应用周期内不再使用的对象被GC Roots引用,导致不能回收,使实际可使用内存变小

2、使用MAT来查找内存泄漏

MAT工具可以帮助开发者定位导致内存泄漏的对象,以及发现大的内存对象,然后解决内存泄漏并通过优化内存对象,以达到减少内存消耗的目的。

使用步骤

1、在https://eclipse.org/mat/downloads.php下载MAT客户端。
2、从Android Studio进入Profile的Memory视图,选择需要分析的应用进程,对应用进行怀疑有内存问题的操作,结束操作后,主动GC几次,最后export dump文件。
3、因为Android Studio保存的是Android Dalvik/ART格式的.hprof文件,所以需要转换成J2SE HPROF格式才能被MAT识别和分析。Android SDK自带了一个转换工具在SDK的platform-tools下,其中转换语句为:
代码语言:javascript复制
./hprof-conv file.hprof converted.hprof
复制代码
4、通过MAT打开转换后的HPROF文件。

MAT视图

在MAT窗口上,OverView是一个总体概览,显示总体的内存消耗情况和疑似问题。MAT提供了多种分析维度,其中Histogram、Dominator Tree、Top Consumers和Leak Suspects的分析维度是不同的。下面分别介绍下它们,如下所示:

1、Histogram

列出内存中的所有实例类型对象和其个数以及大小,并在顶部的regex区域支持正则表达式查找。

2、Dominator Tree

列出最大的对象及其依赖存活的Object。相比Histogram,能更方便地看出引用关系

3、Top Consumers

通过图像列出最大的Object

4、Leak Suspects

通过MAT自动分析内存泄漏的原因和泄漏的一份总体报告

分析内存最常用的是Histogram和Dominator Tree这两个视图,视图中一共有四列:

  • Class Name:类名。
  • Objects:对象实例个数。
  • Shallow Heap:对象自身占用的内存大小,不包括它引用的对象。非数组的常规对象的Shallow Heap Size由其成员变量的数量和类型决定,数组的Shallow Heap Size由数组元素的类型(对象类型、基本类型)和数组长度决定。真正的内存都在堆上,看起来是一堆原生的byte[]、char[]、int[],对象本身的内存都很小。因此Shallow Heap对分析内存泄漏意义不是很大
  • Retained Heap:是当前对象大小与当前对象可直接或间接引用到的对象的大小总和,包括被递归释放的。即:Retained Size就是当前对象被GC后,从Heap上总共能释放掉的内存大小。

查找内存泄漏具体位置

常规方式
  • 1、按照包名类型分类进行实例筛选或直接使用顶部Regex选取特定实例。
  • 2、右击选中被怀疑的实例对象,选择Merge Shortest Paths to GC Root->exclude all phantom/weak/soft etc references。(显示GC Roots最短路径的强引用)
  • 3、分析引用链或通过代码逻辑找出原因。

还有一种更快速的方法就是对比泄漏前后的HPROF数据:

  • 1、在两个HPROF文件中,把Histogram或者Dominator Tree增加到Compare Basket。
  • 2、在Compare Basket中单击 ! ,生成对比结果视图。这样就可以对比相同的对象在不同阶段的对象实例个数和内存占用大小,如明显只需要一个实例的对象,或者不应该增加的对象实例个数却增加了,说明发生了内存泄漏,就需要去代码中定位具体的原因并解决。

需要注意的是,如果目标不太明确,可以直接定位RetainedHeap最大的Object,通过Select incoming references查看引用链,定位到可疑的对象,然后通过Path to GC Roots分析引用链

此外,我们知道,当Hash集合中过多的对象返回相同的Hash值时,会严重影响性能,这时可以用 Map Collision Ratio 查找导致Hash集合的碰撞率较高的罪魁祸首

高效方式

在本人平时的项目开发中,一般会使用如下几种方式来快速对指定页面进行内存泄漏的检测(也称为运行时内存分析优化):

  • 1、shell命令 LeakCanary MAT:运行程序,所有功能跑一遍,确保没有改出问题,完全退出程序,手动触发GC,然后使用adb shell dumpsys meminfo packagename -d命令查看退出界面后Objects下的Views和Activities数目是否为0,如果不是则通过LeakCanary检查可能存在内存泄露的地方,最后通过MAT分析,如此反复,改善满意为止。
  • 2、Profile MEMORY:运行程序,对每一个页面进行内存分析检查。首先,反复打开关闭页面5次,然后收到GC(点击Profile MEMORY左上角的垃圾桶图标),如果此时total内存还没有恢复到之前的数值,则可能发生了内存泄露。此时,再点击Profile MEMORY左上角的垃圾桶图标旁的heap dump按钮查看当前的内存堆栈情况,选择按包名查找,找到当前测试的Activity,如果引用了多个实例,则表明发生了内存泄露。
  • 3、从首页开始用依次dump出每个页面的内存快照文件,然后利用MAT的对比功能,找出每个页面相对于上个页面内存里主要增加了哪些东西,做针对性优化。
  • 4、利用Android Memory Profiler实时观察进入每个页面后的内存变化情况,然后对产生的内存较大波峰做分析。

此外,除了运行时内存的分析优化,我们还可以对App的静态内存进行分析与优化。静态内存指的是在伴随着App的整个生命周期一直存在的那部分内存,那我们怎么获取这部分内存快照呢?

首先,确保打开每一个主要页面的主要功能,然后回到首页,进开发者选项去打开"不保留后台活动"。然后,将我们的app退到后台,GC,dump出内存快照。最后,我们就可以将对dump出的内存快照进行分析,看看有哪些地方是可以优化的,比如加载的图片、应用中全局的单例数据配置、静态内存与缓存、埋点数据、内存泄漏等等。

3、常见内存泄漏场景

对于内存泄漏,其本质可理解为无法回收无用的对象。这里我总结了我在项目中遇到的一些常见的内存泄漏案例(包含解决方案)。

1)资源性对象未关闭

对于资源性对象不再使用时,应该立即调用它的close()函数,将其关闭,然后再置为null。例如Bitmap等资源未关闭会造成内存泄漏,此时我们应该在Activity销毁时及时关闭。

2)注册对象未注销

例如BraodcastReceiver、EventBus未注销造成的内存泄漏,我们应该在Activity销毁时及时注销。

3)类的静态变量持有大数据对象

尽量避免使用静态变量存储数据,特别是大数据对象,建议使用数据库存储。

4)单例造成的内存泄漏

优先使用Application的Context,如需使用Activity的Context,可以在传入Context时使用弱引用进行封装,然后,在使用到的地方从弱引用中获取Context,如果获取不到,则直接return即可。

5)非静态内部类的静态实例

该实例的生命周期和应用一样长,这就导致该静态实例一直持有该Activity的引用,Activity的内存资源不能正常回收。此时,我们可以将该内部类设为静态内部类或将该内部类抽取出来封装成一个单例,如果需要使用Context,尽量使用Application Context,如果需要使用Activity Context,就记得用完后置空让GC可以回收,否则还是会内存泄漏。

6)Handler临时性内存泄漏

Message发出之后存储在MessageQueue中,在Message中存在一个target,它是Handler的一个引用,Message在Queue中存在的时间过长,就会导致Handler无法被回收。如果Handler是非静态的,则会导致Activity或者Service不会被回收。并且消息队列是在一个Looper线程中不断地轮询处理消息,当这个Activity退出时,消息队列中还有未处理的消息或者正在处理的消息,并且消息队列中的Message持有Handler实例的引用,Handler又持有Activity的引用,所以导致该Activity的内存资源无法及时回收,引发内存泄漏。

解决方案如下所示:

  • 1、使用一个静态Handler内部类,然后对Handler持有的对象(一般是Activity)使用弱引用,这样在回收时,也可以回收Handler持有的对象。
  • 2、在Activity的Destroy或者Stop时,应该移除消息队列中的消息,避免Looper线程的消息队列中有待处理的消息需要处理。

需要注意的是,AsyncTask内部也是Handler机制,同样存在内存泄漏风险,但其一般是临时性的。对于类似AsyncTask或是线程造成的内存泄漏,我们也可以将AsyncTask和Runnable类独立出来或者使用静态内部类。

7)容器中的对象没清理造成的内存泄漏

在退出程序之前,将集合里的东西clear,然后置为null,再退出程序

8)WebView

WebView都存在内存泄漏的问题,在应用中只要使用一次WebView,内存就不会被释放掉。我们可以为WebView开启一个独立的进程,使用AIDL与应用的主进程进行通信,WebView所在的进程可以根据业务的需要选择合适的时机进行销毁,达到正常释放内存的目的。

9)使用ListView时造成的内存泄漏

在构造Adapter时,使用缓存的convertView。

4、内存泄漏监控

一般使用LeakCanary进行内存泄漏的监控即可,具体使用和原理分析请参见我之前的文章Android主流三方库源码分析(六、深入理解Leakcanary源码)。

除了基本使用外,我们还可以自定义处理结果,首先,继承DisplayLeakService实现一个自定义的监控处理Service,代码如下:

代码语言:javascript复制
public class LeakCnaryService extends DisplayLeakServcie {
    
    private final String TAG = “LeakCanaryService”;
    
    @Override
    protected void afterDefaultHandling(HeapDump heapDump, AnalysisResult result, String leakInfo) {
        ...
    }
}

重写 afterDefaultHanding 方法,在其中处理需要的数据,三个参数的定义如下:

  • heapDump:堆内存文件,可以拿到完成的hprof文件,以使用MAT分析。
  • result:监控到的内存状态,如是否泄漏等。
  • leakInfo:leak trace详细信息,除了内存泄漏对象,还有设备信息。

然后在install时,使用自定义的LeakCanaryService即可,代码如下:

代码语言:javascript复制
public class BaseApplication extends Application {
    
    @Override
    public void onCreate() {
        super.onCreate();
        mRefWatcher = LeakCanary.install(this, LeakCanaryService.calss, AndroidExcludedRefs.createAppDefaults().build());
    }
    
    ...
    
}
代码语言:javascript复制

经过这样的处理,就可以在LeakCanaryService中实现自己的处理方式,如丰富的提示信息,把数据保存在本地、上传到服务器进行分析。

注意

LeakCanaryService需要在AndroidManifest中注册。

0 人点赞