44

Android 内存暴减的秘密?!

 6 years ago
source link: https://segmentfault.com/a/1190000012708312?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

作者:杨超,腾讯移动客户端开发 工程师
商业转载请联系腾讯WeTest获得授权,非商业转载请注明出处。
原文链接:http://wetest.qq.com/lab/view/362.html


WeTest 导读

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

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

  1. 找到性能瓶颈
  2. 分析优化方案

上述三步看似第三步最能决定优化结果,而事实上,从笔者的几次性能优化经历来看,找到瓶颈确占据了绝对的影响力!

● 能否找到瓶颈意味着优化做不做的下去。

● 找到的瓶颈性能越差意味着优化效果越明显。

● 找到的瓶颈越多同样意味着优化效果越好。


一、如何找瓶颈所在

在分析方法上,主要:

● 分析代码逻辑,检查有问题的逻辑,对其进行相关优化。

● 模拟用户操作 在内存占用较高的时候dump内存,使用MAT分析

● 然后是分析HeapDump的方法

  1. 看 DominatorTree,确定占用内存最多的实例
  2. 通过 GC root辅助分析内存占用的来源
  3. 通过 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。

六、成果对比

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

静息态内存

一期优化效果是在[email protected]上测试到的静息态内存优化 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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK