Android 面试之内存优化篇

Posted by Don on January 10, 2020

众所周知,内存对于Android来说是非常宝贵的,而且它对于应用程序的影响是非常大的,因此在面试时经常会被问到内存相关的问题,本文,笔者将带领大家一起来学习一下Android中的内存优化。 如果有朋友不想对原理有些了解,只对优化方案感兴趣的话可以直接跳过前面3部分,从第4部分看起即可,但是笔者还是希望您能从头看起。

一、Android内存管理机制

内存管理说白了其实就是对内存的分配与回收,而在安卓中无论是ART还是DVM(Dalvik虚拟机),都和JVM(java虚拟机)一样,无需程序员显示的管理内存的分配与回收,这一切都是由系统自动管理。虚拟机会回收无用内存的机制称为GC(垃圾回收),这也是本文的重点。接下来我们先来了解一下DVM和ART的原理。
PS:在Android 5.0以下,使用的是Dalvik虚拟机,5.0及以上,则使用的是ART虚拟机。

二、DVM内存的分配与回收

  1. DVM
    • Google公司开发的用于Android平台的虚拟机
    • 在Android 5.0以下使用该虚拟机
    • 在Android中的每一个应用都运行在一个单独Dalvik虚拟机中,即每个android应用程序对应一个Dalvik进程。独立的进程可以防止在虚拟机崩溃的时候所有程序都被关闭。
  2. 内存分配流程
    DVM的java堆可以分配内存的Space有2个,分别是Zygote Space和Allocation Space.
    Zygote Space用来管理Zygote进程在启动过程中预加载和创建的各种对象(比如系统资源),Zygote Space不会触发GC,所有进程都共享该区域。
    Allocation Space是在Zygote进程fork第一个子进程之前创建的,它是一种私有进程,Zygote进程及fork的子进程在Allocation Space进行对象分配和释放。

    当DVM的解析器遇到new指令时会触发 分配内存的操作,而java对象所占的内存主要在堆上实现,而堆是线程共享的,因此在分配内存过程中会对java堆进行加锁,这就会导致创建对象的开销会比较大(尽量避免频繁创建对象)。
    2.1 分配内存的流程
    1).如果可用内存大于申请内存,那么就将分配得到的地址直接返回给调用者,否则会进入下面的第2步;
    2).执行一次GC,GC执行完成后,分配指定内存,分配成功将分配地址直接返回给调用者,否则进入下面第3步;
    3).将堆的当前大小设置为Dalvik虚拟机启动时指定的Java堆最大值,然后进行内存分配,分配成功将分配地址直接返回给调用者,否则进入下面第4步;
    4).再次执行GC,这里的GC和步骤2中的GC区别在于此次会回收软引用对象,GC完成后会进行内存分配,如果还是失败则会抛出OOM异常。

  3. 内存回收(GC)
    3.1 Dalvik的GC类型以及触发时机:
    GC_CONCURRENT: 表示是在已分配内存达到一定量之后触发的GC;
    GC_FOR_MALLOC: 表示是在堆上分配对象时内存不足触发的GC;
    GC_BEFORE_OOM: 表示是在准备抛OOM异常之前进行的最后努力而触发的GC;
    GC_EXPLICIT: 表示是应用程序调用System.gc、VMRuntime.gc接口或者收到SIGUSR1信号时触发的GC。

    3.2 Dalvik的GC算法与流程 Dalvik虚拟机老年代使用标记清除(Mark——Sweep)算法,年轻代使用拷贝(Copying)算法。

    • 标记清除算法(Mark——Sweep) 定义:
      算法分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收被标记的对象。 这种算法直接在内存中把需要回收的对象“抠”出来。效率不高,清除之后会产生内容碎片,造成内存不连续,当分配较大内存对象时可能会因内存不足而触发垃圾收集动作或者报OOM.

    • 拷贝算法(Copying)
      定义:
      它将可用内存按容量划分为大小相等的两块,一次只在一块内存中进行分配,垃圾回收一次之后,就将该内存中的未被回收的对象移动到另一块内存中,然后将该内存一次清理掉。

    • GC流程:
      GC分为Mark和Sweep两个阶段,Mark阶段是查找和标记所有可访问到的活动对象,在Mark阶段要求挂起整个应用程序的非GC线程,因此Mark阶段分为并行和串行2种方式。并行会有选择性的停止当前工作线程,而串行会停止所有工作线程。但是并行GC需要多执行一次标记跟集对象以及递归标记那些在GC过程中被访问了的对象,这以为着并行GC需要花费更多的CPU资源。
      Sweep阶段是在标记完成后回收所有可回收的对象。
  4. 总结
    DVM有以下2个问题:
    1).GC时会挂起工作线程,频繁gc的话会导致程序卡顿
    2).内存碎片化严重,容易导致OOM

三、ART虚拟机

ART(Android Runtime)是Android 4.4发布的,用来替换Dalvik虚拟机,但Android 4.4默认采用的还是DVM,系统会提供一个选项来开启ART。在
Android 5.0及以上系统默认采用ART,google对DVM已不在维护。

  1. 内存分配
    ART的Java堆可以分配内存的Space有3个,Zygote Space、Allocation Space、Large Objcet Space,而应用运行时能够分配内存的Space只有 Allocation Space 和Large Object Space。

    ART与DVM内存分配策略基本相同,不过ART在Java堆上开辟了一个Space(Large Object Space,LOS)专门用于存储一些大对象(例如数组或者内存 大于一定值的对象)。

  2. 内存回收(GC)
    2.1 GC类型及调用时机:
    kGcCauseForAlloc: 当要分配内存的时候发现内存不够的情况下引起的GC,这种情况下的GC会Stop World.
    kGcCauseBackground: 当内存达到一定的阀值的时候会去出发GC,这个时候是一个后台GC,不会引起Stop World.
    kGcCauseExplicit:当应用程序显示调用System.gc、时触发的GC。

    2.2 GC 算法与流程

    • GC 算法:
      并发标记清除(Concurrent Mark Sweep,CMS)
      CMS里面包含多种回收机制:
      sticky CMS:ART的不移动(non-moving )分代垃圾回收器。它仅扫描堆中自上次 GC 后修改的部分,并且只能回收自上次GC后分配的对象;
      partial CMS:仅仅对应用程序的堆进行垃圾回收,但是不处理Zygote的堆;
      full CMS: 会对应用程序和Zygote的堆都会进行垃圾回收;
      当应用将进程状态更改为察觉不到卡顿的进程状态(例如,后台或缓存)时,ART 将执行堆压缩(清除内存碎片)。

    • GC流程:
      并行GC流程如下:
      1).调用子类实现的成员函数InitializePhase执行GC初始化阶段。
      2).获取用于访问Java堆的锁。
      3).调用子类实现的成员函数MarkingPhase执行GC并行标记阶段。
      4).释放用于访问Java堆的锁。
      5).挂起所有的ART运行时线程。
      6).调用子类实现的成员函数HandleDirtyObjectsPhase处理在GC并行标记阶段被修改的对象。
      7).恢复第4步挂起的ART运行时线程。
      8).重复第5到第7步,直到所有在GC并行阶段被修改的对象都处理完成。
      9).获取用于访问Java堆的锁。
      10).调用子类实现的成员函数ReclaimPhase执行GC回收阶段。
      11).释放用于访问Java堆的锁。
      12).调用子类实现的成员函数FinishPhase执行GC结束阶段

    非并行GC流程图如下:
    1).调用子类实现的成员函数InitializePhase执行GC初始化阶段。
    2).挂起所有的ART运行时线程。
    3).调用子类实现的成员函数MarkingPhase执行GC标记阶段。
    4).调用子类实现的成员函数ReclaimPhase执行GC回收阶段。
    5).恢复第2步挂起的ART运行时线程。
    6).调用子类实现的成员函数FinishPhase执行GC结束阶段

    ART的并发GC和Dalvik的并发GC有什么区别呢,初看好像2者差不多,虽然没有一直挂起线程,但是也会有暂停线程去执行标记对象的流程。通过阅读 相关文档可以了解到ART并发GC对于Dalvik来说主要有三个优势点:

    标记自身
    ART在对象分配时会将新分配的对象压入到Heap类的成员变量allocationstack描述的Allocation Stack中去,从而可以一定程度上缩减对象遍历范围 。

    预读取
    对于标记Allocation Stack的内存时,会预读取接下来要遍历的对象,同时再取出来该对象后又会将该对象引用的其他对象压入栈中,直至遍历完毕 。

    减少Suspend时间
    在Mark阶段是不会Block其他线程的,这个阶段会有脏数据,比如Mark发现不会使用的但是这个时候又被其他线程使用的数据,在Mark阶段也会处理一 些脏数据而不是留在最后Block的时候再去处理,这样也会减少后面Block阶段对于脏数据的处理的时间。

  3. ART与DVM的区别
    优点:
    1). 运行效率高于DVM。因为ART在应用安装时会进行预编译(ahead of time,AOT),将字节码编译成机器码存储在本地,而DVM是在程序运行时进行及时 编辑(just in time,JIT)。
    2).改善电池续航,因为预编译不需要在应用每次运行时都进行编译,从而也减少了CPU的资源,降低了能耗
    3).内存的分配与GC效率都比DVM高,而且解决了内存碎片化的问题

    缺点:
    1).应用安装时间边长,而且应用所占空间变大(android7.0 之后,ART 引入 JIT,安装时不会将字节码全部编译成机器码,而是运行时将热点代码( 当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码”)编译成机器码。从而缩短安装时间及减少安装空间)

四、内存优化

通过上面的内容,我们大概应该对内存的分配与GC有了一定的了解,那么解下来笔者将从3个方面解析内存优化方案:
1).减少运行时内存
2).内存抖动优化
3).内存泄漏优化

  1. 减少运行时内存
    • 减小apk的体积
      1).在gradle中开启混淆并移除无用的resource文件及代码
      2).在gradle中去除自动生成的国际化资源
      3).在gradle中对支持so库进行优化,一般只保留armeabi-v7a 即可
      3).对图片进行压缩或者使用webp格式代替
      4).使用微信资源压缩打包工具对APP进行压缩
    • 遵守开发规范
      1).如果app只有一套切图的话,最好使用较高分辨率的切图,并放置在对应分辨率的文件夹内,如果放在了低分辨率的文件夹内那么系统加载时会对图片进行放大,会浪费系统资源
      2).加载图片时对图片进行缩放
      3).对图片使用不同的解码格式,对质量要求不大的图片采用 RGB_565 格式,RGB_565的内存暂用率ARGB_8888少一半
      4).使用更加轻量的数据结构ArrayMap\SparseArray代替HashMap
      5).使用对象池来复用对象,避免频繁的创建与销毁带来性能问题
      6).使用include、Merge、ViewStub等标签优化布局嵌套层级与复用,减少内存消耗
      7).在内存间数据传输时推荐使用Parcelable,如activity间传输数据,因为Parcelable的性能比Serializable好,内存占用较少
  2. 内存抖动优化
    内存抖动是指内存频繁地分配和回收,而频繁的gc会导致卡顿,严重时和内存泄漏一样会导致OOM。而导致OOM的原因相信看了上面DVM相关内容的朋友 都已明白这里就不多说了。
    内存抖动优化方案:
    1).尽量避免在循环体或者频繁调用的函数内创建对象,应该把对象创建移到循环体或者函数体外
    2).自定义View时onDraw()、onlayout()方法会被频繁调用,所以应避免在该方法中创建对象
    3).上面也有提到尽量使用对象池来复用经常用到的对象 4).不要在循环体中使用”+”拼接字符串,因为系统会创建大量的StringBuilder对象.这种情况可以在循环体外部创建一个StringBuilder对象,在循环 内调用append方法

  3. 内存泄漏优化
    内存泄露的根本原因:长生命周期的对象持有短生命周期的对象。短周期对象就无法及时释放。
    内存泄漏情况:
    1).Activity调用了finish()但是他的Context仍被其他实例所引用导致的内存泄漏
    解决方案: 使用弱引用WeakReference或者使用Application Context代替(Dialog的context除外)

    2).内部类(成员内部类、局部内部类、匿名内部类)造成的内存泄漏
    解决方案: 首先将其修改为静态内部类,若内部类需要持有其他对象的实例则需要使用WeakReference,若为AsyncTask等异步或者周期性任务则需在 适当的时机取消异步或周期性任务

    3).单例模式造成的内存泄漏 如果单例需要使用Context的尽量使用Application Context,以免会造成内存泄漏

    4).资源未关闭造成的内存泄漏
    对BraodcastReceiver、File、Cursor、Stream、Bitmap等资源的使用,应该在Activity销毁时及时关闭或者注销,否则这些资源将不会被回收,造成 内存泄漏

    5).集合容器中的内存泄露 集合对象没有及时清理引起的内存泄漏。通常会把一些对象装入到集合中,当不使用的时候一定要记得及时清理集合,让相关对象不再被引用

    6).webview造成的内存泄漏
    解决方案: 为WebView单独开启一个进程,通过AIDL与主线程进行通,在关闭webview时销毁进程,或者使用腾讯的x5内核浏览服务替代原生的 webview

    7).Handler内存泄漏
    Handler通过Message与主线程进行交互,Message发出之后是存储在MessageQueue中的,有的Message并不是马上被处理,而且Message的target属性持有Handler的引用,如果Message在MessageQueue中存留时间过长就会导致Handler无法被回收,而如果Handler是非静态的,则会导致Activity或Service无法被回收。
    AsyncTask内部也是Handler机制,同样存在内存泄漏风险。
    解决方案: 将Handler声明为静态内部类,如果Hanlder需要context的话可以用过弱引用方式引用外部类 8).横竖屏切换时的内存泄漏
    当进行屏幕旋转,默认情况下会销毁掉当前的Activity,并创建新的Activity并保持之前的状态。如果该过程在新Activity中存在某些对象持有该销毁掉的Activity的引用,那么这就是内存泄露。这个解决方法是应尽量避免在Activity中使用非静态内部类,由于非静态内部类会隐式持有外部类实例的引用。为什么这就是内存泄露呢?因为我们明确地知道旧的Activity引用不该持有

五、内存分析与监控

即使我们全面掌握了内存优化的方案但也难免在开发中还是会有bug,下面推荐几款软件来实现对内存的分析与监控:
1、android studio 自带的 Profiler工具 以及MAT组合使用
点此查看使用教程

2、LeakCanary            
LeakCanary是我们在开发阶段测试阶段监测内存泄露的利器,非常方便,使用起来也非常简单,不会的请自行百度。           

原理:
LeakCanary通过application.registerActivityLifecycleCallbacks来绑定Activity生命周期的监听,从而监控所有Activity; 在Activity执行onDestroy()时,通过WeakReference + ReferenceQueue的方式判断对象是否被回收(WeakReference创建时,可以传入一个ReferenceQueue对象,假如WeakReference中引用对象被回收,那么就会把WeakReference对象添加到ReferenceQueue中,可以通过ReferenceQueue中是否为空来判断,被引用对象是否被回收)。判定回收, 手动GC, 再次判定回收,采用双重判定来确保当前引用是否被回收的状态正确性;如果两次都未回收,则确定为泄漏对象。