阅读 1971

Android应用优化之内存概念

导语

现在的Android智能手机发展信息万变,从一开始的HTC到小米价格战到现在高端市场份额战,在软硬件都发生了翻天覆地的变化。在硬件上内存从一开始的一两百M到现在4G。从软件上我们从一开始为了实现需求而写代码到现在为了代码更健壮、更漂亮而进行不断优化代码。这些都是Android发展的必然一步。今天我来跟大家一起分享Android内存优化的相关概念和实践。

概念

进程内存与RAM之间的关系

进程内存既是虚拟内存(或者叫逻辑内存),而程序的运行需要实实在在的内存,即物理内存(RAM),在需要的时候操作系统会将程序运行中申请的内存(虚拟内存)映射到RAM,让进程能够使用物理内存。

Android中的进程

Google提供的Android整体架构图,可以看到Android系统是基于Linux内核的,但针对移动设备较低的内存和能耗低的需求,Android按照自身需要开发低耗的组件和库,但是Android进程
最明显的内存特征是与zygote共享内存。为了加快启动速度及节约内存,Android应用的进程都是有zygote fork出来的。由于zygote已经载入了完整的Dalvik虚拟机和Android应用框架的代码,fork出的进程和zygote共享同一块内存,这样就节约了每个进程单独载入的时间和内存。

虚拟内存分区

虚拟内存对各种类型的数据进行存储,由于数据的杂乱,因此程序划分五个区域分别管理不同的数据:程序寄存器(Program Count Register)、本地方法栈(Native Stack)、方法区(Methon Area)、栈(Stack)、堆(Heap)

Java虚拟机、Dalvik虚拟机、ART虚拟机的区别

虚拟机(Virtual Machine),这个名词相信大家都不陌生。说到虚拟机我们肯定要说到Dalvik和JVM。
DVM是Dalvik Virtual Machine的缩写,是安卓虚拟机的意思。(为什么不叫AVM->Android Virtual Machine呢?原因是其作者以其祖上居住过的名为Dalvik的村子命名)。
JVM是相对Java Virtual Machine而言的,对于Java(Oracle公司)与Android(Google公司)的关系大家都懂。

JVM运行的是.class字节码,DVM运行的是.dex字节码格式。
Java类被编译成一个或多个字节码.class文件,打包到.jar文件中,java虚拟机从相应的.class文件和.jar文件中获取相应的字节码。
java类被编译成.class文件后,会通过一个dx工具将所有的.class文件转换成一个.dex文件,然后DVM会从其中读取指令和数据。

JVM基于栈,DVM基于寄存器。
JVM基于栈结构,程序在运行时虚拟机需要频繁的从栈上读取写入数据,这个过程需要更多的指令分派与内存访问次数,很耗费CPU时间。
DVM基于寄存器架构,数据的访问通过寄存器间直接传递,这样的访问方式比基于栈方式要快很多。

ART完整名称是Android Runtime。在Android5.0中,ART取代了Dalvik虚拟机(安卓在4.4中发布了ART)。
ART之所以会比Dalvik快,是因为ART执行的是本地机器指令,而Dalvik执行的是Dex字节码,通过通过解释器执行。尽管Dalvik也会对频繁执行的代码进行JIT生成本地机器指令来执行,但毕竟在应用程序运行的过程中将Dex字节码翻译成本地机器机器指令也会影响到应用程序本身的执行,因此即使Dalvik使用了JIT,也在一定程度上也比不上直接就可以执行本地机器指令的运行时。

Android内存分配机制

Android设备出厂以后,虚拟机对单个应用的最大内存分配就确定下来了,如dalvik.vm.heapstartsize=8m,超出这个值就会OOM。而Android为每个进程分配内存的时候,采用了弹性的分配方式,也就是刚开始并不会一下分配很多内存给每个进程,而是给每一个进程分配一个“够用”的量。这个量是根据每一个设备实际的物理内存大小来决定的。随着应用的运行,可能会发现当前的内存可能不够使用了,这时候Android又会为每个进程分配一些额外的内存大小。但是这些额外的大小并不是随意的,也是有限度的,系统不可能为每一个App分配无限大小的内除。对于这个属性值是定义在/system/build.prop文件中,它配置dalvik堆的有关设定。具体设定由如下三个属性来控制

  • dalvik.vm.heapstartsize
    堆分配的初始大小,调整这个值会影响到应用的流畅性和整体ram消耗。这个值越小,系统ram消耗越慢,但是由于初始值较小,一些较大的应用需要扩张这个堆,从而引发gc和堆调整的策略,会应用反应更慢。相反,这个值越大系统ram消耗越快,但是程序更流畅。
  • dalvik.vm.heapgrowthlimit
    受控情况下的极限堆(仅仅针对dalvik堆,不包括native堆)大小,dvm heap是可增长的,但是正常情况下dvm heap的大小是不会超过dalvik.vm.heapgrowthlimit的值。这个值控制那些受控应用的极限堆大小,如果受控的应用dvm heap size超过该值,则将引发oom。
  • dalvik.vm.heapsize
    不受控情况下的极限堆大小,这个就是堆的最大值。不管它是不是受控的。这个值会影响非受控应用的dalvikheap size。一旦dalvik heap size超过这个值,直接引发oom。

       用他们三者之间的关系做一个简单的比喻:分配dalvik heap就好像去食堂打饭,有人饭量大,要吃三碗,有人饭量小,连一碗都吃不完。如果食堂按照三碗的标准来给每个人打饭,那绝对是铺张浪费,所以食堂的策略就是先打一碗,凑合吃,不够了自己再来加,设定堆大小也是一样,先给一个合理值,凑合用,自己不够了再跟系统要。食堂毕竟是做买卖的,如果很多人明显吃不了那么多,硬是一碗接着一碗。为了制止这种不合理的现象,食堂又定了一个策略,一般人就只能吃三碗。但是如果虎背熊腰的大汉确实有需要,可以吃上五碗,超过五碗就不给了(太亏本了)。

情景 值含义
开始给一碗 dalvik.vm.heapstartsize
一般人最多吃三碗 dalvik.vm.heapgrowthlimit
虎背熊腰的大汉最多能吃五碗 dalvik.vm.heapsize

在android开发中,如果要使用大堆。需要在manifest中指定android:largeHeap为true。这样dvm heap最大可达dalvik.vm.heapsize。

RAM不足会出现的现象

当RAM不足时,Android程序主要会出现以下三种情形。

  • 内存泄漏

    不止Android程序员,内存泄露应该是大部分程序员都遇到过的问题,可以说大部分的内存问题都是内存泄露导致的,有兴趣的同学可以跳到我的另外链接来了解。

  • 内存抖动

    Android里内存抖动是指内存频繁地分配和回收,而频繁的gc会导致卡顿,严重时还会导致OOM。

  • 内存溢出

    内存溢出是一种非常严重的后果,大家可以根据接下来的分析去判断自己的程序有没做对于的优化事项。

Android应对RAM不足——内存释放垃圾回收机制

  • Application Framework

    Anroid基于进程中运行的组件及其状态规定了默认的五个回收优先级:

    空进程:正常情况下,为了平衡系统整体性能,Android不保存这些进程
    后台进程:存放于一个LRU缓存列表中,先杀死处于列表尾部的进程
    服务进程:正常不会被杀死
    可见进程:正常不会被杀死
    前台进程:正常不会被杀死

    Android为每一个进程分配了优先级的概念,系统需要进行内存回收时最先回收空进程,然后是后台进程,以此类推最后才会回收前台进程。

  • Dalvik 虚拟机

    上面我们说到当Android设备出厂以后,虚拟机对单个应用的最大内存分配就确定下来了,超出这个值就会OOM。

    Android的GC操作,GC全称是Garbage Collection,也就是所谓的垃圾回收。Android系统会在适当的时机触发GC操作,一旦进行GC操作,就会将一些不再使用的对象进行回收。

上图蓝色圈圈表示是内存中的对象,圈圈之间的箭头是对象的引用,上面的对象有的在使用,有的已经不再使用,那GC操作会从一个叫作Roots的对象开始检查。

可以看出,黄色的对象仍然会被继续保留使用,而蓝色的对象就会在GC操作当中被系统回收掉了,这就是一次简单的垃圾回收。

虚拟机通过可达性(Reachability)来判断对象是否存活,基本思想:以”GC Roots”的对象作为起始点向下搜索,搜索形成的路径称为引用链,当一个对象到GC Roots没有任何引用链相连(即不可达的),则该对象被判定为可以被回收的对象,反之不能被回收。

另一方面,上文提及到Android系统会在适当的时机触发GC操作,由于Java语言的特性,不像C++等语言需要人为地进行垃圾回收,还有我们也不需要主动去通知系统进行垃圾回收。当系统进行垃圾回收,会在打印台进行打印。打印的数据主要分为四个部分:

  1. GC_Reason,这个是触发这次GC操作的原因,一般情况下有以下几种原因:

    • GC_CONCURRENT: 当我们应用程序的堆内存快要满的时候,系统会自动触发GC操作来释放内存。
    • GC_FOR_MALLOC: 当我们的应用程序需要分配更多内存,可是现有内存已经不足的时候,系统会进行GC操作来释放内存。
    • GC_HPROF_DUMP_HEAP: 当生成HPROF文件的时候,系统会进行GC操作,关于HPROF文件我们下面会讲到。
    • GC_EXPLICIT: 这种情况就是我们刚才提到过的,主动通知系统去进行GC操作,比如调用System.gc()方法来通知系统。或者在DDMS中,通过工具按钮也是可以显式地告诉系统进行GC操作的。
  2. Amount_freed,表示系统通过这次GC操作释放了多少内存。

  3. Heap_stats中会显示当前内存的空闲比例以及使用情况(活动对象所占内存 / 当前程序总内存)。
  4. Pause_time表示这次GC操作导致应用程序暂停的时间。

下面是一次GC操作在LogCat中打印的日志:

深入分析垃圾回收时,我们需要知道Android Dalvik Heap与原生Java一样,将堆的内存空间分为三个区域,Young Generation(年轻代)、Old Generation(年老代)、Permanent(读音:[ˈpɜ:rmənənt]) Generation(永久代)

最近分配的对象会存放在Young Generation区域,当这个对象在这个区域停留的时间达到一定程度,它会被移动到Old Generation,最后累积一定时间再移动到Permanent Generation区域。系统会根据内存中不同的内存数据类型分别执行不同的gc操作。

回收算法

  • 标记回收算法(Mark and Sweep GC)

从”GC Roots”集合开始,将内存整个遍历一次,保留所有可以被GC Roots直接或间接引用到的对象,而剩下的对象都当作垃圾对待并回收,这个算法需要中断进程内其它组件的执行并且可能产生内存碎片。

  • 复制算法 (Copying)

将现有的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后,清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。

  • 标记-压缩算法 (Mark-Compact)

先需要从根节点开始对所有可达对象做一次标记,但之后,它并不简单地清理未标记的对象,而是将所有的存活对象压缩到内存的一端。之后,清理边界外所有的空间。这种方法既避免了碎片的产生,又不需要两块相同的内存空间,因此,其性价比比较高。

  • 分代回收

将所有的新建对象都放入称为年轻代的内存区域,年轻代的特点是对象会很快回收,因此,在年轻代就选择效率较高的复制算法。当一个对象经过几次回收后依然存活,对象就会被放入称为老生代的内存空间。对于新生代适用于复制算法,而对于老年代则采取标记-压缩算法。

然而复制算法和标记-压缩算法的区别在于前者是用空间换时间后者则是用时间换空间。

前者的在工作的时候是不没有独立的“mark”与“copy”阶段的,而是合在一起做一个动作,就叫scavenge(或evacuate,或者就叫copy)。也就是说,每发现一个这次收集中尚未访问过的活对象就直接copy到新地方,同时设置forwarding pointer。这样的工作方式就需要多一份空间。

后者在工作的时候则需要分别的mark与compact阶段,mark阶段用来发现并标记所有活的对象,然后compact阶段才移动对象来达到compact的目的。如果compact方式是sliding compaction,则在mark之后就可以按顺序一个个对象“滑动”到空间的某一侧。因为已经先遍历了整个空间里的对象图,知道所有的活对象了,所以移动的时候就可以在同一个空间内而不需要多一份空间。

结合上面我们说的ART与DVM的比较,在【Android】揭秘 ART 细节 ---- Garbage collection文章中图文结合,十分形象地描述了ART的GC优点,大家可以自行去阅读,本文就不重复篇幅去描述了。

不要频繁的引发GC,执行GC操作的时候,任何线程的任何操作都会需要暂停,等待GC操作完成之后,其他操作才能够继续运行, 故而如果程序频繁GC, 自然会导致界面卡顿。

Android内存分析

上文我们介绍了很多关于内存的概念知识,在我们了解相关后,我们通过检测工具来发现问题.本文先介绍最常见的三款检测工具.

  • Android Monitor Memory

    在我们使用Android Studio时,我们可以通过Minitor Memory来观察内存的使用情况.


图中顶部选择不同的设备,和对应调试的进程.
图中水平方向是时间轴,竖直方向是内存的分配情况.深蓝色区域表示使用中的内存总量,浅灰色表示未作分配的内存.
内存分析工具,从左到右四个按钮,依次是:

  1. Enabled/disenable 检测开关,不用管.
  2. Initiate GC 手动调用GC.在我们抓取内存前,先手动点击一下这个按钮来手动触发GC行为.
  3. Dump Java Heap 点击生成一个文件(包名+日期+“.hprof”).记录的是一个时间段内,程序使用内存的情况.
  4. start Allocation Tracking 开始分配追踪,第一次点击可以指定追踪内存的开始位置,第二次点击可以结束追踪的位置。这样我们截取了一段要分析的内存,等待几秒钟Android Studio会给我们打开一个Allocation视图

了解完基本操作我们使用运行一下项目,然后我们手动操作GC,再一定量的操作后,看看项目的内存使用情况,此时含有正常新增的内存使用,也可能出现了内存泄漏的情况.我们点击Dump Java Heap,等一小会儿会自动生成.hprof文件并自动弹出HPROF Viewer来分析内存使用情况.

我们看着上图来分析,显示左上角的显示方式,有两个选项分别是Heap区域和Class List View的展示方式.
Heap类型有:
App Heap -- 当前App使用的Heap
Image Heap -- 磁盘上当前App的内存映射拷贝
Zygote Heap -- Zygote进程Heap

Class List View有:
Class List View -- 类列表方式
Package Tree View -- 根据包结构的树状显示

HPROF Viewer主要分三大模块
模块a:这个应用中所有类的名字
模块b:左边类的所有实例
模块c:在选择B中的实例后,这个实例的引用树

模块a名词解析:

名词 解析
Class Name 类名,Heap中的所有Class
Total Count 内存中该类这个对象总共的数量,有的在栈中,有的在堆中
Heap Count 堆内存中这个类 对象的个数
Sizeof 每个该实例占用的内存大小
Shallow Size 所有该类的实例占用的内存大小
Retained Size 所有该类对象被释放掉,会释放多少内存

模块b名词解析:

名词 解析
Instance 该类的实例
Depth 深度, 从任一GC Root点到该实例的最短跳数
Dominating Size 该实例可支配的内存大小

b模块右上角有个AnalyzerTasks的按钮, 点击会进入HPROF Analyzer的hprof的分析界面,点击Analyzer Tasks右边的绿色运行箭头,Android Studio会自动的根据此hprof文件分析有哪些类是有内存泄漏的,如下图所示:


在Analyzer Tasks中的AnalyzerResults中看到分析泄漏的activities,在代码中实例只有一个,但我们在a模块来看 total count的值为2,heap count的值也为2,说明有一个是多余的。我们在b模块看到了对应的两个实例.根据分析我们可以在对应的代码就是修复.

但是遇到一个常见的问题,我们知道了这中内存泄漏的问题,但是我找到究竟是哪里出现问题呀,所以接着的功能是Allocation Tracker,用来内存分配追踪。在内存图中点击途中标红的部分,启动追踪,再次点击就是停止追踪,随后自动生成一个alloc结尾的文件,这个文件就记录了这次追踪到的所有数据,然后会在右上角打开一个数据面板Allocation Tracker启动追踪.

Allocation Tracker查看方式
有两种查看方式:

  1. Group by Method:用方法来分类我们的内存分配
  2. Group by Allocator:用内存分配器来分类我们的内存分配

Group by Allocator方式.右击直接跳到对应的代码.
点击统计图按钮,会生成上图,扇形统计图是以圆心为起点,最外层是其内存实际分配的对象,每一个同心圆可能被分割成多个部分,代表了其不同的子孙,每一个同心圆代表他的一个后代,每个分割的部分代表了某一带人有多人,你双击某个同心圆中某个分割的部分,会变成以你点击的那一代为圆心再向外展开。

  • MAT

    接下来我们说一下比Memory Monitor更强大的MAT。MAT全称 Eclipse Memory Analysis Tools 是一个分析Java堆数据的专业工具。

    首先集成MAT工具,我们可以在官网下载,地址:www.eclipse.org/mat/.如果你电脑有eclipse就直接安装MAT插件(不懂如何安装的同学自行搜索"eclipse安装MAT插件").
    当我们集成了MAT工具后,我们回到AS IDE中,之前我们在分析Monitor Memory时候生成过.hprof文件,这时需要通过AS转换一下格式或者使用jdk自带命令转换,如下图AS转换:

接着我们用MAT打开转换后的.hprof文件.如下图所示,我们要关注红框内的功能.

Shallow Heap :一个对象内存的消耗大小,不包含对其他对象的引用;
Retained Heap :是shallow Heap的总和,也就是该对象被GC之后所能回收的内存大小;
详细解释可参考文章:Shallow heap & Retained heap

Histogram
可列出每一个类的实例数。支持正则表达式查找,也可以计算出该类所有对象的retained size

Dominator Tree
Dominator Tree:对象之间dominator关系树。如果从GC Root到达Y的的所有path都经过X,那么我们称X dominates Y,或者X是Y的Dominator Dominator Tree由系统中复杂的对象图计算而来。从MAT的dominator tree中可以看到占用内存最大的对象以及每个对象的dominator。
我们也可以右键选择Immediate Dominator”来查看某个对象的dominator。

Path to GC Roots
查看一个对象到RC Roots的引用链
通常在排查内存泄漏的时候,我们会选择exclude all phantom/weak/soft etc.references,
意思是查看排除虚引用/弱引用/软引用等的引用链,因为被虚引用/弱引用/软引用的对象可以直接被GC给回收,我们要看的就是某个对象否还存在Strong 引用链(在导出HeapDump之前要手动出发GC来保证),如果有,则说明存在内存泄漏,然后再去排查具体引用。(记得看最上方的箭头,用包名来查看)

右击查看当前Object所有引用,被引用的对象:

List objects with (以Dominator Tree的方式查看)

incoming references 引用到该对象的对象
outcoming references 被该对象引用的对象

Show objects by class (以class的方式查看)

by incoming references 引用到该对象的对象
by outcoming references 被该对象引用的对象

Heap Dump Overview

从工具栏中点开 Heap Dump Overview视图,可以看到一个全局的内存占用信息

OQL(Object Query Language)
类似SQL查询语言
Classes:Table
Objects:Rows
Fileds: Cols

select * from com.example.mat.Listener

查找size=0并且未使用过的ArrayList
select * from java.util.ArrayList where size=0 and modCount=0

查找所有的Activity
select * from instanceof android.app.Activity

按红色感叹号查询结果.

上面我们介绍了MAT几个重要的功能点.下面我们开始来找出造成内存泄漏的凶手.

1.我们通过Dominator Tree视图用Path to GC Roots方式来查找.

2.Object Query Language快速查找


不要认为输命令很麻烦,但是比上面Path to GC Roots方式一个个包看的速度要快要准.

3.内存快照对比


打开两个时间点的.hprof文件,都打开了Histogram标签,然后按右上角Compare to anther Heap Dump按钮,进行对比.

小结:我们使用MAT的最终目的就是能顺利地找出内存泄漏的地方我们上面介绍了三种方式,之前我们通过Monitor Memory的方式已经大概能定位泄漏类,然后我们使用MAT前两个方式十分直接地去查找究竟是哪个对象引用出现了泄漏问题.而第三个对比方式,在我们还是不确定位置情况下可以使用,我们可以通过对比的方式来判断泄漏的位置.

  • LeakCanary


LeakCanary是square公司出品。有一句话为square出品,必属精品。
项目地址:github.com/square/leak…。它的工作原理、接入流程、使用方法我就不在这里一一介绍了,网上关于LeakCanary的教程实在太多。它是你开发必备工具。这就是我对它的理解。

总结:
整篇文章我们可以分为两部分,第一部分是对内存基础知识的概述.大家需要注意结构写法,是按照物理内存、虚拟机内存、Androi内存分配机制、Android内存回收机制。针对异常导致的内存泄漏、内存抖动、内存溢出我们利用Monitor Memory,MAT,LeakCanary对内存进行了一系列的分析 其中Monitor Memory自带的内存分析工具直观方便,但其功能却不如MAT强大,特别是没有有效的搜索、排序等功能。遇到一些棘手的问题,可能还是要借助MAT来分析内存。大家觉得我的描述不够详细或准确,可以根据我这个文章描述结构形成一个基本的认识后,再根据每一个小点进行细化学习。
说说我自己的实操方法,我认为大型项目都要引用LeakCanary包。在debug环境下,如果出现了内存泄漏,我们会马上收到提示,我们可以根据LeakCanary的信息收集地方对泄漏做一开始的判断,如果有相当的经验后,我们可以直接修改,如果不能我们可以通过Monitor Memory来更清晰的定位,遇到更加棘手的问题,我们再使用MAT的方式,根据它丰富的工具一步步来定位内存泄漏的对象,并对其分析解决。
不过分析了这么多,最重要的是实操。小伙伴们马上实践起来。

关注下面的标签,发现更多相似文章
评论
说说你的看法