云音乐 Android 内存监控探索篇

语言: CN / TW / HK

图片来自:http://unsplash.com 本文作者: zgy

背景

随着云音乐不断的对线上崩溃治理,目前崩溃率已经达到了行业内较低水平。但线上还存在很多 OOM 的崩溃,这种崩溃大多是因为编码不规范导致的内存异常问题(比如内存泄露、大对象、大图等不合理的内存使用)。内存问题难发现、难复现和难排查。这就需要我们通过一些监控手段和一些工具去协助开发人员更好的排查此类问题。 接下来就是云音乐在内存监控方面的一些探索和实践,主要从以下几个方面介绍

概览图

内存泄露监控

谈到内存问题,我们最先想到的应该就是内存泄露。简单来说内存泄露就是某些不再使用的对象被其他生命周期更长的 GC Root 直接或者间接以强引用的方式持有,导致内存不能及时释放,从而引发内存问题。

内存泄露容易增加应用内存峰值提高 OOM 的概率,属于错误型问题,同时也是相对比较容易监控的类型。但是对于业务同学一般开发任务比较重,开发过程中一般不太会主动去关注本地检测的泄露问题。这就需要我们去建立一套自动化工具监控内存泄露,并自动生成任务单派发到对应开发,从而推动开发人员像解决崩溃问题的流程一样解决 APP 中的泄露问题。

内存监控方案

首先说到内存泄露检测大家肯定都能想到 LeakCanary,Leakcanary 是 Square 开源的 Java 内存泄漏分析工具,主要用于开发阶段检测 Android 应用中常见的内存泄露。

LeakCanary 的优势是能给出可读性很好的性能检测结果,并且能给出一些常见的解决方案,所以相比其他本地分析工具( MAT 等)更加高效。 LeakCanary 的核心原理是主要通过 Android 生命周期的 api 来监听 activities 和 fragments 什么时候被销毁,被销毁的对象会被传递给一个 ObjectWatcher,它持有它们的弱引用,默认等待5秒后观察弱引用是否进入关联的引用队列,是则说明未发生泄露,否则说明可能发生泄漏。

LeakCanary 的核心流程如下:

流程图

Leakcanary 在测试环境能基本满足我们本地的泄露监控,但是由于 LeakCanary 本身检测会主动触发 GC 造成卡顿,并且默认直接使用的是 Debug.dumpHprofData(),在 Dump 的过程中会有较长时间的应用冻结时间,不太适合生产环境。

对于这点快手团队在开源框架 Koom 中提出了优化方案:它利用 Copy-on-write 机制 fork 子进程 dump Java Heap,解决了 dump 过程中 App 长时间冻结的问题。Koom 的核心原理是周期性查询 Java 堆内存、线程数、文件描述符数等资源占用情况,当连续多次触发设定的阈值或者突发性连续快速突破高阈值时,触发镜像采集,镜像采集采用虚拟机 supend->fork 虚拟机进程 -> 虚拟机 resume->dump 内存镜像的策略,同时基于 shark 执行镜像解析离线内存泄露判定与引用链查找,并生成分析报告。

Koom 的核心流程图如下:

Koom

经过对两个开源库的分析比对,为了做到更全面的监控,我们决定从线上线下两个维度来建立我们的监控系统,再结合我们的平台对分析出的内存泄漏、大对象等问题按照引用链自动聚合归因,并且按照聚合后的问题排序,后续通过自动建单的方式推动业务侧开发去解决问题。

整体流程:

流程图

线上我们建立一个相对严苛的条件(内存连续触顶、内存突增,线程数或者 FD 数连续几次达到阈值等条件,并且单个用户在一定周期内只会触发一次),当用户触发这些条件后,会 dump 内存生成 HPORF 文件,然后对 HPORF 文件进行分析,分析出内存泄露和大对象(大对象阈值通过线上配置可动态调整)等信息,同时分析大图占用以及图片总占用等信息,最后将分析的结果上报到后台服务。为了降低对线上用户的影响,前期我们暂时先不上传 HPORF 文件,后期再根据需要按照采样的方式上报裁剪后的 HPORF 文件。关于 shark 对 HPORF 文件的分析,网上都有较为详细的资料,这里就不展开了。

线下我们主要结合自动化测试以及在测试环境下,监控 Activity、Fragent 泄露数量达到一定阈值或者内存触顶等多种情况下触发 dump,并且会输出 HPORF 文件分析结果,同时上报到后台服务。

平台侧根据客户端上报的问题,将大数据问题完成聚合消费后,按照用户的泄露次数、影响用户数、平均内存泄露率等维度进行排序,后续可以通过自动化建单的方式,分发给对应的开发,从而推动业务侧解决。

目前我们主要支持以下对象的泄露:

  • 已经 destroyed 和 finished 的 activity
  • fragment manager 已经为空的 fragment
  • 已经 destroyed 的 window
  • 超过阈值大小的 bitmap
  • 超过阈值大小的基本类型数组
  • 超过阈值大小的对象个数的任意 class
  • 已经清理的 ViewModel 实例
  • 已经从 window manager 移除的 RootView

大图监控

我们都知道 Bitmap 一直是 Android App 总内存消耗占比最大的部分,在很多 java 或者 native 内存问题的背后都能看到不少很大的 Bitmap 的影子,所以大图治理是内存治理必不可少的一步,那么我们做内存监控也必然少不了大图监控。

针对大图监控,我们主要分为线上图片库加载的大图和本地资源大图的监控。

线上大图监控

目前我们主要是对网络加载的图片做了统一的监控, 由于我们业务加载图片都统一使用的是同一个图片框架,所以我们只需要在加载图片时判断加载的图片是否超过一定的阈值或者超过 view 的大小,超过则进行记录和上报。我们改造了当前的图片库,新增图片信息的获取从而回调给监控 sdk,可以拿到加载图片的宽、高、文件大小等信息,同时也获取当前 view 的大小,然后我们会对比当前 view 的图片大小或者图片占用内存是否达到一定的阈值(这里支持线上配置),最终上报到我们的监控平台。为了方便分析定位,并且减少性能消耗,我们在线上不会抓取堆栈信息,只会获取当前 view 的层级信息,为了防止 view 层级过大,我们只获取5层数据,目前来看当前的信息已经足够我们定位到当前的 view。同时我们也结合了自研的曙光埋点系统,算出当前 Oid 页面的大图率,这样也可以方便我们监控一些 p0 级页面的大图率。

本地图片资源监控

除了线上的大图,我们还会对本地的资源图片做一些把控,同时也能防止图片资源过大导致包体积快速增长的问题。具体实现是通过卡点流程去做一些本地资源的检测,通过插件在 mergeResources 任务后,遍历图片资源,搜集超过阈值的图片资源,输出一个列表,然后上报后台服务,通过自动建单的方式,找到对应的开发,在发版前修复掉。

流程

内存大小监控

除了发现监控泄露的问题和大图的问题,我们还需要建立一个内存大盘,以便我们能更好的了解当前 App 线上的内存占用问题,方便我们更好的监控 App 的内存使用情况。我们的内存大盘主要分为应用启动内存(Pss)和运行中内存(Pss)、Java 内存、线程等。

启动内存、运行内存和 Java 内存监控

我们发现在 App 启动的时候,如果遇到需要使用的内存过大,这时候在 App 侧会出现较大的体验问题,系统不断的回收内存,同时 App 执行启动,在内存不足的情况下启动会更慢,因此我们需要监控启动内存占用情况,来方便我们后续的内存治理。Android 系统中,需要我们关注两类内存的使用情况,物理内存和虚拟内存。通常我们使用 Android Memory Profiler 的方式查看 APP 的内存使用情况。

android

我们可以查看当前进程总内存占用、JavaHeap、NativeHeap,以及 Graphics、Stack、Code 等细分类型的内存分配情况。那么我们需要线上运行时获取这些内存数据该怎么获取呢?这里我们主要通过获得所有进程的 Debug.MemoryInfo 数据(注意:这个接口在低端机型中可能耗时较久,不能在主线程中调用)。通过 Debug.MemoryInfo 的 getMemoryStat 方法(需要 23 版本及以上),我们可以获得等价于 Memory Profiler 默认视图中的多项数据,从而不断获取启动完成以及运行过程中细分内存使用情况。

应用启动完成时内存的获取我们结合了我们之前启动监控的完成时间节点来采集当前的内存情况。我们在启动时就会启动多个进程,根据我们之前的分析,APP 启动的时候如果需要使用内存越多,越容易导致 APP 启动出现问题。所以我们会统计所有进程的数据,为后续的进程治理做好铺垫。

运行内存则是每隔一段时间去异步获取当前内存的使用情况,同时也是获取整个应用所有进程的内存占用情况。我们把所有采集的数据上报到平台端,所有计算都在后台处理,这样可以做到灵活多变。后台可以计算出启动完成、运行中平均 PSS 等指标,它们可以反映整个 APP 内存的大概情况。

此外,我们还可以通过 RunTime 来获取 Java 内存。我们通过采集的数据计算出 Java 内存触顶(默认内存占用超过 85% 算触顶)的情况,再根据我们的平台的汇总算出一个触顶率,可以很好的反映 App 的 Java 内存的使用情况。一般如果超过 85% 最大堆限制,GC 会变得更加频繁,容易造成 OOM 和卡顿。因此 Java 触顶率是我们需要关注的一个很重要的指标。

在监控 Java 内存触顶的同时,我们在采集数据时也加了 Java 内存不足的回调。对于系统函数 onLowMemory 等函数是针对整个系统的内存回调,对于单进程来说,Java 内存的使用没有回调函数供我们及时释放内存。我们在做触顶的时候,刚好可以实时监控进程的堆内存使用率,达到阈值即可通知相关模块进行内存释放,这样也可以在一定程度上降低 OOM 的概率。

线程监控

除了由内存泄露或者申请大量内存导致的常见的 OOM 问题。我们也会遇到类似如下错误

Java java.lang.OutOfMemoryError: {CanCatch}{main} pthread_create (1040KB stack) failed: Out of memory

这里的原因大家应该都知道,根本原因是因为内存不足导致的,直接的原因是在创建线程时初始 stack size 的时候,分配不到内存导致的。这里就不具体去分析 pthread_create 的源码了。除了 vmsize 对最大线程数的限制外,在 linux 中对每个进程可创建的线程数也有一定的限制(/proc/pid/limits)而实际测试中,我们也发现不同厂商对这个限制也有所不同,而且当超过系统进程线程数限制时,同样会抛出这个类型的 OOM。这里特别指出的是华为的 emui 系统的某些机型,将最大线程数限制为 500 个。

为了了解我们当前的线程的使用情况,我们对云音乐的线程数进行了监控统计,线程数超过一定阈值时,将当前的线程信息上报平台。这里平台也计算出了一个线程触顶率,通过这个触顶率可以衡量我们整体的线程健康情况,也为我们后续收敛应用线程做好铺垫。

除此之外,我们还借鉴了 KOOM 对线程泄露做了监控,主要监控 native 线程的几个生命周期方法: pthread_create、 pthread_detach、 pthread_join、 pthread_exit。 hook 以上几个方法,用于记录线程生命周期和堆栈、名称等信息,当发现一个 joinable 的线程在没有 detach 或者 join 的情况下,执行了 pthread_exit,则记录下泄露线程信息,然后在合适的时机上报线程泄露。

总结

云音乐的内存监控相比业内起步较晚,所以可以站在巨人的肩膀上,结合云音乐现状做更适合我们当前场景下的监控和优化。内存监控是一个持续完善的课题,我们并不能一步到位的做完所有事情。更重要的是我们能持续发现问题,持续做精细化的监控,而不是一直对处于"对当前内存现状不了解,一边填坑又一边挖坑"的阶段。我们的目标是建立合理的平台为开发人员解决问题或者及时发现问题。当前云音乐内存监控还属于不断探索和不断完善的阶段,我们还需要在未来的时间里配合开发人员不断的优化和迭代。

参考资料

  • http://github.com/square/leakcanary
  • http://github.com/KwaiAppTeam/KOOM
  • http://juejin.cn/post/7134728428003000356#heading-30
  • http://blog.yorek.xyz/android/paid/master/memory_2/#_1

本文发布自网易云音乐技术团队,文章未经授权禁止任何形式的转载。我们常年招收各类技术岗位,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 grp.music-fe(at)corp.netease.com!