内存优化一直是客户端性能优化的重要组成部分,内存泄漏又是内存问题的一大罪魁祸首,那么如何高效快速地检测并修复内存泄漏呢?本文介绍一种在开发阶段自动化检测页面(ViewController/View)内存泄漏问题的实践方案。
TLDR
- 使用 MLeaksFinder 等工具找到内存泄漏对象
- 使用 FBRetainCycleDetector 获取循环引用链
- (对于被全局对象引用的场景)获取全局对象引用链
介绍
所谓内存泄漏,就是程序已分配的内存由于某种原因未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。
一句话概括,就是无法释放不再使用的内存。
在iOS开发中最常遇到的内存泄漏类型有:
- 存在循环引用,导致对象无法释放
- 被全局对象(如单例)持有,导致对象无法释放
- (非ARC管理的对象)没有主动释放
本文主要介绍前两种内存泄漏的检测,第三种内存泄漏问题不在本文的讨论范围内。
目标
- 自动检测内存泄漏,及时告警
- 自动获取引用链,高效修复
总的来说,就是越自动化越好,信息越全越好。
因此,本文不会介绍如何使用 Xcode/Instrument 手动检测内存泄漏。
内存泄漏检测
本文仅介绍页面级别的内存泄漏检测,包括 ViewController 及其 View/Subviews。
检测内存泄漏其实是一个很麻烦的问题。在文章开头的定义中我们知道,内存泄漏指的是无法释放不再使用的内存。那么哪些内存属于不再使用的内存呢?显然,如果没有具体的上下文信息,这个问题是无解的。
所以内存泄漏就没法自动化检测了?
我们可以另辟蹊径。
在一些特定的场景下,我们可以推断出特定的对象属于不再使用的内存对象。比如,当页面退出后,我们有理由认为该页面(ViewController*)以及该页面的 *View 和所有 Subviews 都应该被销毁。因为在页面退出,这些内存对象就没用了。
业界有很多检测页面内存泄漏的解决方案,比较为大家所熟知的就是 MLeaksFinder 了。
一句话概括 MLeaksFinder 的检测原理,就是在页面退出一段时间后检测该页面及相关 View 是否为空,如果不为空则说明可能出现了内存泄漏。具体原理本文就不再赘述了,大家可以自行了解。
接入 MLeaksFinder 后,在退出页面后如果检测到了内存泄漏,我们就可以输出如下信息:
1 | [2020-12-5 19:19:06:759][❌][1277] *CJYAPMonitor.m:183: [APM] leaked: [CJYShareViewController, 0x13d57c850] |
引用链获取
现在我们知道出现了内存泄漏,也知道是哪个对象出现了内存泄漏,但是我们并不知道这个泄漏对象到底被谁引用了。也就是说,我们知道东西丢了,但是并不知道小偷是谁。如何抓到罪魁祸首呢?
如果不借助其他工具,我们只能
- 对着相关代码一行行看
- 重复出问题的场景,在 Xcode 的 Memory Graph 中定位该对象。
显然,这两种方案都不够优雅,费时费力,还不一定能找到问题。有没有办法自动获取泄漏对象的引用链呢?
循环引用链
FBRetainCycleDetector 是一个循环引用检测工具,主要原理是生成对象的引用关系图,然后进行深度优先遍历,如果发现了环的存在,则说明出现了循环引用。
1 | FBRetainCycleDetector *detector = [FBRetainCycleDetector new]; |
FBRetainCycleDetector 的最大问题,就是需要先提供待检测对象(candidate),也就是泄漏对象。泄漏对象如何获得呢?MLeaksFinder 已经帮我们找好了!
MLeaksFinder 负责找到泄漏对象,FBRetainCycleDetector 负责获取泄漏对象的循环引用链,完美!
1 | [2020-12-5 19:19:06:759][❌][1277] *CJYAPMonitor.m:183: [APM] leaked: [CJYAPShareViewController,0x13d57c850],retain info: |
全局对象引用链
循环引用场景的自动检测问题已经搞定了,被全局对象持有这个问题怎么解决呢?
如果是全局对象持有 ViewController/View ,那么当页面退出时,ViewController/View 无法被释放,MLeaksFinder 就会检测到内存泄漏。但是,此时并不存在 泄漏对象 -> 全局对象
的引用,只有 全局对象 -> 泄漏对象
的引用,因此并没有出现循环引用,无法使用 FBRetainCycleDetector 获取循环引用链。
这个问题的难点在于,我们很容易就能知道泄漏对象引用了哪些对象(向下查找),但是却无法知道 哪些对象引用了泄漏对象(向上查找)。
既然无法直接向上查找,我们就只有一条路可走了:找到所有的全局对象,然后 向下查找 其是否引用了泄漏对象。
获取所有全局对象
怎么找到所有全局对象呢?我们知道全局对象存储在 Mach-O
文件的 __DATA segment __bss section,那就暴力一点,把该section的所有指针都遍历出来吧!
关于 Mach-O 文件格式的详细信息,可参考 https://developer.apple.com/library/archive/documentation/Performance/Conceptual/CodeFootprint/Articles/MachOOverview.html
1 | + (NSArray<NSObject *> *)globalObjects { |
注意需要判断指针指向的是否为OC对象,如果不是合法的OC对象则需要过滤掉。此处参考 https://blog.timac.org/2016/1124-testing-if-an-arbitrary-pointer-is-a-valid-objective-c-object/
输出引用链
拿到所有全局对象后,接下来要做的就是找到 哪个全局对象引用了泄漏对象 。
怎么找呢?生成全局对象的引用关系图,然后进行深度优先遍历,如果发现了泄漏对象的存在,则说明该全局对象引用了泄漏对象。
等等,这不是和 FBRetainCycleDetector 的检测机制差不多吗?有没有办法复用 FBRetainCycleDetector 的检测逻辑呢?
好像不行,因为此时并没有出现循环引用?
秉着不重复造轮子的态度,我决定强行使用 FBRetainCycleDetector 这个轮子。没有循环引用,我就人工造一个循环引用出来!
1 | - (void)checkLeakedObject:(NSObject *)leakedObject withGlobalObjects:(NSArray<NSObject *> *)globalObjects { |
给泄漏对象 添加上对全局对象的引用 后,如果全局对象也引用了泄漏对象,那自然就出现循环引用了,也就能用 FBRetainCycleDetector 获取到引用链了。
最后再处理下检测结果,将添加的 __associated_object 换成 [Global] 进行输出,结果就非常清晰了。
1 | [2020-12-5 19:31:06:759][❌][1277] *CJYAPMonitor.m:183: [APM] leaked: [CJYAPShareViewController, 0x13d5733c4],retain info: |
总结
本文介绍了如何通过自动化工具进行页面级别的内存泄漏检测,并输出详细的循环引用和全局对象引用信息,方便开发者快速高效地发现并修复内存泄漏问题。
值得注意的是,内存泄漏的自动化检测必然存在False Positive
,也就是把不是内存泄漏的场景判定是内存泄漏。因为对象无论是被循环引用还是被全局对象引用,只要符合预期(对象还有用),那么就不应该被判定为内存泄漏。内存泄漏自动检测工具一般都会提供白名单机制,用于忽略不应该被判定为内存泄漏的场景。
iOS 内存泄漏检测工具已经在Github开源,欢迎关注!