【腾讯开源】iOS爆内存问题解决方案-OOMDetector组件

组件介绍

OOMDetector是手Q自研的IOS内存监控组件,腾讯内部目前已有多个App接入了OOMDetector,它主要有如下两个功能:git

  • 爆内存堆栈统计:负责记录进程内存分配堆栈和内存块大小,在爆内存时Dump堆栈数据到磁盘github

  • 内存泄漏检测:检测内存泄漏,目前支持Malloc内存块和OC对象的泄漏检测算法

OOMDetector能够快速帮助开发者发现和定位App爆内存问题和内存泄漏,组件目前已经在Github开源,源码地址:https://github.com/Tencent/OOMDetector。缓存

背景

目前业内已有一些比较的IOS内存分析工具,下面逐个介绍这些工具的功能以及它们在使用上的不足。安全

Allocation

做为IOS开发,咱们都很熟悉苹果官方提供的Allocation内存分析工具,在开发调试阶段,能够用Allocation详细分析App各模块内存占用。Allocation对App的内存监控比较全面,能监控到全部堆内存以及部分VM内存分配。虽然Allocation的功能比较强大,可是它也有比较明显的使用局限性,主要表现为如下两点:服务器

  • 没法独立在App运行,只能在调试阶段链接Mac使用多线程

  • 性能较差,大型App开启后容易引起卡死函数

这两点限制决定了Allocation只适合于在开发阶段辅助分析代码中存在的内存问题,而没法直接对线上用户的问题进行监控和定位。工具

FBAllocationTracker

FBAllocationTracker是Facebook开源的内存分析工具,它的原理是用 Method Swizzling替换本来的alloc方法,这样能够在App运行时记录全部OC实例的分配信息,帮助App在运行阶段发现一些OC对象的异常增加问题。相比Allocation,FBAllocationTracker对App性能影响较低,能够在App中独立运行。可是这个工具也有比较明显的缺陷:性能

  • 监控范围不够全面,只能监控OC对象,不能监控C++对象和malloc内存块以及VM内存

  • 没有内存对象分配的堆栈信息,对于开发者来讲很难只经过对象的类型和数量定位到内存增加的缘由

综上所述,FBAllocationTracker虽然能独立在App中运行,可是监控的内存范围过小,同时记录的对象信息也过于简单,对于分析内存问题帮助十分有限。

内存问题一直是手Q的关注重点,为了保证线上大盘用户的内存质量,咱们但愿有一款工具可以帮助监控和定位线上用户的内存问题。基于这样的背景,咱们团队自研了OOMDetector组件。OOMDetector经过Hook系统底层的内存分配方法,可以记录到进程全部内存分配的堆栈信息,同时组件可以在对性能流畅度影响不大的状况下可以保证在App中独立运行,能够方便用于分析和监控线上用户的内存问题(爆内存或者内存泄漏问题)。

组件原理

爆内存堆栈统计

爆内存堆栈监控原理

爆内存堆栈监控的实现原理如图1所示,经过Hook IOS系统底层内存分配的相关方法(包括malloc_zone相关的堆内存分配以及vm_allocate对应的VM内存分配方法),跟踪并记录进程中每一个对象内存的分配信息,包括分配堆栈、累计分配次数、累计分配内存等,这些信息也会被缓存到进程内存中。在内存触顶的时候,组件会定时Dump这些堆栈信息到本地磁盘,这样若是程序爆内存了,就能够将爆内存前Dump的堆栈数据上报到后台服务器进行分析。

图1 爆内存监控原理

性能挑战

App的内存分配方法的调用频率很是高,在大型App中可能高达10W/次每秒。要Hook这类方法对组件的性能来讲是极大的挑战,由于若是组件自己耗时的话就很容易致使App卡顿甚至卡死。在OOMDetector中,咱们对Hook方法代码的执行效率进行了严格控制,也采起了一些策略对Hook方法中耗时较多的堆栈回溯和锁等待进行了优化:

  • 优化堆栈回溯方法

对于堆栈回溯,系统提供了backtrace_symbols方法能够直接获取堆栈信息,可是这个方法特别耗时。因此咱们根据堆栈的回溯原理实现了更高效的堆栈回溯方法,优化后的方法在运行时只会获取堆栈函数的地址信息,在回写磁盘的时候再根据动态库的地址范围拼装成如图2所示堆栈格式(相似Crash堆栈),后台服务器利用atos命令和符号表文件就能够还原出对应的堆栈内容。经过这种方式能够把耗时较高的符号还原工做放到服务器端,客户端只须要执行耗时较少的堆栈函数地址回溯操做,优化后的堆栈回溯方法耗时低于1us。

图2 堆栈格式

  • 优化锁等待耗时

对于多线程的内存分配,为了保证线程安全,堆栈数据的插入操做必需要上锁。对于这种高频调用的方法,锁的性能是咱们最关心的指标。IOS开发中NSLock和@synchronized是比较经常使用的,那么这两种锁的性能如何呢?

咱们经过测试代码对IOS中经常使用的锁进行了测试,总结了图2所示的各类锁的性能比较图,根据图3的测试结果,NSLock和@synchronized的性能要低于pthread_mutex,性能最好的是自旋锁OSSpinLock。

自旋锁的原理是,若是自旋锁已经被别的执行单元保持,调用者就一直循环等待锁的释放。相比互斥锁而言,自旋锁不会引发调用者休眠,节省了线程休眠的状态切换,因此有更高的效率,但代价是增长了cpu的使用率。对于咱们的场景,由于须要上锁部分的代码执行耗时较少,采用OSSpinLock的自旋锁并不会显著增长cpu的使用率,因此咱们优先考虑锁的效率采用了OSSpinLock的方案。

图3 各类锁的性能比较

堆栈聚类和压缩

以前提到,咱们的Hook方法会缓存每一个内存分配的堆栈数据。假设App的内存块个数为25W,堆栈平均深度20行,每一个堆栈地址采用8字节的整型数据存储,那么25W个堆栈数据将占用40M的内存空间。显然这样的内存增加对于任何App都是不可承受的,因此咱们须要对组件的内存占用进行优化。

咱们分析爆内存问题时候,只须要分析那些内存占用较大的堆栈,基本不用关心那些内存占用较小的堆栈。因此咱们的优化思路也很明确:只保留内存占用较大的堆栈。要完成这个工做就必须对内存中全部堆栈先进行聚类合并,统计出每一个堆栈累计的内存值。

具体的优化策略如图4所示,对于每一个记录到的分配堆栈,首先经过md5算法将堆栈数据压缩为16字节的md5,经过md5值进行聚类,缓存中只保留16字节的md5数据,只有当某个堆栈的累计内存超过必定阀值时,才会保留原始堆栈信息,这样由于超过阀值的堆栈数量有限,堆栈原始信息占用的空间几乎就能够忽略不计了。

图4 堆栈聚类和压缩原理

采用两种方式能够将堆栈下降到优化前的1/40左右,优化后的组件内存基本不会对App的内存形成太大影响。

数据Dump方案

前面提到,在内存触顶后要将内存中的堆栈数据定时Dump到磁盘中,常规的方案是IO接口直接把数据写入到磁盘。由于数据Dump的频率较高,频繁的IO操做会致使程序卡顿。由于数据Dump的操做是很是高频的,因此咱们采用了效率更高的mmap方式。

mmap是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间。实现这样的直接映射关系后,写文件的过程进程不会有额外的文件的数据拷贝操做,避免了内核空间和用户空间的频繁切换,如图5所示。根据咱们的代码实测,向mmap映射空间写数据的性能与直接写内存一致,效率远高于IO操做。

图5 内存映射原理

那么mmap的回写时机是怎样的?根据官方文档描述,主要有以下时机:

  • 系统内存不足时

  • 进程crash时

  • 主动调用 msync时

mmap 在内存不足时会主动进行回写操做,这样的机制也保证咱们的监控组件能在程序爆内存前将缓存中的数据回写到磁盘,从这一点看采用mmap的方式相比常规IO操做也有更强可靠性。

内存泄漏检测

除了爆内存堆栈监控,OOMDetector还集成了内存泄漏检测功能,可以检测Malloc内存块和OC对象的“无主内存泄漏”。所谓“无主内存泄漏”是指内存块在进程内已经没有引用却没法正常释放的内存块。

按照以前介绍的方案,OOMDetector能够记录到每个对象的分配堆栈信息,要从这些对象中找出 “泄漏对象”,咱们须要知道在程序可访问的进程内存空间中,是否有“指针变量”指向对应的内存块,那些在整个进程内存空间都没有指针指向的内存块,就是咱们要找的泄漏内存块。如图2所示,在IOS系统中,可能包含指针变量的内存区域有堆内存、栈内存、全局数据区和寄存器,OOMDetector 经过对这些区域遍历扫描便可找到全部可能的“指针变量”,整个扫描流程结束后都没有“指针变量”指向的内存块便是泄漏内存块。

为了不内存访问冲突,扫描过程须要挂起全部线程,整个过程会卡住程序1-2秒。由于扫描过程较为耗时,这个功能目前主要用于App的测试阶段,与自动化测试结合可快速高效的发现泄漏问题。

图6 内存泄漏检测原理

展望

开源只是开始,咱们后续仍会不断对OOMDetector组件进行改进,也欢迎你们对组件多提意见。若是你的IOS应用也在受到内存问题困扰或者你也对IOS内存监控技术感兴趣,那么来了解下咱们的组件吧!