组件介绍
OOMDetector是手Q自研的IOS内存监控组件,腾讯内部目前已有多个App接入了OOMDetector,它主要有以下两个功能:
- 爆内存堆栈统计:负责记录进程内存分配堆栈和内存块大小,在爆内存时Dump堆栈数据到磁盘
- 内存泄漏检测:检测内存泄漏,目前支持Malloc内存块和OC对象的泄漏检测
OOMDetector可以快速帮助开发者发现和定位App爆内存问题和内存泄漏,组件目前已经在Github开源,源码地址:https://github.com/Tencent/OO...。
背景
目前业内已有一些比较的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内存监控技术感兴趣,那么来了解下我们的组件吧!