0%

如何优雅地检测内存泄漏

内存优化一直是客户端性能优化的重要组成部分,内存泄漏又是内存问题的一大罪魁祸首,那么如何高效快速地检测并修复内存泄漏呢?本文介绍一种在开发阶段自动化检测页面(ViewController/View)内存泄漏问题的实践方案。

TLDR

  • 使用 MLeaksFinder 等工具找到内存泄漏对象
  • 使用 FBRetainCycleDetector 获取循环引用链
  • (对于被全局对象引用的场景)获取全局对象引用链

介绍

所谓内存泄漏,就是程序已分配的内存由于某种原因未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。
一句话概括,就是无法释放不再使用的内存

在iOS开发中最常遇到的内存泄漏类型有:

  1. 存在循环引用,导致对象无法释放
  2. 被全局对象(如单例)持有,导致对象无法释放
  3. (非ARC管理的对象)没有主动释放

本文主要介绍前两种内存泄漏的检测,第三种内存泄漏问题不在本文的讨论范围内。

目标

  1. 自动检测内存泄漏,及时告警
  2. 自动获取引用链,高效修复

总的来说,就是越自动化越好,信息越全越好。
因此,本文不会介绍如何使用 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]

引用链获取

现在我们知道出现了内存泄漏,也知道是哪个对象出现了内存泄漏,但是我们并不知道这个泄漏对象到底被谁引用了。也就是说,我们知道东西丢了,但是并不知道小偷是谁。如何抓到罪魁祸首呢?

如果不借助其他工具,我们只能

  • 对着相关代码一行行看
  • 重复出问题的场景,在 XcodeMemory Graph 中定位该对象。

显然,这两种方案都不够优雅,费时费力,还不一定能找到问题。有没有办法自动获取泄漏对象的引用链呢?

循环引用链

FBRetainCycleDetector 是一个循环引用检测工具,主要原理是生成对象的引用关系图,然后进行深度优先遍历,如果发现了环的存在,则说明出现了循环引用。

1
2
3
4
5
FBRetainCycleDetector *detector = [FBRetainCycleDetector new];
// 添加检测对象
[detector addCandidate:leakedObject];
// 检测循环引用
NSSet *result = [detector findRetainCycles];

FBRetainCycleDetector 的最大问题,就是需要先提供待检测对象(candidate),也就是泄漏对象。泄漏对象如何获得呢?MLeaksFinder 已经帮我们找好了!

MLeaksFinder 负责找到泄漏对象,FBRetainCycleDetector 负责获取泄漏对象的循环引用链,完美!

1
2
3
4
[2020-12-5 19:19:06:759][❌][1277] *CJYAPMonitor.m:183: [APM] leaked: [CJYAPShareViewController,0x13d57c850],retain info: 
-> CJYShareViewController
-> _view -> CJYLayoutView
-> _layoutSubviewsBlock -> __NSMallocBlock__

全局对象引用链

循环引用场景的自动检测问题已经搞定了,被全局对象持有这个问题怎么解决呢?

如果是全局对象持有 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
+ (NSArray<NSObject *> *)globalObjects {
NSMutableArray<NSObject *> *objectArray = [NSMutableArray array];
uint32_t count = _dyld_image_count();
for (uint32_t i = 0; i < count; i++) {
const mach_header_t *header = (const mach_header_t*)_dyld_get_image_header(i);

// 截取最后一段作为image_name,只针对CJYDemo进行处理
const char *image_name = strrchr(_dyld_get_image_name(i), '/');
if (image_name) {
image_name = image_name + 1;
}
if (strncmp(image_name, "CJYDemo", 6) != 0) {
continue;
}

// 获取image偏移量
vm_address_t slide = _dyld_get_image_vmaddr_slide(i);
long offset = (long)header + sizeof(mach_header_t);
for (uint32_t i = 0; i < header->ncmds; i++) {
const segment_command_t *segment = (const segment_command_t *)offset;
// 获取__DATA.__bss section的数据,即静态内存分配区
if (segment->cmd != SEGMENT_CMD_TYPE || strncmp(segment->segname, "__DATA", 6) != 0) {
offset += segment->cmdsize;
continue;
}
section_t *section = (section_t *)((char *)segment + sizeof(segment_command_t));
for (uint32_t j = 0; j < segment->nsects; j++) {
if ((strncmp(section->sectname, "__bss", 5) != 0)) {
section = (section_t *)((char *)section + sizeof(section_t));
continue;
}
// 遍历获取所有全局对象
vm_address_t begin = (vm_address_t)section->addr + slide;
vm_size_t size = (vm_size_t)section->size;
vm_size_t end = begin + size;
section = (section_t *)((char *)section + sizeof(section_t));

const uint32_t align_size = sizeof(void *);
if (align_size <= size) {
uint8_t *ptr_addr = (uint8_t *)begin;
for (uint64_t addr = begin; addr < end && ((end - addr) >= align_size); addr += align_size, ptr_addr += align_size) {
vm_address_t *dest_ptr = (vm_address_t *)ptr_addr;
uintptr_t pointee = (uintptr_t)(*dest_ptr);
// 省略判断指针是否指向OC对象的代码
// ...
// [objectArray addObject:(NSObject *)pointee];
}
}
}
offset += segment->cmdsize;
}
// 仅针对CJYDemo image执行一次,执行完直接break
break;
}
return objectArray;
}

注意需要判断指针指向的是否为OC对象,如果不是合法的OC对象则需要过滤掉。此处参考 https://blog.timac.org/2016/1124-testing-if-an-arbitrary-pointer-is-a-valid-objective-c-object/

输出引用链

拿到所有全局对象后,接下来要做的就是找到 哪个全局对象引用了泄漏对象

怎么找呢?生成全局对象的引用关系图,然后进行深度优先遍历,如果发现了泄漏对象的存在,则说明该全局对象引用了泄漏对象。

等等,这不是和 FBRetainCycleDetector 的检测机制差不多吗?有没有办法复用 FBRetainCycleDetector 的检测逻辑呢?
好像不行,因为此时并没有出现循环引用?

秉着不重复造轮子的态度,我决定强行使用 FBRetainCycleDetector 这个轮子。没有循环引用,我就人工造一个循环引用出来!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- (void)checkLeakedObject:(NSObject *)leakedObject withGlobalObjects:(NSArray<NSObject *> *)globalObjects {
// 如果leakedObject被全局对象持有,那么实际不存在循环引用链。这里人工设置associatedObject造成循环引用,以便被detector检测到。
[FBAssociationManager hook];
for (NSObject *obj in globalObjects) {
objc_setAssociatedObject(leakedObject, $(@"qn_apm_fake_%p", obj).UTF8String, obj, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

// 开始检测,并过滤无用数据
FBRetainCycleDetector *detector = [FBRetainCycleDetector new];
[detector addCandidate:leakedObject];
NSSet *result = [detector findRetainCycles];

// 此处省略过滤逻辑,因为全局对象本身可能就有循环引用,需要过滤出包含leakedObject的引用链
// filter...

// 移除人工设置的associatedObject
for (NSObject *obj in globalObjects) {
objc_setAssociatedObject(leakedObject, $(@"qn_apm_fake_%p", obj).UTF8String, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
[FBAssociationManager unhook];

给泄漏对象 添加上对全局对象的引用 后,如果全局对象也引用了泄漏对象,那自然就出现循环引用了,也就能用 FBRetainCycleDetector 获取到引用链了。

最后再处理下检测结果,将添加的 __associated_object 换成 [Global] 进行输出,结果就非常清晰了。

1
2
3
[2020-12-5 19:31:06:759][❌][1277] *CJYAPMonitor.m:183: [APM] leaked: [CJYAPShareViewController, 0x13d5733c4],retain info: 
[Global] -> CJYGlobalObject
-> _vc -> CJYShareViewController

总结

本文介绍了如何通过自动化工具进行页面级别的内存泄漏检测,并输出详细的循环引用和全局对象引用信息,方便开发者快速高效地发现并修复内存泄漏问题。

值得注意的是,内存泄漏的自动化检测必然存在False Positive,也就是把不是内存泄漏的场景判定是内存泄漏。因为对象无论是被循环引用还是被全局对象引用,只要符合预期(对象还有用),那么就不应该被判定为内存泄漏。内存泄漏自动检测工具一般都会提供白名单机制,用于忽略不应该被判定为内存泄漏的场景。

iOS 内存泄漏检测工具已经在Github开源,欢迎关注!