Android 内存暴减的秘密?!

efux3752 3年前
   <h2>WeTest 导读</h2>    <p>在 <a href="/misc/goto?guid=4959756309250437105" rel="nofollow,noindex">我这样减少了26.5M Java内存!</a> 一文中内存优化一期已经告一段落,主要做的事情是,造了几个分析内存问题的轮子,定位进程各种类型内存占用情况,分析了线程创建OOM的原因。当然最重要的是,优化了一波进程静息态的内存占用(减少26M+)。而二期则是在一期的基础之上,推进已发现问题的SDK解决问题,最终要的是要优化进程的动态Java内存占用!</p>    <p>通常来说不管是做什么性能优化,逃不出性能优化3步曲:</p>    <ol>     <li>找到性能瓶颈</li>     <li>分析优化方案</li>     <li>执行优化</li>    </ol>    <p>上述三步看似第三步最能决定优化结果,而事实上,从笔者的几次性能优化经历来看,找到瓶颈确占据了绝对的影响力!</p>    <p>● 能否找到瓶颈意味着优化做不做的下去。</p>    <p>● 找到的瓶颈性能越差意味着优化效果越明显。</p>    <p>● 找到的瓶颈越多同样意味着优化效果越好。</p>    <h2>一、如何找瓶颈所在</h2>    <p>在分析方法上,主要:</p>    <p>● 分析代码逻辑,检查有问题的逻辑,对其进行相关优化。</p>    <p>● 模拟用户操作 在内存占用较高的时候dump内存,使用MAT分析</p>    <p>● 然后是分析HeapDump的方法</p>    <ol>     <li>看 DominatorTree,确定占用内存最多的实例</li>     <li>通过 GC root辅助分析内存占用的来源</li>     <li>通过 RetainHeapSize 量化的分析内存占用</li>    </ol>    <p>动态内存优化比静态要更难,其难点在于动态二字之上。动态不仅是的查找瓶颈变得困难,也使得对比优化成果不显而易见。而不同的环境、操作路径、设备、使用习惯等各个因素都有可能导致内存占用的不同。可能的情况是:找到的性能瓶颈和用户实际操作的方式不同,导致不能解决外网的OOM。因此直接获取手机用户的真实数据则是最行之有效的一种方式。</p>    <p>因此辅助采取了另一种方式, 收集真实的用户数据。</p>    <p>● 在手机发生OOM的时候dump内存,上传到后台,以便后续分析</p>    <p>措施1:可以优化现有代码逻辑,针对内存占用过多/不合理的场景进行优化。这是主场景。</p>    <p>措施2:主要分析外网用户的使用习惯下,发生OOM的场景。比较容易发现bug类问题导致瞬间内存占用过多的场景。</p>    <h2>二、找到哪些瓶颈</h2>    <p>找到的瓶颈问题很多,稍微按照分类梳理一下:</p>    <p><strong>1. 加载进内存,实际上没用到(还没用到)的数据</strong></p>    <p>1)PullToRefreshListView 的 Loading 和 Empty View lazyLoad,这是下拉刷新的组件,其下拉刷新有一个帧动画,图片较多,占用较多内存。</p>    <p>2)Minibar PlayListView。每个页面都会有一个Minibar,但是不一定Minibar都会打开播放列表。</p>    <p>3)AsyncImageView 的 默认图和失败图以Drawble的形式直接加载进内存的。</p>    <p><strong>2、 UI 相关数据,未及时释放</strong></p>    <p>1)24 小时直播间数据,只在节目切换的时候才有用</p>    <p>2)弹幕,只在播放页展示弹幕的时候才有用</p>    <p>3)播放页 TransitionBackgroundManager 大图内存占用问题 。这个一个大图,为了做渐变动画。</p>    <p><strong>3、数据结构不合理,占用内存过多</strong></p>    <p>1)播放历史最多记录600个节目信息,每一个ShowInfo占用内存多达22K(通过MAT查看RetainHeap)</p>    <p>2)下载管理会在内存中存储用户下载的 节目信息,歌词,专辑信息,分别占用内存 12K, 0-10K, 12K。并且这里没有数量限制。</p>    <p><strong>4、 图片占用内存过多</strong></p>    <p>1)在应用主页操作一下,发现图片(Bitmap)占用的内存很多</p>    <p>2)高斯模糊图片。</p>    <p><strong>5、 bug类导致内存占用过多</strong></p>    <p>播放历史应为代码逻辑bug,导致没有控制记录数量上限。于是用户听的节目越多内存占用就越大。这里的问题主要通过OOM上报发现,占用内存最多的一次上报,仅播放历史记录就占内存50M之多。</p>    <p>上述 1-4 点通过措施1主动检查内存发现。而第5点则是在分析了OOM上报“意外”发现的,如果是通过措施1的方式,几乎不可能知道这么多OOM竟然是因为这个问题引起的。</p>    <h2>三、怎么优化瓶颈</h2>    <p>找到问题之后,剩下的就是比较好做的了,只需顺藤摸瓜,各个击破!</p>    <p><strong>1、懒加载 (LazyLoad)</strong></p>    <p>针对上面的1.1, 1.2, 都可以做LazyLoad,真正需要下拉刷新/展示播放列表的时候再创建相关实例。</p>    <p>1.4 则可以在动画结束之后清理掉相关Bitmap</p>    <p>1.3 会复杂一点。图片加载组件可以提供default图,在图片加载过程中临时展示;以及faild图,在图片加载失败之后展示。这两个图在AsyncImageView中都是直接引用住图片 (Drawable)的。事实上绝大多数场景都会显示成功的图片。因此这里的修改方式是:</p>    <p>AsyncImageView的 default/fail 图片不再引用 drawable,而是引用资源ID,在需要的时候再由ImageLoader加载进内存,同时这些图片将有ImageCache统一管理,并占用内存LRU空间(之前是由Resource管理)。</p>    <p>这里去掉了几个大图的内存占用。内存占用在几M级别。</p>    <p><strong>2、及时释放</strong></p>    <p>上面 2.1 中的24小时直播间的数据会一直在内存中,即使用户当前没有在听24小时直播间。这个显然是不合理的。</p>    <p>修改的做法是 <strong>业务数据缓存的DB中,在需要用到的时候从DB中查询出来</strong></p>    <p>2.2 的弹幕则是纯粹的UI相关数据,在播放页退出之后即可释放了。</p>    <p>2.3 是为了动画准备的一张大图,为了做一个炫酷的动画效果。事实上,在动画结束之后,就可以释放了。这个图片占用的内存和手机分辨徐率相关,分辨率(严格来说是density)越高的手机,图片尺寸越大。在主流手机上1080p约1M。</p>    <p>这里分别减少了 287K + 512K + 1M</p>    <p><strong>3、 优化数据结构</strong></p>    <p>3.1 和 3.2 都会存储节目信息,而节目信息相关的jce结构都比较大,通过MAT,可以看到 Show:12K, Album:10K, 一个ShowInfo同时包含了上面两种数据结构。</p>    <p>最合理的方式应该是:</p>    <ol>     <li>数据存储在DB</li>     <li>在需要数据的时候通过一次db查询,拿到具体的数据。</li>    </ol>    <p>但是因为现有代码都是从内存中查询,接口是同步的方式,全部改异步的成本会比较大,这里我们的时间成本和测试自由都有限。</p>    <p>综合上面MAT分析的结果,有个思路:</p>    <p>内存中存储 节目信息 (ShowMeta)最少的内存,例如: 节目名,节目id,专辑id 之类的信息。而真正的Show和Album结构存在DB中。</p>    <p>这样内存中的数据可以尽量的少,同时大部分已有接口还可以保持同步调用的方式。</p>    <p>此外,从用户的角度出发,假设一个重度用户下载了1000个节目,那么每一个ShowMeta占用的内存都会被放大1000倍,因此载极限的优化ShowMeta都不为过。</p>    <p>这里做了两件事:</p>    <p>1. 删字段,把ShowMeta中的非必要字段删掉。</p>    <p>比如其中的url字段,实际只用来通过hash生成文件名,我们完全可以用showId代替。而一个url长度可达500Byte,1000个ShowMeta的话,这里就能节省500K内存了!</p>    <p>再比如:dowanloadTaskId字段,是存储下载任务的id的,在节目下载完成后,该字段即失去意义,因此可以删除之。</p>    <p>2、 intern 这里是参考了 String.intern 的思路。不同的ShowMeta可能会有相同的字段,或者说字段中有相同的部分。</p>    <p>比如同一个专辑中的ShowMeta其albumId字段都会是相同的,我们只需要保留一份albumId,其他ShowMeta都可以用同一个实例。(内存优化一期对ShowList做了同样的改造)</p>    <p>再比如:ShowMeta中会存储下载文件的全路径,而事实上所有节目都会存储在同一个文件目录中,因此这里把文件路径拆成 目录+文件名来存储,而路径采用 intern 的方式,保证了内存中只会有一份。</p>    <p><img src="https://simg.open-open.com/show/b1398ebc53be830f6f8655f364f2a6e4.png"></p>    <pre>  优化前</pre>    <p><img src="https://simg.open-open.com/show/50572fa4be24b1bba83e7a102a07d934.png"></p>    <pre>  优化后</pre>    <p>最直观的看变化是内存占用从 14272B 到 120B。仔细看会发现 ShowRecordMeta 的retainHeap 不等于各字段内存占用之和,这是因为上面提到的 String intern 的作用,相同字段被复用了,因此这里的retainheap不准确,通过RecordDataManager/countof(records) 计算,平均每一个record 14800/60 = 247B,减少98%。</p>    <p>这里的修改结果:</p>    <p>播放历史 ShowHistoryBiz -> ShowHistoryMeta 内存占用从 19k 到 约216B</p>    <p>下载记录 ShowRecordBiz -> ShowRecordMeta 内存占用 从 14k 到 约100B</p>    <p>粗略估计,这里修改的播放历史(每次播放都会增加一个记录,上限600个),(19256-216)* 600 = 10.9M</p>    <p>和下载记录(假设一个轻度使用用户用户下载100个节目),内存总共可以减少:</p>    <p>(14727-100)* 100 = 1.4M</p>    <p>如果是重度用户,下载1000个节目,则有14M之多!</p>    <p>不得不说这是个很大的数字!</p>    <h2>四、图片内存</h2>    <p>在Android 2.3 之后,Bitmap改了实现,图片内存从native heap转移到了Java heap。这就导致了JavaHeap占用暴增。(然而8.0又改成NativeHeap了,具体原因官方文档并没有提及,有待考察)。</p>    <p>通常我们分析 heap dump 的时候会发现Bitmap占用的内存是绝对的大头。这次我们做内存优化也不例外。</p>    <p>这里的思路是分析内存占用是否合理:</p>    <ol>     <li>是否所有图片都用于界面展示</li>     <li>是否图片尺寸过大。</li>    </ol>    <p>首先,分析内存占用是否合理。经过一期的优化,在不打开MainActivity的时候,内存中几乎没有图片。但是打开MainActivity之后,内存中会出现几十兆的图片内存。</p>    <p>图片内存主要是用于展示的,也即:被AsyncImageView持有的部分。</p>    <p>另外是内存的图片缓存,会持有 最大JavaHeap 1/8 的内存充当 Bitmap 缓存,使用LRU算法淘汰老数据。</p>    <p>当然另外一些图片过大属于使用不当,实际上可以裁剪才View实际的大小。</p>    <p>而一些全屏(和屏幕等宽的图,主要是Banner)图其实可以裁剪的更小一点(如3/4大小)减少近46%的内存占用,而观感不会有特别明显的区别。(写这个文档的时候突然想到的,TODO一下)。</p>    <p>问题1:针对AsyncImageView的问题,思考是否所有图片都在用户展示?</p>    <p>答案显然是否定的,一部分图片被ListView回收的view所持有,这些内存占用显然是不合理的。</p>    <p>问题2:另外就是ViewPager这种多页面视图,给用户展示的实际上只有一个,其他几个视图并没有在展示,因此这里是否可以改造ViewPager呢?</p>    <p>针对第一个问题,被ListView回收的view仍然在内存中的问题,通过改造AsyncImageView,在View从windowdetach的时候,主动释放Bitmap,attach到Window的时候再次尝试加载图片。另外是多图滚动视图,这里的图片很大,因此占用内存也很多。因为历史原因之前使用的是Gallery,其有bug导致会额外引用住两个大图(已经不可见),因此这里使用RecyclerView修改了其实现,解决上述问题。</p>    <p>针对第二个问题,目前还没有采取有效措施,主要依赖Android系统,主动回收Activity的内存。(这里存疑,需要深挖系统代码,理清理逻辑之后再下结论。短期的结论是:系统的清理行为不可靠)。如果要改的话,可以简单的修改一下ViewPager的内存,保证在其他page不可见的时候,回收其相关的Fragment。留个TODO。</p>    <p><strong>LRU + TTL</strong></p>    <p>针对图片缓存,这里本身只是缓存图片并且有LRU算法保证不会超过最大内存,理论上内存占用合理。但是LRU算法有一个问题,就是一旦缓存满了,后续只能通过添加新Bitmap才能淘汰掉老的Bitmap,而此时缓存占用的内存仍然是最大值。因此这里的思考是LRU+TTL算法:即在LRU的基础上,指定每一个Bitmap在缓存中存在是有效时长。超过时长之后主动将其从缓存中清理掉。这样我们就可以解决LRUcache占用的内存不可减少的问题。</p>    <p>再次感谢afc组件作者raezlu和笔者讨论问题,欣然接受建议,并身体力行的实现了TTL方案!</p>    <p><strong>高斯模糊</strong></p>    <p>这里补充一个,关于高斯模糊图片占用内存过高的问题,在之前版本已经优化过了。</p>    <p>因为高斯模糊的图片本身会让图片变得模糊(废话。。),因此图片的信息实质上是丢失了很大一部分的。在此思路的基础上,我们可以把需要高斯模糊的图片先缩小(比如 100x100),然后再做高斯模糊。这样不仅减少了内存占用,同时高斯模糊处理的速度也可以大大增加!</p>    <p>比如,之前遇到播放页封面cover图 720 <em>720的大小,占内存 720</em> 720 <em>4 = 2M,降低到 100x100 占用内存大小 100</em> 100 * 4= 40K,内存优化效果明显,而视觉上几乎没有差距。</p>    <h2>五、其他优化</h2>    <p>这里主要针对外网的TOP1 crash,WNS内部线程创建导致的OOM。</p>    <p>笔者的解决方案是先根据crash上报信息,深挖系统源码《 <a href="/misc/goto?guid=4959756309342182676" rel="nofollow,noindex">Android 创建线程源码与OOM分析</a> 》,彻底理清楚线程创建逻辑,并最终确定crash原因是线程的无节制创建。然后针对crash,整理出详细的原因分析,再给WNS的小伙伴提了bug,待修复之后替换sdk。</p>    <h2>六、成果对比</h2>    <p>内存优化的效果总体还不错,这里一共做了两期,优化了几十个项目。首先要比较感谢项目组给了可观的排期,这样才有时间做一些比较深入的改动。</p>    <p><strong>静息态内存</strong></p>    <p>一期优化效果是在Nexus6P@7.1上测试到的静息态内存优化 26.5M。</p>    <p>二期又进一步做了优化(上文3.2 3.3节),现在静息态内存再次dump会发现只有3M内存了,而这3M有一部分是播放列表,一部分是播放页持有的小图片。</p>    <p>通过计算,可以得出静息态内存进一步减少了:</p>    <p>24小时直播间单例: 287K</p>    <p>弹幕manager 单例: 512K</p>    <p>播放页动画大图:1M</p>    <p>播放历史 600个(上限):(19256-216) * 600 = 10.9M</p>    <p>下载记录 下载100个节目:(14727-100)* 100 = 1.4M</p>    <p>总共减少: 28M+</p>    <p><strong>动态内存</strong></p>    <p>动态内存比较不好对比,这里决定采用黑盒测试的方式:</p>    <p>打开应用,MainActivity各个tab操作一遍,打开播放页,然后对比内存占用量。鉴于笔者只有一台Nexus6P开发机,为了控制变量,这里创建了两台模拟器,并排摆放,分别打开企鹅FM4.0和3.9版本,确保使用相同的操作路径。</p>    <p>这里测试了两种场景:</p>    <ol>     <li>应用新安装</li>     <li>老用户,听了很多节目(播放历史600个),下载近200个节目</li>    </ol>    <p><img src="https://simg.open-open.com/show/8b0e61e775c98d9cc26baafb5dc02c2e.png"></p>    <pre>  experiment</pre>    <p>操作对照图</p>    <p>通过AndroidStudio查看内存占用情况。</p>    <p><img src="https://simg.open-open.com/show/57859324f810b28afcb235d3fe47d2a9.png"></p>    <pre>  compare clean install</pre>    <p>在场景一种:4.0版本占用 38.74M,而3.9版本占用 59.78M。减少了21.04M内存。</p>    <p>compare heavy use</p>    <p>在场景二中:4.0版本占用 45.5M,而3.9版本占用 87.4M。减少了41.9M内存。</p>    <p>事实上,因为有图片缓存在LRU算法的基础上增加了TTL逻辑,在静止1分钟之后(只要不再加载新图片),4.0版本,内存还会下降。(图片缓存超时主动清理)。</p>    <p><img src="https://simg.open-open.com/show/79b558b5c65ec7802eae27a7acb6cccf.png"></p>    <pre>  4.0 ImageCache TTL</pre>    <p>可以看到Java内存下降到 34.92M,而此时3.9版本仍然没有变化,此时内存减少 52.48M。</p>    <p>PS:需要注意的是3.9版本的“广播”tab在4.0版本替换成了“书城”tab,而书城tab的页面要远复杂的多,图片也更多。</p>    <p>最后,在4.0版本发布外网之后,笔者对比了一下3.9版本的Crash上报,结果如下:</p>    <p><img src="https://simg.open-open.com/show/9a697b65cf9c9014246c4f1ca74977b8.png"></p>    <p>总的crash率从 0.41%下降到%0.16,减少了0.21%。而OOM类型的crash率从 0.19%下降到 0.04%,减少了0.15%!而剩下的0.04%则主要是线程创建导致的。目前在通过线程监控组件查找根本原因,后续推动相关SDK进行优化!</p>    <h2>七、结论</h2>    <p>另外需要注意的一点是,动态内存和静态内存虽然分别减少了 52M 和 28M,但是两者是有一部分交集的。</p>    <p>两者的测量标准稍有不同,对应用的影响也不同。</p>    <p>动态内存主要优化app在低内存设备上的性能,并减少OutOfMemory发生的几率。</p>    <p>而静态内存,主要优化app退后台后的内存占用,一方面可以减少应用进程被Android系统的LowMemoryKiller杀死,另一方面可以让用户的设备有更多剩余内存,用户体验更好。</p>    <p>UPA——一款针对Unity游戏/产品的深度性能分析工具,由腾讯WeTest和unity官方共同研发打造,可以帮助游戏开发者快速定位性能问题。旨在为游戏开发者提供更完善的手游性能解决方案,同时与开发环节形成闭环,保障游戏品质。</p>    <p> </p>    <p> </p>    <p>来自:https://segmentfault.com/a/1190000012708312</p>    <p> </p>