iOS内存泄漏检查&原理
前面罗列了iOS中常见的会导致内存泄漏的场景, 这篇文章主要说一下内存泄漏的常见检测方式和原理.
1 内存分类
要想检查内存泄漏, 首先我们要了解一个 app 的内存分类. 苹果的开发者文档里可以看到,一个 app 的内存分三类:
- Leaked memory: Memory unreferenced by your application that cannot be used again or freed (also detectable by using the Leaks instrument).
- Abandoned memory: Memory still referenced by your application that has no useful purpose.
- Cached memory: Memory still referenced by your application that might be used again for better performance.
Leaked memory 和 Abandoned memory 都属于应该释放而没释放的内存, 都是内存泄露.
2 常见的检测内存泄漏的手段
2.2 系统提供的检测手段
Leaked memory 可以用 Instrument 的 Leaks 检测出来. Leaks的实现思路是搜索所有可能包含指向malloc内存块指针的内存区域,比如全局数据内存块,寄存器和所有的栈。如果malloc内存块的地址被直接或者间接引用,则是reachable的,反之,则是leaks.
Abandoned memory,可以用 Instrument 的 Allocations 检测出来。检测方法是用 Mark Generation 的方式,当你每次点击 Mark Generation 时,Allocations 会生成当前 App 的内存快照,而且 Allocations 会记录从上回内存快照到这次内存快照这个时间段内,新分配的内存信息.
2.3 MSLeakHunter
MSLeakHunter原理很简单, 它只检测UIViewController和UIView,通过hook掉UIViewController的-viewDidDisappear
方法,并认为-viewDidDisappear
执行后,UIViewController会很快被释放,如果UIViewController没有被释放,则打个建议日志.
这种做法比较简单粗暴,只适合小场景,毕竟-viewDidDisappear
被调用可能是因为有push进来一个新的ViewController,把当前的ViewController挡住了,所以存在很多错误的建议日志,需要结合实际情况具体分析.
2.4 MLeaksFinder
MLeaksFinder是微信读书团队使用的内存泄漏检测工具. 他的主要原理如下.
- 当一个 UIViewController 被 pop 或 dismiss 后,该 UIViewController 包括它的 view,view 的 subviews 等等将很快被释放
- 在一个 ViewController 被 pop 或 dismiss 一小段时间后,看看该 UIViewController,它的 view,view 的 subviews 等等是否还存在
具体实现如下:
-
为基类 NSObject 添加一个方法 -willDealloc 方法,该方法的作用是,先用一个弱指针指向 self,并在一小段时间(3秒)后,通过这个弱指针调用 -assertNotDealloc,而 -assertNotDealloc 主要作用是直接中断言。
- (BOOL)willDealloc { __weak id weakSelf = self; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ [weakSelf assertNotDealloc]; }); return YES; } - (void)assertNotDealloc { NSAssert(NO, @“”); }
- 当我们认为某个对象应该要被释放了,在释放前调用这个方法,如果3秒后它被释放成功,weakSelf 就指向 nil,不会调用到 -assertNotDealloc 方法,也就不会中断言,如果它没被释放(泄露了),-assertNotDealloc 就会被调用中断言。这样,当一个 UIViewController 被 pop 或 dismiss 时(我们认为它应该要被释放了),我们遍历该 UIViewController 上的所有 view,依次调 -willDealloc,若3秒后没被释放,就会中断言
2.5 PLeakSniffer
PLeakSniffer, PLeakSniffer的核心监测思路是: 如果Controller被释放了,但其曾经持有过的子对象如果还存在,那么这些子对象就是泄漏的可疑目标.
子对象(比如view)建立一个对controller的weak引用,如果Controller被释放,这个weak引用也随之置为nil。那怎么知道子对象没有被释放呢?
通过Objective C的runtime机制,递归的将一个Controller所有强引用的property找出,并安装proxy监听Ping通知.用一个单例对象每个一小段时间发出一个ping通知去ping这个子对象,如果子对象还活着就会一个pong通知。所以结论就是:如果子对象的controller已不存在,但还能响应这个ping通知,那么这个对象就是可疑的泄漏对象.
2.6 FBMemoryProfiler
FBMemoryProfiler是Facebook开源的一个用于分析iOS内存使用和检测循环引用的工具库.
主要是通过runtime的两个方法, 来获取类中的哪些 ivar 是 strong 或是 weak,都未记录的就是基本类型和 __unsafe_unretained 的对象类型.
const char *class_getIvarLayout(Class cls)
const char *class_getWeakIvarLayout(Class cls)
把对象(包括 Block 对象)当成节点,以强引用为关系建立有向图,以深度优先遍历该有向图,寻找有向图中的环,一个环就代表一个循环引用.
关于这两个API的详细使用, 可以参考Objective-C Class Ivar Layout 探索, runtime使用篇: class_getIvarLayout 和 class_getWeakIvarLayout
2.7 OOMDetector
OOMDetector是手Q自研的IOS内存监控组件, 主要有爆内存堆栈统计和内存泄漏检测两个功能. 主要工作原理如下:
Hook iOS系统底层内存分配的相关方法(包括malloc_zone相关的堆内存分配以及vm_allocate对应的VM内存分配方法). 跟踪并记录进程中每个对象内存的分配信息,包括分配堆栈、累计分配次数、累计分配内存等,这些信息也会被缓存到进程内存中.
在程序可访问的进程内存空间中,是否有“指针变量”指向对应的内存块,那些在整个进程内存空间都没有指针指向的内存块,就是我们要找的泄漏内存块. 在iOS系统中,可能包含指针变量的内存区域有堆内存、栈内存、全局数据区和寄存器,OOMDetector 通过对这些区域遍历扫描即可找到所有可能的“指针变量”,整个扫描流程结束后都没有“指针变量”指向的内存块即是泄漏内存块.
为了避免内存访问冲突,扫描过程需要挂起所有线程,整个过程会卡住程序1-2秒
2.8 OOMDetector优化
- hook malloc / free 等16个内存管理函数,malloc 调用是非常频繁的,一旦 hook 后能形成非常高速的 malloc / free 流。
- 用个哈希表记录已经分配的内存块(key : 地址,value : (调用栈,块大小,计数器等等))
- 在hook后的 malloc / free 方法中能拿到申请和释放的地址。如果遇到 malloc 申请,向哈希表中插入一个key为该地址的元素。如果遇到 free 释放,在哈希表中删除一个key为该地址的元素。那么这个哈希表中就记录着当前进程中所有申请的内存块。
- 发起内存泄漏检测的时候,遍历内存中所有指针指向的地址,然后在哈希表中查,如果有该地址,那么对应的value的计数器加一,如果没有则跳过。遍历完了之后,查哈希表中所有元素的计数器,显然计数器为 0 的内存块就是泄漏的,没有一个指针指向他,因为如果有指针指向他,他的计数器会被加一。
发现泄漏后,把value中的调用栈,地址等等上报到后台,程序员根据调用栈就能找到相关代码进行泄漏修复。
3 HOOk
OC 的方法之所以可以 HOOK 是因为它的运行时特性,OC 的方法调用在底层都是 msg_send(id,SEL)的形式,这为我们提供了交换方法实现(IMP)的机会,但 C 函数在编译链接时就确定了函数指针的地址偏移量(Offset),这个偏移量在编译好的可执行文件中是固定的,而可执行文件每次被重新装载到内存中时被系统分配的起始地址(在 lldb 中用命令image List获取)是不断变化的.
既然 C 函数的指针地址是相对固定且不可修改的,那么 fishhook 又是怎么实现 对 C 函数的 HOOK 呢?其实内部/自定义的 C 函数 fishhook 也 HOOK 不了,它只能HOOK Mach-O 外部(共享缓存库中)的函数。fishhook 利用了 MachO 的动态绑定机制, 苹果的共享缓存库不会被编译进我们的 MachO 文件,而是在动态链接时才去重新绑定.
苹果采用了PIC(Position-independent code)技术成功让 C 的底层也能有动态的表现:
- 编译时在 Mach-O 文件 _DATA 段的符号表中为每一个被引用的系统 C 函数建立一个指针(8字节的数据,放的全是0),这个指针用于动态绑定时重定位到共享库中的函数实现。
- 在运行时当系统 C 函数被第一次调用时会动态绑定一次,然后将 Mach-O 中的 _DATA 段符号表中对应的指针,指向外部函数(其在共享库中的实际内存地址)。
fishhook 正是利用了 PIC 技术做了这么两个操作:
- 将指向系统方法(外部函数)的指针重新进行绑定指向内部函数/自定义 C 函数。
- 将内部函数的指针在动态链接时指向系统方法的地址。
这样就把系统方法与自己定义的方法进行了交换,达到 HOOK 系统 C 函数(共享库中的)的目的
fishHook的具体原理可以参考动态修改 C 语言函数的实现 和 fishhook的实现原理浅析两篇文章
参考资料:
1.iOS内存深入探索之Leaks
2.iOS 线上内存泄漏检测方案与结果
3.【腾讯开源】iOS爆内存问题解决方案-OOMDetector组件
4.MLeaksFinder: 精准 iOS 内存泄露检测工具
5.iOS内存泄漏自动检测盘点
6.iOS内存泄漏自动检测工具PLeakSniffer
7.FBRetainCycleDetector解析——获取一般对象的Strong成员变量