1、内存泄漏的定义
Android是基于Java的,众所周知Java语言的内存管理是其一大特点,不用像C语言那样处理对象的内存分配到回收的全部过程。在Java中我们只需要简单地新建对象就可以了,Java垃圾回收器会负责回收释放对象内存。这么看的话,垃圾回收器会管理内存又怎么还会发生内存泄漏呢?
其实Java中的内存泄漏的定义是:对象不再被程序所使用,但是由于这些对象被引用着导致GC(GarbageCollector)不能回收它们。
下面这张图可以帮助我们更好地理解对象的状态,以及内存泄漏的情况
左边未引用的对象是会被GC回收的,右边被引用的对象不会被GC回收,但是未使用的对象中除了未引用的对象,还包括已被引用的一部分对象,那么内存泄漏久发生这部分已被引用但未使用的对象。
接下来还有一个疑问:未使用的对象被谁引用会让GC无法回收呢?
现在主流的程序语言的主流实现中,是通过可达性分析(ReachabilityAnalysis)来判断对象是否存活的。这个算法的基本思路是:通过一系列的称为“GCRoots”的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GCRoots没有任何引用链时,说明此对象不可用,可以被回收了。
可以作为GCRoots的对象包括下面几种:
·虚拟机栈中引用的对象,一般是当前在使用中局部变量
·方法区中类静态属性引用的对象,就是静态变量对应的对象
·方法区中常量引用的对象
·本地方法栈中JNI(即一般说的Native方法)引用的对象
MAT分析内存泄漏的时候,也是查看对象到GCRoots的引用链,来定位泄漏代码的位置。
所以未使用的对象直接或间接地被GCRoots引用时会让GC无法回收,从而产生内存泄漏。
2、Android的内存管理
了解了Java的内存泄漏的起因,接下来大致了解Android中的内存管理机制。
Google在Android的官网上有这样一篇文章,初步介绍了Android是如何管理应用的进程与内存分配:http://developer.android.com/training/articles/memory.html。Android系统的Dalvik虚拟机扮演了常规的内存垃圾自动回收的角色,Android系统没有为内存提供交换区,它使用paging与memory-mapping(mmapping)的机制来管理内存,下面简要概述一些Android系统中重要的内存管理基础概念。
分配与回收内存
每一个进程的Dalvikheap都反映了使用内存的占用范围。这就是通常逻辑意义上提到的DalvikHeapSize,它可以随着需要进行增长,但是增长行为会有一个系统为它设定的上限。
逻辑上讲的HeapSize和实际物理意义上使用的内存大小是不对等的,ProportionalSetSize(PSS)记录了应用程序自身占用以及和其他进程进行共享的内存。
Android系统并不会对Heap中空闲内存区域做碎片整理。系统仅仅会在新的内存分配之前判断Heap的尾端剩余空间是否足够,如果空间不够会触发gc操作,从而腾出更多空闲的内存空间。在Android的高级系统版本里面针对Heap空间有一个GenerationalHeapMemory的模型,最近分配的对象会存放在YoungGeneration区域,当这个对象在这个区域停留的时间达到一定程度,它会被移动到OldGeneration,最后累积一定时间再移动到PermanentGeneration区域。系统会根据内存中不同的内存数据类型分别执行不同的gc操作。例如,刚分配到YoungGeneration区域的对象通常更容易被销毁回收,同时在YoungGeneration区域的gc操作速度会比OldGeneration区域的gc操作速度更快。如下图所示:
每一个Generation的内存区域都有固定的大小,随着新的对象陆续被分配到此区域,当这些对象总的大小快达到这一级别内存区域的阀值时,会触发GC的操作,以便腾出空间来存放其他新的对象。如下图所示:
通常情况下,GC发生的时候,所有的线程都是会被暂停的。执行GC所占用的时间和它发生在哪一个Generation也有关系,Young Generation中的每次GC操作时间是最短的,Old Generation其次,Permanent Generation最长。执行时间的长短也和当前Generation中的对象数量有关,遍历树结构查找20000个对象比起遍历50个对象自然是要慢很多的。
为什么通常情况下,GC发生的时候,所有的线程都会被暂停?
因为每次GC的时候,需要先找到可作为GC Roots的对象,然后以此搜索引用链,这个过程需要在一致性的内存快照中进行。这个“一致性”表示在整个过程中不能出现对象引用关系不断变化的情况,所以需要暂停所有的执行线程。
限制应用的内存
为了整个Android系统的内存控制需要,Android系统为每一个应用程序都设置了一个硬性的Dalvik Heap Size最大限制阈值,这个阈值在不同的设备上会因为RAM大小不同而各有差异。如果你的应用占用内存空间已经接近这个阈值,此时再尝试分配内存的话,很容易引起OutOfMemoryError的错误。
ActivityManager.getMemoryClass()可以用来查询当前应用的Heap Size阈值,这个方法会返回一个整数,表明你的应用的Heap Size阈值是多少Mb(megabates)。
还有一个用adb命令查询的方法:
adb shell getprop dalvik.vm.heapgrowthlimit
3、案例
JOOX是IBG一个核心产品,2014年发布以来已经成为5个国家和地区排名第一的音乐App。东南亚是JOOX的主要发行地区,实际上这些地区还是有很多的低端机型,对App的进行内存优化势在必行。
上面介绍了Android系统内存分配和回收机制,同时也列举了常见的内存问题,但是当我们接到一个内存优化的任务时,我们应该从何开始?下面是一次内存优化的分享。
1. 首先是解决大部分内存泄露。
不管目前App内存占用怎样,理论上不需要的东西最好回收,避免浪费用户内存,减少OOM。实际上自JOOX接入LeakCanary后,每个版本都会做内存泄露检测,经过几个版本的迭代,JOOX已经修复了几十处内存泄露。
2. 通过MAT查看内存占用,优化占用内存较大的地方。
JOOX修复了一系列内存泄露后,内存占用还是居高不下,只能通过MAT查看到底是哪里占用了内存。关于MAT的使用,网上教程无数,简单推荐两篇MAT使用教程,MAT - Memory Analyzer Tool 使用进阶。
点击Android Studio这里可以dump当前的内存快照,因为直接通过Android Sutdio dump出来的hprof文件与标准hprof文件有些差异,我们需要手动进行转换,利用sdk目录/platform-tools/hprof-conv.exe可以直接进行转换,用法:hprof-conv 原文件.hprof 新文件.hprof。只需要输入原文件名还有目标文件名就可以进行转换,转换完就可以直接用MAT打开。
下面就是JOOX打开App,手动进行多次gc的hprof文件。
这里我们看的是Dominator Tree(即内存里占用内存最多的对象列表)。
Shallo Heap:对象本身占用内存的大小,不包含其引用的对象内存。
Retained Heap: Retained heap值的计算方式是将retained set中的所有对象大小叠加。或者说,由于X被释放,导致其它所有被释放对象(包括被递归释放的)所占的heap大小。
第一眼看去 居然有3个8M的对象,加起来就是24M啊 这到底是什么鬼?
我们通过List objects-》with incoming references查看(这里with incoming references表示查看谁引用了这个对象,with outgoing references表示这个对象引用了谁)
通过这个方式我们看到这三张图分别是闪屏,App主背景,App抽屉背景。
这里其实有两个问题:
这几张图原图实际都是1280x720,而在1080p手机上实测这几张图都缩放到了1920x1080
闪屏页面,其实这张图在闪屏显示过后应该可以回收,但是因为历史原因(和JOOX的退出机制有关),这张图被常驻在后台,导致无谓的内存占用。
优化方式:我们通过将这三张图从xhdpi挪动到xxhdpi(当然这里需要看下图片显示效果有没很大的影响),以及在闪屏显示过后回收闪屏图片。
优化结果:
从原来的8.29x3=24.87M 到 3.68x2=7.36M 优化了17M(有没一种万马奔腾的感觉。。可能有时费大力气优化很多代码也优化不了几百K,所以很多情况下内存优化时优化图片还是比较立竿见影的)。
同样方式我们发现对于一些默认图,实际要求的显示要求并不高(图片相对简单,同时大部分情况下图片加载会成功),比如下面这张banner的背景图:
优化前1.6M左右,优化后700K左右。
同时我们也发现了默认图片一个其他问题,因为历史原因,我们使用的图片加载库,设置默认图片的接口是需要一个bitmap,导致我们原来几乎每个adapter都用BitmapFactory decode了一个bitmap,对同一张默认图片,不但没有复用,还保存了多份,不仅会造成内存浪费,而且导致滑动偶尔会卡顿。这里我们也对默认图片使用全局的bitmap缓存池,App全局只要使用同一张bitmap,都复用了同一份。
另外对于从MAT里看到的图片,有时候因为看不到在项目里面对应的ID,会比较难确认到底是哪一张图,这里stackoverflow上有一种方法,直接用原始数据通过GIM还原这张图片。
这里其实也看到JOOX比较吃亏一个地方,JOOX不少地方都是使用比较复杂的图片,同时有些地方还需要模糊,动画这些都是比较耗内存的操作,Material Design出来后,很多App都遵循MD设计进行改版,通常默认背景,默认图片一般都是纯色,不仅App看起来比较明亮轻快,实际上也省了很多的内存,对此,JOOX后面对低端机型做了对应的优化。
3. 我们也对Bugly上的OOM进行了分析,发现其实有些OOM是可以避免的。
下面这个crash就是上面提到的在LsitView的adapter里不停创建bitmap,这个地方是我们的首页banner位,理论上App一打开就会缓存这张默认背景图片了,而实际在使用过一段时间后,才因为为了解码这张背景图而OOM, 改为用全局缓存解决。
下面这个就是传说中的内存抖动
实际代码如下,因为打Log而进行了字符串拼接,一旦这个函数被比较频繁地调用,那么就很有可能会发生内存抖动。这里我们新版本已经改为使用stringbuilder进行优化。
还有一些比较奇怪的情况,这里是我们扫描歌曲文件头的时候发生的,有些文件头居然有几百M大,导致一次申请了过大的内存,直接OOM,这里暂时也无法修复,直接catch住out of memory error。
4. 同时我们对一些逻辑代码进行调整,比如我们的App主页的第三个tab(Live tab)进行了数据延迟加载,和定时回收。
这里因为这个页面除了有大图还有轮播banner,实际强引用的图片会有多张,如果这个时候切到其他页面进行听歌等行为,这个页面一直在后台缓存,实际是很浪费耗内存的,同时为优化体验,我们又不能直接通过设置主页的viewpager的缓存页数,因为这样经常都会回收,导致影响体验,所以我们在页面不可见后过一段时间,清理掉adapter数据(只是清空adapter里的数据,实际从网络加载回来的数据还在,这里只是为了去掉界面对图片的引用),当页面再次显示时再用已经加载的数据显示,即减少了很多情况下图片的引用,也不影响体验。
5. 最后我们也遇到一个比较奇葩的问题,在我们的Bugly上报上有这样一条上报
我们在stackoverflow上看到了相关的讨论,大致意思是有些情况下比如息屏,或者一些省电模式下,频繁地调System.gc()可能会因为内核状态切换超时的异常。这个问题貌似没有比较好的解决方法,只能是优化内存,尽量减少手动调用System.gc()
优化结果
我们通过启动App后,切换到我的音乐界面,停留1分钟,多次gc后,获取App内存占用
优化前:
优化后:
多次试验结果都差不多,这里只截取了其中一次,有28M的优化效果。
当然不同的场景内存占用不同,同时上面试验结果是通过多次手动触发gc稳定后的结果。对于使用其他第三方工具不手动gc的情况下,试验结果可能会差异比较大。
对于上面提到的JOOX里各种图片背景等问题,我们做了动态的优化,对不同的机型进行优化,对特别低端的机型设置为纯色背景等方式,最终优化效果如下:
平均内存降低41M。
本次总结主要还是从图片方面下手,还有一点逻辑优化,已经基本达到优化目标。
评论
查看更多