Android 内存优化最佳实践

Android 内存优化最佳实践

移动设备上,内存是兵家必争之地,内存,CPU,帧率,耗电量,是非常重要的用户体验性能,从根本上考虑,优化内存和CPU,帧率和耗电量一般都会得到一定程度的优化。

Bugly 在 2016 收集的崩溃数据如下(android 应用数据)

同比 2015 年, OOM 崩溃问题从 2% 上涨到了 6%,OOM问题,随着业务场景的复杂,问题越发严重。

JVM 虚拟机概述

Java虚拟机(英语:Java Virtual Machine,缩写为JVM),一种能够运行Java bytecode的虚拟机,以堆栈结构机器来进行实做。最早由Sun所研发并实现第一个实现版本,是Java平台的一部分,能够运行以Java语言写作的软件程序。

首先了解下 JVM相关的基础知识,方便后面理解对象回收的一些模型。

JVM 运行时数据区

简单点说,就是 jvm 运行的时候,包括哪几块内存区域,具体看图。

  • PC:程序计数器,线程独立,记录当前线程方法执行行数,如果执行为 native 层方法,则数值为 Undefined。这一块不会出现 OOM。
  • VM Stack: 虚拟机栈,线程独立,JVM会为每个方法创建一个栈帧,然后入栈,一个方法调用完成,也就是入栈和出栈的过程。虚拟机栈的生命周期等于线程生命周期。会出现 OOM 问题。
  • Native Stack: 本地方法栈,线程独立。类似虚拟机栈。会出现 OOM 问题。
  • Heap: 堆,线程共享。对象分配都是在这块区域进行的,是内存管理的主要区域。会出现 OOM 问题。
  • Method Area: 方法区,线程共享。保存了虚拟机加载的类信息,常量和静态变量。

对于 android 开发来说,主要了解VM Stack和 Heap 这一块的内存管理,基本就可以了。而对于 VM Stack 知道两个原则即可:

  1. 栈帧保存了局部变量表,操作数栈,动态连接,方法出口等信息。局部变量表所需空间,在编译期间已经确定,运行是不会再申请。其次,栈掉用的深度,也是编译时确定的。
  2. 某个线程方法请求的栈深度大于 JVM 虚拟机所允许的深度,就会抛出 StackOverfolwError 异常。

至于 Java 堆,后面会详细介绍。

GC Root

GC root 是 GC 里面另一个很重要的概念, java 对象的回收,根据计算当前对象对于 Gc Root 对象是否可达,所以我们有必要知道那些对象是被当作 GC Root 对象。

在 Jvm 里面 GC root 可能是以下对象:

  1. 虚拟机栈中引用的对象
  2. 方法区中类静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈 JNI 引用的对象
  5. 存活的线程对象
  6. Class 对象
  7. 用于同步的 Monitor 对象

那么在 android 里面什么是 GC root 对象呢?又该如何对一个对象占用的内存空间能否被销毁,产生感知呢?Android 里面 GC Root 对象是以下类型的对象:

  • 全局变量引用的对象
  • 栈变量引用的对象
  • 寄存器引用的对象

Java Heap

java heap,也就是我们 java 层内存回收的主要区域,我们先了解以下几个特点:

  1. Java 对象是如何被创建的
  2. 对象何时被认为是“死”的,如何判断
  3. JVM GC 回收算法
java 对象创建过程

这里只说明通过 new 关键字创建的对象,具体的过程如下:

所以实际上,一个 Class 文件被加载之后,其对象实例大小所占的内存就是固定大小的。但是由于在实际开发中,对象里面往往包含了数组等数据结构,所以导致,相同类的不同对象实例所占内存大小,可能会不相同。那么假设JVM 无法为新的对象分配内存,怎会发生 OOM 异常。

JVM java Heap 回收算法

这里简单提一下可达性分析算法,所谓可达性,就是判断当前对象是否被一类成为 GC Root 的对象引用,也就是说,是否存在一条引用链,可以让当前对象到达 GC Root 对象。

说起回收算法,首先得说一个点,如果判断对象是否可达,这里存在好几种判断方式:

  1. 对象引用计数。
  2. 可达性分析算法。

大部分 JVM 虚拟机里面都是通过计算 GC root 可达,来判断一个对象的内存是否可以回收,这里用一张图就可以说明。

不同的 JVM 虚拟机支持不同的回收算法,常见的回收算法有如下:

  1. 标记-清除算法
  2. 复制算法
  3. 标记-整理算法
  4. 分代回收算法

和大部分商业的 JVM 虚拟机一样,android 虚拟机上主要使用 标记-清除算法,这里先不具体展开描述,后面会详细介绍。

这里需要声明的是分代回收的策略:JVM 根据对象存活周期的不同,将内存划分为几块。一般是分为新生代和老年代,例如 Dalvik 上是分成了三块, Young Generation,Old Generation,Permanent Generation。这样根据不同区的对象生命周期特点,实施不同的回收算法。

JVM 虚拟机, Dalvik 虚拟机, ART 虚拟机

很多人疑问,为什么我这里讲的是 Android 内存优化,但是又先把 JVM 拿出来讲呢?这里不关系虚拟机之间的架构,运行模式等细节,不过还是要简单讲解下三者之间的主要差异和关系。

  1. JVM 基于堆栈,通过解释器执行 java 字节码
  2. Dalvik 基于寄存器,它属于 JVM 虚拟机衍生出来的,通过解释器运行 dex 字节码,存在 JIT 技术,使常用的代码翻译成本地机器指令。(IOS 上都是直接执行本地机器指令的) Dalvik 实现了 Java 虚拟机接口。
  3. 等同于 Dalvik,只不过执行的是本地机器指令,使用 AOT 预编译,对 dex 文件进行了优化,转化成了本地执行的机器码,所以实际上 ART 是执行本地机器指令的虚拟机。

他们之间运行时的内存划分存在相同点,不过差异较大,可是是基于发展的,原理借鉴性很大。

Android 虚拟机概述

在 4.4 以及更早的版本使用的是 Dalvik 虚拟机,在 4.4 可选 和 5.0 以后,使用的是 ART 虚拟机。这里主要讲解 ART 虚拟机上,内存管理和回收。

在了解 ART 虚拟机的 GC 机制之前,先了解下 Dalvik 虚拟机的 GC 机制。

Dalvik 虚拟机 GC

在 android 应用程序启动时,其应用进程由 zygote 进程 fork 出来,这就意味着应用程序和 zygote 共享了一个用来分配对象的堆。

然而了为避过多的拷贝,Dalvik 虚拟机将自己的堆划分为两部分(实际上只有一个堆,但是 dalvik 虚拟机会将进程创建完成时,已经使用的堆和未使用的堆,划分为两个部分),前者是 Zygote Heap,后者是 Active Heap。应用程序之后的对象内存分配,都是在 Active Heap 上进行的。

其具体的分配情况如下:

关于 Dalvik 中虚拟机 Java 堆的几个特点:

  1. 虚拟机在创建的时候,就会分配固定大小的虚拟内存,但是实际上使用的物理内存是按需分配的。
  2. Davlik 是动态调整 Java 堆可用的最大值。

Dalvik 虚拟机在碰到 new 语法(new 一个对象)的时候,会使用一种渐进的方法来对对象分配内存,这过程中就可以触发 GC,GC 回收那些没有被根集对象引用的对象。

Dalvik 虚拟机采用的是 Mark-Sweep 算法,这里分 Mark 阶段和 Sweep 阶段,又分为是否 Concurrent ,其次 GC 分不同的原因,具体参数由一个 GCSpec 结构体表示,源码位置/dalvik/vm/alloc/Heap.h :

struct GcSpec {  
  /* If true, only the application heap is threatened. */  
  bool isPartial;  
  /* If true, the trace is run concurrently with the mutator. */  
  bool isConcurrent;  
  /* Toggles for the soft reference clearing policy. */  
  bool doPreserve;  
  /* A name for this garbage collection mode. */  
  const char *reason;  
};  

isPartial: 为true时,表示仅仅回收Active堆的垃圾;为false时,表示同时回收Active堆和Zygote堆的垃圾。

isConcurrent: 为true时,表示执行并行GC;为false时,表示执行非并行GC。

doPreserve: 为true时,表示在执行GC的过程中,不回收软引用引用的对象;为false时,表示在执行GC的过程中,回收软引用引用的对象。

reason: 一个描述性的字符串。

/* Not enough space for an "ordinary" Object to be allocated. */  
extern const GcSpec *GC_FOR_MALLOC;  

/* Automatic GC triggered by exceeding a heap occupancy threshold. */  
extern const GcSpec *GC_CONCURRENT;  

/* Explicit GC via Runtime.gc(), VMRuntime.gc(), or SIGUSR1. */  
extern const GcSpec *GC_EXPLICIT;  

/* Final attempt to reclaim memory before throwing an OOM. */  
extern const GcSpec *GC_BEFORE_OOM;  

GC_FOR_MALLOC: 表示是在堆上分配对象时内存不足触发的GC。

GC_CONCURRENT: 表示是在已分配内存达到一定量之后触发的GC。

GC_EXPLICIT: 表示是应用程序调用System.gc、VMRuntime.gc接口或者收到SIGUSR1信号时触发的GC。

GC_BEFORE_OOM: 表示是在准备抛OOM异常之前进行的最后努力而触发的GC。

实际上,GC_FOR_MALLOC、GC_CONCURRENT和GC_BEFORE_OOM三种类型的GC都是在分配对象的过程触发的。

//png dalvik GC 过程

GC 过程比较繁琐复杂,这里只大概进行分析:

  1. Mark 阶段,首先会挂起所有的线程,准备进行 Mark 工作。
  2. 开始标记根集对象。
  3. 如果是 Concurrent 类型的 GC,则会先唤醒挂起的线程,然后用特定的数据结构,记录当前开始被修改过引用的对象。
  4. 递归标记所有被根集对象引用的对象。
  5. 如果是 Concurrent 类型的 GC,会重新锁定堆,然后在挂起线程,查看是否创建了新的根集对象。
  6. 特殊处理处理软引用,弱引用和影子引用类型的对象。
  7. 回收弱引用对象。
  8. 交换Mark Bitmap 和 Live Bitmap,根据 Mark BItmap 使得GC 知道,在 上次GC存活的 Live Bitmap 对象中,哪些对象是当前 GC 可以回收的。
  9. 如果是 Concurrent 类型的 GC,会解锁第五步中锁定的堆和唤起线程。
  10. 回收对象,也就是 Mark Bitmap 中标记为 0,Live Bitmap 标记为 1 的对象。
  11. 重置 Mark Bitmap 和 Live Bitmap。
  12. 唤起第一步中挂起的线程。

并行GC和非并行 GC 的主要区别就是,前者在 GC 过程中,会通过一定的策略挂起和唤醒非GC 线程(工作线程),使得应用进程有更好的执行效率,但是却需要额外的CPU和内存开支来维持。

ART 虚拟机 GC

ART 上的 GC 回收策略也使用了 Mark-Sweep 算法,和 Dalvik 虚拟机的回收算法存在相似点。

ART 运行时堆

首先了解下 ART 将运行时堆的划分。

其中,Image Space,Zygote Space,Allocation Space 在物理内存地址上是连续的空间,Large Object Space 则是一些离散地址的集合,用来分配一些大对象。

  • Image Space 保存了预加载的类
  • Zygote Space 等同于 Dalvik 的 Zygote Heap,是 Zygote 进程中堆的拷贝
  • Allocation Space等同于 Dalvik 的 Active 堆,初始化堆的除去 Zygote Space 的剩余部分。
  • Large Object Space 分配大对象

在 Image Space 和 Zygote 之间,存在一段内存保存了 system@[email protected]@colasses.oat 这个在ART 虚拟机启动的时候就会加载,其内容是 dex 文件翻译生成的,所有需要预加载的类对象都会创建保存在这里,这意味着每次启动系统,只需要将这块内容映射到内存里面,避免了每次创建新的类对象。

ART 里面使用了一个 Heap 类去描述 ART 运行时的堆,这里介绍下该类的一些重要的成员变量,源码位置

  1. mark_sweep_collectors_: 一个std::vector

GC 基本概念

在 Java 程序里面程序员不用关心 GC,因为GC 会由虚拟机触发,触发时刻取决于虚拟机采用的规范,但总体上来说,我们需要对 GC 有以下认识:

  1. 进行 GC 的时候,进程必然是暂停,所谓暂停,也就是所有的代码执行会停止,等待 GC 结束之后,继续运行。
  2. GC 可能会损坏应用性能,导致显示不稳定,界面响应速度变慢,以及其它体验问题。

内存泄漏

内存泄漏的定义:在感知上,某个对象应该是该被 GC 回收了,但是因为该对象仍存在达到 GC Root 的引用链,导致其内存实际无法被回收,就是内存泄漏。

这里引发一点思考,其实内存泄漏,可以说是两种情况造成了:

  1. 使用者对该对象的生命周期不够清晰。
  2. 引用了错误的 GC Root 对象。

这里再考虑几个场景,假设,我对象 A 在某个场景下,会存在内存泄漏,那么如果我重复进入多个这样的场景,对象 A 会泄漏多少个实例呢?这个就要看 GC Root 的对象的实例数量了,如果每次是不同的 GC Root 对象,那么就会泄漏多个对象,如果一直只有一个 GC Root,那么就只有一个对象实例泄漏。

所以,这里再次说明了内存泄漏的另一个特点,阶段时效性。

内存泄漏的检测方法

内存检测的方式大同小异,一般分为两类:

  1. IDE 工具或者插件
  2. 内存泄漏检测框架

下面就介绍以下其中三种较为常用的检测方式。

Android Studio 检测内存泄漏

在 AS 3.0 以前的版本,我们可以通过 Android Monitor ->Monitor 来直观的检查Activity 内存泄漏。

  1. 选择你需要测试的 Activity,进入然后触发需要的场景操作
  2. 然后退出当前 Activity
  3. 点开 mointors -> Initiate GC -> Dump the Heap —> 等待生成分析文件 -> Analyse Actiivties Leaks
MAT

MAT 是 Eclipse 上的 java 内存分析工具,从AS dump 出来的不是标志的 prof 文件,需要通过如下方式转成 prof 文件。

LeakCanary

这个框架就不多说了,直接集成,简单粗暴。

Activity 泄漏检查的基本原理

Activity 的生命周期由程序员感知,但是实际上回收和控制其生命周期还是 Android 系统管理的,程序员负责完成回调生命周期方法,GC 系统回收内存。

API 14 以上(Android 4.0 )在 Application 类提供了一个监听 Activity 生命周期回调的接口,可以监听该 Application 下所有的 Activity 的生命周期回调;

public interface ActivityLifecycleCallbacks {
    void onActivityCreated(Activity activity, Bundle savedInstanceState);
    void onActivityStarted(Activity activity);
    void onActivityResumed(Activity activity);
    void onActivityPaused(Activity activity);
    void onActivityStopped(Activity activity);
    void onActivitySaveInstanceState(Activity activity, Bundle outState);
    void onActivityDestroyed(Activity activity);
}

这样我们就可以知道 Activity 是否进入了 onDestroy()方法,LeakCanary 则使用软引用引用需要检查的Activity,然后在 GC 的时候通过判断该软用持有的对象是否为空,来判断 Activity 所持有的内存是否被回收,从而检查 该 Activity 对象实例是否泄漏。

当然 Fragment 的泄漏也是一样的,通过类似的回调去检测,Fragment 对象的内存是否真正的能够被释放。

OOM

我们经常说到 OOM,那么实际上,我们对 OOM 了解多少呢?我最早的时候只是知道 OOM 便是内存溢出,如果虚拟机不能完成新的内存申请的时候,就会抛出 OOM 异常。

首先看下这个异常类,其类图如下:

首先看下 VirtualMachineError,API 中的描述是:

Thrown to indicate that the Java Virtual Machine is broken or has run out of resources necessary for it to continue operating. // 当 JVM 虚拟机被打断或者获取不到必要的资源进行下一步操作的时候,抛出该错误。

OOM 不属于我们熟悉的 Java Exception 机制,它属于 JVM 运行时的异常错误,而且由 JVM 虚拟机抛出,所以你不会看到一个 java 方法的签名有抛出 OOM的。

内存抖动

内存抖动,也就是不断的创建对象,同时不断的触发 GC 去回收对象,内存抖动的问题,我们可以通过各种工具查看的到, Android Studio 的 Monitor 就是最好的方法。

总结

讲了一大堆东西,那么我们了解 GC 究竟有什么作用了,它既不是系统 api 也不是代码技巧,更不是编程思想或者框架,它只是底层原理。

  1. 了解内存泄漏的根本原理,解决内存泄漏问题。
  2. 编写性能体验更好的代码

Android 内存优化最佳策略

优化 BitMap 加载

这里单独把 Bitmap 拎出来讲是有特定的原因的,由于 Bitmap 非常占用内存,所以我们需要考虑对 Bitmap 加载进行优化。

  1. 避免加载 Bitmap 出现 OOM 问题
  2. 加载 Bitmap 前,考虑进行适当的压缩
  3. 使用合理的缓存策略

避免 new Thread

thread 会占用系统资源,我们应该避免野蛮的使用 new Thread,而是使用线程池,使用 ThreadPoolExecutor 或者其子类,即便是 AsyncTask 中也是使用了线程池管理子线程的启动和使用。

避免内存泄漏

检测内存泄漏的方式很多,上面已经提到了,避免内存泄漏,不仅是为了程序性能,更是为了程序的使用体验。

  1. 内部类,避免内部类引用外部类导致内存泄漏
  2. static 变量,注意全局 static 变量,尽量不要引用 Activity 等生命周期明显的变量,或者需要手动释放
  3. BroadCast 等使用完毕之后取消监听或者反注册

优化布局

尽量使用少的布局,因为 ViewTree 的绘制是非常耗费时间的,过多的使用 findViewById() 方法也是非常消耗时间的。在 ViewPager 等类似的控件中,考虑增加缓存项目。

使用 Fragment

使用 ViewPager 和 Fragment 时,如果 Fragment 较少,可以直接使用 FragmentPagerAdapter,如果 Fragment 较多则需要使用 FragmentStatePagerAdapter ,会有较好的性能体验

你可能感兴趣的:(android,性能模块)