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

WeTest 导读

目前 iOS 主流的内存监控工具是 Instruments 的 Allocations,但只能用于开发阶段。本文介绍如何实现离线化的内存监控工具,用于 App 上线后发现内存问题。


FOOM(Foreground Out Of Memory),是指 App 在前台因消耗内存过多引起系统强杀。对用户而言,表现跟 crash 一样。Facebook 早在 2015 年 8 月提出 FOOM 检测办法,大致原理是排除各种情况后,剩余的情况是 FOOM,具体链接:https://code.facebook.com/posts/1146930688654547/reducing-fooms-in-the-facebook-ios-app/

微信自 15 年年底上线 FOOM 上报,从最初数据来看,每天 FOOM 次数与登录用户数比例接近 3%,同期 crash 率 1% 不到。而 16 年年初某东老大反馈微信频繁闪退,在艰难拉取 2G 多日志后,才发现 kv 上报频繁打 log 引起 FOOM。接着 16 年 8 月不少外部用户反馈微信启动不久后闪退,分析大量日志还是不能找到 FOOM 原因。微信急需一个有效的内存监控工具来发现问题。

一、实现原理

微信内存监控最初版本是使用 Facebook 的 FBAllocationTracker 工具监控 OC 对象分配,用 fishhook 工具 hook malloc/free 等接口监控堆内存分配,每隔 1 秒,把当前所有 OC 对象个数、TOP 200 最大堆内存及其分配堆栈,用文本 log 输出到本地。该方案实现简单,一天内完成,通过给用户下发 TestFlight,最终发现联系人模块因迁移 DB 加载大量联系人导致 FOOM。

不过这方案有不少缺点:

1、监控粒度不够细,像大量分配小内存引起的质变无法监控,另外 fishhook 只能 hook 自身 app 的 C 接口调用,对系统库不起作用;

2、打 log 间隔不好控制,间隔过长可能丢失中间峰值情况,间隔过短会引起耗电、io 频繁等性能问题;

3、上报的原始 log 靠人工分析,缺少好的页面工具展现和归类问题。

所以二期版本以 Instruments 的 Allocations 为参考,着重四个方面优化,分别是数据收集、存储、上报及展现。

1.数据收集

16 年 9 月底为了解决 ios10 nano crash,研究了 libmalloc 源码,无意中发现这几个接口:

当 malloc_logger 和__syscall_logger 函数指针不为空时,malloc/free、vm_allocate/vm_deallocate 等内存分配/释放通过这两个指针通知上层,这也是内存调试工具 malloc stack 的实现原理。有了这两个函数指针,我们很容易记录当前存活对象的内存分配信息(包括分配大小和分配堆栈)。分配堆栈可以用 backtrace 函数捕获,但捕获到的地址是虚拟内存地址,不能从符号表 dsym 解析符号。所以还要记录每个 image 加载时的偏移 slide,这样符号表地址=堆栈地址-slide。

另外为了更好的归类数据,每个内存对象应该有它所属的分类 Category,如上图所示。对于堆内存对象,它的 Category 名是 “Malloc ”+ 分配大小,如 “Malloc 48.00KiB”;对于虚拟内存对象,调用 vm_allocate 创建时,最后的参数 flags 代表它是哪类虚拟内存,而这个 flags 正对应于上述函数指针__syscall_logger 的第一个参数 type,每个 flag 具体含义可以在头文件找到;对于 OC 对象,它的 Category 名是 OC 类名,我们可以通过 hook OC 方法 +[NSObject alloc] 来获取:

但后来发现,NSData 创建对象的类静态方法没有调用 +[NSObject alloc],里面实现是调用 C 方法 NSAllocateObject 来创建对象,也就是说这类方式创建的 OC 对象无法通过 hook 来获取 OC 类名。最后在苹果开源代码 CF-1153.18 找到了答案,当CFOASafe=true 并且CFObjectAllocSetLastAllocEventNameFunction!=NULL 时,CoreFoundation 创建对象后通过这个函数指针告诉上层当前对象是什么类型:

通过上面方式,我们的监控数据来源基本跟 Allocations 一样了,当然是借助了私有 API。如果没有足够的 “技巧”,私有 API 带不上 Appstore,我们只能退而求其次。修改 malloc_default_zone 函数返回的 malloc_zone_t 结构体里的 malloc、free 等函数指针,也是可以监控堆内存分配,效果等同于 malloc_logger;而虚拟内存分配只能通过 fishhook 方式。

2.数据存储

存活对象管理

APP 在运行期间会大量申请/释放内存。以上图为例,微信启动 10 秒内,已经创建了 80 万对象,释放了 50 万,性能问题是个挑战。另外在存储过程中,也尽量减少内存申请/释放。所以放弃了 sqlite,改用了更轻量级的平衡二叉树来存储。

伸展树(Splay Tree),也叫分裂树,是一种二叉排序树,不保证树是平衡,但各种操作平均时间复杂度是 O(logN),可近似看作平衡二叉树。相比其他平衡二叉树(如红黑树),其内存占用较小,不需要存储额外信息。伸展树主要出发点是考虑到局部性原理(某个刚被访问的结点下次又被访问,或者访问次数多的结点下次可能被访问),为了使整个查找时间更少,被频繁查询的结点通过 “伸展” 操作搬移到离树根更近的地方。大部分情况下,内存申请很快又被释放,如 autoreleased 对象、临时变量等;而 OC 对象申请内存后紧接着会更新它所属 Category。所以用伸展树管理最适合不过了。

传统二叉树是用链表方式实现,每次添加/删除结点,都会申请/释放内存。为了减少内存操作,可以用数组实现二叉树。具体做法是父结点的左右孩子由以往的指针类型改成整数类型,代表孩子在数组的下标;删除结点时,被删除的结点存放上一个被释放的结点所在数组下标。

堆栈存储

据统计,微信运行期间,backtrace 的堆栈有成百万上千万种,在捕获最大栈长 64 情况下,平均栈长 35。如果 36bits 存储一个地址(armv8 最大虚拟内存地址 48bits,实际上 36bits 够用了),一个堆栈平均存储长度 157.5bytes,1M 个堆栈需要 157.5M 存储空间。但通过断点观察,实际上大部分堆栈是有共同后缀,例如下面的两个堆栈后 7 个地址是一样的:

为此,可以用 Hash Table 来存储这些堆栈。思路是整个堆栈以链表的方式插入到 table 里,链表结点存放当前地址和上一个地址所在 table 的索引。每插入一个地址,先计算它的 hash 值,作为在 table 的索引,如果索引对应的 slot 没有存储数据,就记录这个链表结点;如果有存储数据,并且数据跟链表结点一致,hash 命中,继续处理下一个地址;数据不一致,意味着 hash 冲突,需要重新计算 hash 值,直到满足存储条件。举个例子(简化了 hash 计算):

1)Stack1 的 G、F、E、D、C、A、依次插入到 Hash Table,索引 1~6 结点数据依次是 (G, 0)、(F, 1)、(E, 2)、(D, 3)、(C, 4)、(A, 5)。Stack1 索引入口是 6

2)轮到插入 Stack2,由于 G、F、E、D、C 结点数据跟 Stack1 前 5 结点一致,hash 命中;B 插入新的 7 号位置,(B, 5)。Stack2 索引入口是 7

3)最后插入 Stack3,G、F、E、D 结点 hash 命中;但由于 Stack3 的 A 的上一个地址 D 索引是 4,而不是已有的 (A, 5),hash 不命中,查找下一个空白位置 8,插入结点 (A, 4);B 上一个地址 A 索引是 8,而不是已有的 (B, 5),hash 不命中,查找下一个空白位置 9,插入结点 (B, 9)。Stack3 索引入口是 9

经过这样的后缀压缩存储,平均栈长由原来的 35 缩短到 5 不到。而每个结点存储长度为 64bits(36bits 存储地址,28bits 储存 parent 索引),hashTable 空间利用率 60%+,一个堆栈平均存储长度只需要 66.7bytes,压缩率高达 42%。

性能数据

经过上述优化,内存监控工具在 iPhone6Plus 运行占用 CPU 占用率 13% 不到,当然这是跟数据量有关,重度用户(如群过多、消息频繁等)可能占用率稍微偏高。而存储数据内存占用量 20M 左右,都用 mmap 方式把文件映射到内存。有关 mmap 好处可自行 google 之。

3.数据上报

由于内存监控是存储了当前所有存活对象的内存分配信息,数据量极大,所以当出现 FOOM 时,不可能全量上报,而是按某些规则有选择性的上报。

首先把所有对象按 Category 进行归类,统计每个 Category 的对象数和分配内存大小。这列表数据很少,可以做全量上报。接着对 Category 下所有相同堆栈做合并,计算每种堆栈的对象数和内存大小。对于某些 Category,如分配大小 TOP N,或者 UI 相关的(如 UIViewController、UIView 之类的),它里面分配大小 TOP M 的堆栈才做上报。上报格式类似这样:

4.页面展现

页面展现参考了 Allocations,可看出有哪些 Category,每个 Category 分配大小和对象数,某些 Category 还能看分配堆栈。

为了突出问题,提高解决问题效率,后台先根据规则找出可能引起 FOOM 的 Category(如上面的 Suspect Categories),规则有:

● UIViewController 数量是否异常

● UIView 数量是否异常

● UIImage 数量是否异常

● 其它 Category 分配大小是否异常,对象个数是否异常

接着对可疑的 Category 计算特征值,也就是 OOM 原因。特征值是由 “Caller1”、“Caller2” 和 “Category, Reason” 组成。Caller1 是指申请内存点,Caller2 是指具体场景或业务,它们都是从 Category 下分配大小第一的堆栈提取。Caller1 提取尽量是有意义的,并不是分配函数的上一地址。例如:

所有 report 计算出特征值后,可以对它们进行归类了。一级分类可以是 Caller1,也可以是 Category,二级分类是与 Caller1/Category 有关的特征聚合。效果如下:

一级分类

二级分类

5.运营策略

上面提到,内存监控会带来一定的性能损耗,同时上报的数据量每次大概 300K 左右,全量上报对后台有一定压力,所以对现网用户做抽样开启,灰度包用户/公司内部用户/白名单用户做 100% 开启。本地最多只保留最近三次数据。

二、降低误判

先回顾 Facebook 如何判定上一次启动是否出现 FOOM:

1.App 没有升级

2.App 没有调用 exit() 或 abort() 退出

3.App 没有出现 crash

4.用户没有强退 App

5.系统没有升级/重启

6.App 当时没有后台运行

7.App 出现 FOOM

1、2、4、5 比较容易判断,3 依赖于自身 CrashReport 组件的 crash 回调,6、7 依赖于 ApplicationState 和前后台切换通知。微信自上线 FOOM 数据上报以来,出现不少误判,主要情况有:

ApplicationState 不准

部分系统会在后台短暂唤起 app,ApplicationState 是 Active,但又不是 BackgroundFetch;执行完 didFinishLaunchingWithOptions 就退出了,也有收到 BecomeActive 通知,但很快也退出;整个启动过程持续 5~8 秒不等。解决方法是收到 BecomeActive 通知一秒后,才认为这次启动是正常的前台启动。这方法只能减少误判概率,并不能彻底解决。

群控类外挂

这类外挂是可以远程控制 iPhone 的软件,通常一台电脑可以控制多台手机,电脑画面和手机屏幕实时同步操作,如开启微信,自动加好友,发朋友圈,强制退出微信,这一过程容易产生误判。解决方法只能通过安全后台打击才能减少这类误判。

CrashReport 组件出现 crash 没有回调上层

微信曾经在 17 年 5 月底爆发大量 GIF crash,该 crash 由内存越界引起,但收到 crash 信号写 crashlog 时,由于内存池损坏,组件无法正常写 crashlog,甚至引起二次 crash;上层也无法收到 crash 通知,因此误判为 FOOM。目前改成不依赖 crash 回调,只要本地存在上一次 crashlog(不管是否完整),就认为是 crash 引起的 APP 重启。

前台卡死引起系统 watchdog 强杀

也就是常见的 0x8badf00d,通常原因是前台线程过多,死锁,或 CPU 使用率持续过高等,这类强杀无法被 App 捕获。为此我们结合了已有卡顿系统,当前台运行最后一刻有捕获到卡顿,我们认为这次启动是被 watchdog 强杀。同时我们从 FOOM 划分出新的重启原因叫 “APP 前台卡死导致重启”,列入重点关注。

三、成果

微信自 2017 年三月上线内存监控以来,解决了 30 多处大大小小内存问题,涉及到聊天、搜索、朋友圈等多个业务,FOOM 率由 17 年年初 3%,降到目前 0.67%,而前台卡死率由 0.6% 下降到 0.3%,效果特别明显。


四、常见问题

UIGraphicsEndImageContext

UIGraphicsBeginImageContext 和 UIGraphicsEndImageContext 必须成双出现,不然会造成 context 泄漏。另外 XCode 的 Analyze 也能扫出这类问题。

UIWebView

无论是打开网页,还是执行一段简单的 js 代码,UIWebView 都会占用 APP 大量内存。而 WKWebView 不仅有出色的渲染性能,而且它有自己独立进程,一些网页相关的内存消耗移到自身进程里,最适合取替 UIWebView。

autoreleasepool

通常 autoreleased 对象是在 runloop 结束时才释放。如果在循环里产生大量 autoreleased 对象,内存峰值会猛涨,甚至出现 OOM。适当的添加 autoreleasepool 能及时释放内存,降低峰值。

互相引用

比较容易出现互相引用的地方是 block 里使用了 self,而 self 又持有这个 block,只能通过代码规范来避免。另外 NSTimer 的 target、CAAnimation 的 delegate,是对 Obj 强引用。目前微信通过自己实现的 MMNoRetainTimer 和 MMDelegateCenter 来规避这类问题。

大图片处理

举个例子,以往图片缩放接口是这样写的:

但处理大分辨率图片时,往往容易出现 OOM,原因是-[UIImage drawInRect:] 在绘制时,先解码图片,再生成原始分辨率大小的 bitmap,这是很耗内存的。解决方法是使用更低层的 ImageIO 接口,避免中间 bitmap 产生:


大视图

大视图是指 View 的 size 过大,自身包含要渲染的内容。超长文本是微信里常见的炸群消息,通常几千甚至几万行。如果把它绘制到同一个 View 里,那将会消耗大量内存,同时造成严重卡顿。最好做法是把文本划分成多个 View 绘制,利用 TableView 的复用机制,减少不必要的渲染和内存占用。

推荐文章

最后推荐几个 iOS 内存相关的链接:

Memory Usage Performance Guidelines

No pressure, Mon!

腾讯 WeTest iOS 预审工具

为了提高 IEG 苹果审核通过率,腾讯专门成立了苹果审核测试团队,打造出 iOS 预审工具这款产品。经过 1 年半的内部运营,腾讯内部应用的 iOS 审核通过率从平均 35% 提升到 90%+。

现将腾讯内部产品的过审经验,以线上工具的形式共享给各位。在 WeTest 腾讯质量开放平台上可以在线使用。点击 http://wetest.qq.com/product/ios 即可立即体验!

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


iOS 预审服务

【扫描工具】上传 IPA 包、图片、视频、应用描述即可进行测试; 多维度自动扫描提审材料的被拒风险;1 小时内反馈全面的扫描报告。

【专家预审】腾讯专家为您遍历 App 所有功能模块;全面暴露 App 内容被拒风险;跟进问题直至上线(需提供官方拒绝邮件)。

【专家咨询】资深预审专家一对一服务; 咨询时间灵活可选,按需购买;有的放矢解 决审核问题。

【ASO 优化】专业团队多维度深度剖析 App 的 ASO 现状;围绕 App 目标用户群筛选高 度关联的关键词;帮助提升 App 在苹果应用商店中的曝光率。


↙↙↙阅读原文可查看相关链接,并与作者交流