iOS微信内存监控

做者:杨津,腾讯移动客户端开发 高级工程师
商业转载请联系腾讯WeTest得到受权,非商业转载请注明出处。
原文连接:wetest.qq.com/lab/view/36…html


WeTest 导读

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

FOOM(Foreground Out Of Memory),是指App在前台因消耗内存过多引发系统强杀。对用户而言,表现跟crash同样。Facebook早在2015年8月提出FOOM检测办法,大体原理是排除各类状况后,剩余的状况是FOOM,具体连接:code.facebook.com/posts/11469…sql


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


1、实现原理

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


不过这方案有很多缺点:微信

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

二、打log间隔很差控制,间隔过长可能丢失中间峰值状况,间隔太短会引发耗电、io频繁等性能问题;ide

三、上报的原始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%开启。本地最多只保留最近三次数据。


2、下降误判

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



1.App没有升级

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

3.App没有出现crash

4.用户没有强退App

5.系统没有升级/重启

6.App当时没有后台运行

7.App出现FOOM


一、二、四、5比较容易判断,3依赖于自身CrashReport组件的crash回调,六、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前台卡死致使重启”,列入重点关注。


3、成果

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




4、常见问题

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腾讯质量开放平台上能够在线使用。点击wetest.qq.com/product/ios 便可当即体验!

相关文章
相关标签/搜索