Android 内存暴减的秘密?!

133
原文链接: cloud.tencent.com

作者:杨超,腾讯移动客户端开发 工程师

商业转载请联系腾讯WeTest获得授权,非商业转载请注明出处。

原文链接:http://wetest.qq.com/lab/view/362.html

WeTest 导读

=========

我这样减少了26.5M Java内存! 一文中内存优化一期已经告一段落,主要做的事情是,造了几个分析内存问题的轮子,定位进程各种类型内存占用情况,分析了线程创建OOM的原因。当然最重要的是,优化了一波进程静息态的内存占用(减少26M+)。而二期则是在一期的基础之上,推进已发现问题的SDK解决问题,最终要的是要优化进程的动态Java内存占用!

通常来说不管是做什么性能优化,逃不出性能优化3步曲:

  1. 找到性能瓶颈
  2. 分析优化方案
  3. 执行优化上述三步看似第三步最能决定优化结果,而事实上,从笔者的几次性能优化经历来看,找到瓶颈确占据了绝对的影响力!● 能否找到瓶颈意味着优化做不做的下去。● 找到的瓶颈性能越差意味着优化效果越明显。● 找到的瓶颈越多同样意味着优化效果越好。一、如何找瓶颈所在在分析方法上,主要:● 分析代码逻辑,检查有问题的逻辑,对其进行相关优化。● 模拟用户操作 在内存占用较高的时候dump内存,使用MAT分析● 然后是分析HeapDump的方法
  4. 看 DominatorTree,确定占用内存最多的实例
  5. 通过 GC root辅助分析内存占用的来源
  6. 通过 RetainHeapSize 量化的分析内存占用

动态内存优化比静态要更难,其难点在于动态二字之上。动态不仅是的查找瓶颈变得困难,也使得对比优化成果不显而易见。而不同的环境、操作路径、设备、使用习惯等各个因素都有可能导致内存占用的不同。可能的情况是:找到的性能瓶颈和用户实际操作的方式不同,导致不能解决外网的OOM。因此直接获取手机用户的真实数据则是最行之有效的一种方式。

因此辅助采取了另一种方式, 收集真实的用户数据。

● 在手机发生OOM的时候dump内存,上传到后台,以便后续分析

措施1:可以优化现有代码逻辑,针对内存占用过多/不合理的场景进行优化。这是主场景。

措施2:主要分析外网用户的使用习惯下,发生OOM的场景。比较容易发现bug类问题导致瞬间内存占用过多的场景。

二、找到哪些瓶颈

找到的瓶颈问题很多,稍微按照分类梳理一下:

1. 加载进内存,实际上没用到(还没用到)的数据

1)PullToRefreshListView 的 Loading 和 Empty View lazyLoad,这是下拉刷新的组件,其下拉刷新有一个帧动画,图片较多,占用较多内存。

2)Minibar PlayListView。每个页面都会有一个Minibar,但是不一定Minibar都会打开播放列表。

3)AsyncImageView 的 默认图和失败图以Drawble的形式直接加载进内存的。

2、 UI 相关数据,未及时释放

1)24 小时直播间数据,只在节目切换的时候才有用

2)弹幕,只在播放页展示弹幕的时候才有用

3)播放页 TransitionBackgroundManager 大图内存占用问题 。这个一个大图,为了做渐变动画。

3、数据结构不合理,占用内存过多

1)播放历史最多记录600个节目信息,每一个ShowInfo占用内存多达22K(通过MAT查看RetainHeap)

2)下载管理会在内存中存储用户下载的 节目信息,歌词,专辑信息,分别占用内存 12K, 0-10K, 12K。并且这里没有数量限制。

4、 图片占用内存过多

1)在应用主页操作一下,发现图片(Bitmap)占用的内存很多

2)高斯模糊图片。

5、 bug类导致内存占用过多

播放历史应为代码逻辑bug,导致没有控制记录数量上限。于是用户听的节目越多内存占用就越大。这里的问题主要通过OOM上报发现,占用内存最多的一次上报,仅播放历史记录就占内存50M之多。

上述 1-4 点通过措施1主动检查内存发现。而第5点则是在分析了OOM上报“意外”发现的,如果是通过措施1的方式,几乎不可能知道这么多OOM竟然是因为这个问题引起的。

三、怎么优化瓶颈

找到问题之后,剩下的就是比较好做的了,只需顺藤摸瓜,各个击破!

1、懒加载 (LazyLoad)

针对上面的1.1, 1.2, 都可以做LazyLoad,真正需要下拉刷新/展示播放列表的时候再创建相关实例。

1.4 则可以在动画结束之后清理掉相关Bitmap

1.3 会复杂一点。图片加载组件可以提供default图,在图片加载过程中临时展示;以及faild图,在图片加载失败之后展示。这两个图在AsyncImageView中都是直接引用住图片 (Drawable)的。事实上绝大多数场景都会显示成功的图片。因此这里的修改方式是:

AsyncImageView的 default/fail 图片不再引用 drawable,而是引用资源ID,在需要的时候再由ImageLoader加载进内存,同时这些图片将有ImageCache统一管理,并占用内存LRU空间(之前是由Resource管理)。

这里去掉了几个大图的内存占用。内存占用在几M级别。

2、及时释放

上面 2.1 中的24小时直播间的数据会一直在内存中,即使用户当前没有在听24小时直播间。这个显然是不合理的。

修改的做法是业务数据缓存的DB中,在需要用到的时候从DB中查询出来

2.2 的弹幕则是纯粹的UI相关数据,在播放页退出之后即可释放了。

2.3 是为了动画准备的一张大图,为了做一个炫酷的动画效果。事实上,在动画结束之后,就可以释放了。这个图片占用的内存和手机分辨徐率相关,分辨率(严格来说是density)越高的手机,图片尺寸越大。在主流手机上1080p约1M。

这里分别减少了 287K + 512K + 1M

3、 优化数据结构

3.1 和 3.2 都会存储节目信息,而节目信息相关的jce结构都比较大,通过MAT,可以看到 Show:12K, Album:10K, 一个ShowInfo同时包含了上面两种数据结构。

最合理的方式应该是:

  1. 数据存储在DB
  2. 在需要数据的时候通过一次db查询,拿到具体的数据。

但是因为现有代码都是从内存中查询,接口是同步的方式,全部改异步的成本会比较大,这里我们的时间成本和测试自由都有限。

综合上面MAT分析的结果,有个思路:

内存中存储 节目信息 (ShowMeta)最少的内存,例如: 节目名,节目id,专辑id 之类的信息。而真正的Show和Album结构存在DB中。

这样内存中的数据可以尽量的少,同时大部分已有接口还可以保持同步调用的方式。

此外,从用户的角度出发,假设一个重度用户下载了1000个节目,那么每一个ShowMeta占用的内存都会被放大1000倍,因此载极限的优化ShowMeta都不为过。

这里做了两件事:

1. 删字段,把ShowMeta中的非必要字段删掉。

比如其中的url字段,实际只用来通过hash生成文件名,我们完全可以用showId代替。而一个url长度可达500Byte,1000个ShowMeta的话,这里就能节省500K内存了!

再比如:dowanloadTaskId字段,是存储下载任务的id的,在节目下载完成后,该字段即失去意义,因此可以删除之。

2、 intern 这里是参考了 String.intern 的思路。不同的ShowMeta可能会有相同的字段,或者说字段中有相同的部分。

比如同一个专辑中的ShowMeta其albumId字段都会是相同的,我们只需要保留一份albumId,其他ShowMeta都可以用同一个实例。(内存优化一期对ShowList做了同样的改造)

再比如:ShowMeta中会存储下载文件的全路径,而事实上所有节目都会存储在同一个文件目录中,因此这里把文件路径拆成 目录+文件名来存储,而路径采用 intern 的方式,保证了内存中只会有一份。

这里写图片描述
这里写图片描述
                       优化后

最直观的看变化是内存占用从 14272B 到 120B。仔细看会发现 ShowRecordMeta 的retainHeap 不等于各字段内存占用之和,这是因为上面提到的 String intern 的作用,相同字段被复用了,因此这里的retainheap不准确,通过RecordDataManager/countof(records) 计算,平均每一个record 14800/60 = 247B,减少98%。

这里的修改结果:

播放历史 ShowHistoryBiz -> ShowHistoryMeta 内存占用从 19k 到 约216B

下载记录 ShowRecordBiz -> ShowRecordMeta 内存占用 从 14k 到 约100B

粗略估计,这里修改的播放历史(每次播放都会增加一个记录,上限600个),(19256-216)* 600 = 10.9M

和下载记录(假设一个轻度使用用户用户下载100个节目),内存总共可以减少:

(14727-100)* 100 = 1.4M

如果是重度用户,下载1000个节目,则有14M之多!

不得不说这是个很大的数字!

四、图片内存

在Android 2.3 之后,Bitmap改了实现,图片内存从native heap转移到了Java heap。这就导致了JavaHeap占用暴增。(然而8.0又改成NativeHeap了,具体原因官方文档并没有提及,有待考察)。

通常我们分析 heap dump 的时候会发现Bitmap占用的内存是绝对的大头。这次我们做内存优化也不例外。

这里的思路是分析内存占用是否合理:

  1. 是否所有图片都用于界面展示
  2. 是否图片尺寸过大。

首先,分析内存占用是否合理。经过一期的优化,在不打开MainActivity的时候,内存中几乎没有图片。但是打开MainActivity之后,内存中会出现几十兆的图片内存。

图片内存主要是用于展示的,也即:被AsyncImageView持有的部分。

另外是内存的图片缓存,会持有 最大JavaHeap 1/8 的内存充当 Bitmap 缓存,使用LRU算法淘汰老数据。

当然另外一些图片过大属于使用不当,实际上可以裁剪才View实际的大小。

而一些全屏(和屏幕等宽的图,主要是Banner)图其实可以裁剪的更小一点(如3/4大小)减少近46%的内存占用,而观感不会有特别明显的区别。(写这个文档的时候突然想到的,TODO一下)。

问题1:针对AsyncImageView的问题,思考是否所有图片都在用户展示?

答案显然是否定的,一部分图片被ListView回收的view所持有,这些内存占用显然是不合理的。

问题2:另外就是ViewPager这种多页面视图,给用户展示的实际上只有一个,其他几个视图并没有在展示,因此这里是否可以改造ViewPager呢?

针对第一个问题,被ListView回收的view仍然在内存中的问题,通过改造AsyncImageView,在View从windowdetach的时候,主动释放Bitmap,attach到Window的时候再次尝试加载图片。另外是多图滚动视图,这里的图片很大,因此占用内存也很多。因为历史原因之前使用的是Gallery,其有bug导致会额外引用住两个大图(已经不可见),因此这里使用RecyclerView修改了其实现,解决上述问题。

针对第二个问题,目前还没有采取有效措施,主要依赖Android系统,主动回收Activity的内存。(这里存疑,需要深挖系统代码,理清理逻辑之后再下结论。短期的结论是:系统的清理行为不可靠)。如果要改的话,可以简单的修改一下ViewPager的内存,保证在其他page不可见的时候,回收其相关的Fragment。留个TODO。

LRU + TTL

针对图片缓存,这里本身只是缓存图片并且有LRU算法保证不会超过最大内存,理论上内存占用合理。但是LRU算法有一个问题,就是一旦缓存满了,后续只能通过添加新Bitmap才能淘汰掉老的Bitmap,而此时缓存占用的内存仍然是最大值。因此这里的思考是LRU+TTL算法:即在LRU的基础上,指定每一个Bitmap在缓存中存在是有效时长。超过时长之后主动将其从缓存中清理掉。这样我们就可以解决LRUcache占用的内存不可减少的问题。

再次感谢afc组件作者raezlu和笔者讨论问题,欣然接受建议,并身体力行的实现了TTL方案!

高斯模糊

这里补充一个,关于高斯模糊图片占用内存过高的问题,在之前版本已经优化过了。

因为高斯模糊的图片本身会让图片变得模糊(废话。。),因此图片的信息实质上是丢失了很大一部分的。在此思路的基础上,我们可以把需要高斯模糊的图片先缩小(比如 100x100),然后再做高斯模糊。这样不仅减少了内存占用,同时高斯模糊处理的速度也可以大大增加!

比如,之前遇到播放页封面cover图 720720的大小,占内存 720 720 4 = 2M,降低到 100x100 占用内存大小 100 100 * 4= 40K,内存优化效果明显,而视觉上几乎没有差距。

五、其他优化

这里主要针对外网的TOP1 crash,WNS内部线程创建导致的OOM。

笔者的解决方案是先根据crash上报信息,深挖系统源码《Android 创建线程源码与OOM分析》,彻底理清楚线程创建逻辑,并最终确定crash原因是线程的无节制创建。然后针对crash,整理出详细的原因分析,再给WNS的小伙伴提了bug,待修复之后替换sdk。

六、成果对比

内存优化的效果总体还不错,这里一共做了两期,优化了几十个项目。首先要比较感谢项目组给了可观的排期,这样才有时间做一些比较深入的改动。

静息态内存

一期优化效果是在Nexus6P@7.1上测试到的静息态内存优化 26.5M。

二期又进一步做了优化(上文3.2 3.3节),现在静息态内存再次dump会发现只有3M内存了,而这3M有一部分是播放列表,一部分是播放页持有的小图片。

通过计算,可以得出静息态内存进一步减少了:

24小时直播间单例: 287K

弹幕manager 单例: 512K

播放页动画大图:1M

播放历史 600个(上限):(19256-216) * 600 = 10.9M

下载记录 下载100个节目:(14727-100)* 100 = 1.4M

总共减少: 28M+

动态内存

动态内存比较不好对比,这里决定采用黑盒测试的方式:

打开应用,MainActivity各个tab操作一遍,打开播放页,然后对比内存占用量。鉴于笔者只有一台Nexus6P开发机,为了控制变量,这里创建了两台模拟器,并排摆放,分别打开企鹅FM4.0和3.9版本,确保使用相同的操作路径。

这里测试了两种场景:

  1. 应用新安装
  2. 老用户,听了很多节目(播放历史600个),下载近200个节目
这里写图片描述
                           experiment 

操作对照图

通过AndroidStudio查看内存占用情况。

这里写图片描述
                    compare clean install 

在场景一种:4.0版本占用 38.74M,而3.9版本占用 59.78M。减少了21.04M内存。

compare heavy use

在场景二中:4.0版本占用 45.5M,而3.9版本占用 87.4M。减少了41.9M内存。

事实上,因为有图片缓存在LRU算法的基础上增加了TTL逻辑,在静止1分钟之后(只要不再加载新图片),4.0版本,内存还会下降。(图片缓存超时主动清理)。

这里写图片描述
                  4.0 ImageCache TTL

可以看到Java内存下降到 34.92M,而此时3.9版本仍然没有变化,此时内存减少 52.48M。

PS:需要注意的是3.9版本的“广播”tab在4.0版本替换成了“书城”tab,而书城tab的页面要远复杂的多,图片也更多。

最后,在4.0版本发布外网之后,笔者对比了一下3.9版本的Crash上报,结果如下:

这里写图片描述

总的crash率从 0.41%下降到%0.16,减少了0.21%。而OOM类型的crash率从 0.19%下降到 0.04%,减少了0.15%!而剩下的0.04%则主要是线程创建导致的。目前在通过线程监控组件查找根本原因,后续推动相关SDK进行优化!

七、结论

另外需要注意的一点是,动态内存和静态内存虽然分别减少了 52M 和 28M,但是两者是有一部分交集的。

两者的测量标准稍有不同,对应用的影响也不同。

动态内存主要优化app在低内存设备上的性能,并减少OutOfMemory发生的几率。

而静态内存,主要优化app退后台后的内存占用,一方面可以减少应用进程被Android系统的LowMemoryKiller杀死,另一方面可以让用户的设备有更多剩余内存,用户体验更好。

UPA—— 一款针对Unity游戏/产品的深度性能分析工具,由腾讯WeTest和unity官方共同研发打造,可以帮助游戏开发者快速定位性能问题。旨在为游戏开发者提供更完善的手游性能解决方案,同时与开发环节形成闭环,保障游戏品质。

目前,限时内测正在开放中,点击http://wetest.qq.com/cube/ 即可使用。

对UPA感兴趣的开发者,欢迎加入QQ群:633065352

如果对使用当中有任何疑问,欢迎联系腾讯WeTest企业QQ:800024531