如何优雅地检测内存泄漏?

1,357 阅读8分钟

👉 JCLeaksFinder

TL;DR

JCLeaksFinder 组件对以上功能进行了统一封装和接口优化,一行代码即可实现内存泄漏检测,欢迎使用!

[JCLeaksConfig sharedInstance].callback = ^(NSObject * _Nonnull leakedObject, NSSet * _Nonnull retainInfo, NSArray<NSString *> * _Nonnull viewStack) {
    // show alert or do something
};

同时,JCLeaksFinder 也支持丰富的自定义配置。

interface JCLeaksConfig : NSObject

+ (JCLeaksConfig *)sharedInstance;

/// 内存泄漏检测结果回调
/// leakedObject -> 泄漏对象
/// retainInfo -> 引用链信息,可能包含多个。不用关心`retainInfo`的具体数据,直接调用`[retainInfo description]`输出结果即可。
/// viewStack -> 泄漏对象层级信息
@property(nonatomic, copy) JCLeaksFinderCallback callback;

/// 检测阈值,默认为5s。退出页面`detectThresholdInSeconds`秒后开始检测是否有内存泄漏。
@property(nonatomic, assign) NSUInteger detectThresholdInSeconds;

/// 检测循环引用的最大引用链长度,默认为`10`。
@property(nonatomic, assign) NSUInteger retainCycleMaxLength;

/// 检测全局对象引用的最大引用链长度,默认为`15`。
@property(nonatomic, assign) NSUInteger globalRetainMaxLength;

/// 是否检测全局对象引用,默认为`YES`。检测全局对象引用耗时较高(约2-3s),在子线程进行
@property(nonatomic, assign) BOOL checkGlobalRetain;

/// 添加自定义的全局对象,默认为`nil`。
/// 有些对象并不是全局对象,但是会在APP生命周期内一直存活,如APP的rootNavigationController、rootTabBarController等
/// 在检测进行全局对象时,会将 `extraGlobalObjects` 也作为全局对象进行引用检测
@property(nonatomic, copy) NSArray<NSObject *> *extraGlobalObjects;

/// 添加白名单类名
- (void)addClassNamesToWhiteList:(NSArray<NSString *> *)classNames;

/// 添加白名单对象。该对象不会被内部持有。
- (void)addObjectToWhiteList:(NSObject *)object;

@end

检测效果

JCLeaksFinder 会将泄漏对象、引用信息、View层级返回给业务层,业务层可自定义提示形式,比如弹出 Alert。

循环引用提示

全局对象引用提示

介绍

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

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

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

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

目标

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

总的来说,就是越自动化越好,信息越全越好。
因此,本文不会介绍如何使用 Xcode/Instrument 手动检测内存泄漏。

内存泄漏检测

本文仅介绍页面级别的内存泄漏检测,包括 ViewController 及其 View/Subviews

检测内存泄漏其实是一个很麻烦的问题。在文章开头的定义中我们知道,内存泄漏指的是无法释放不再使用的内存。那么哪些内存属于不再使用的内存呢?显然,如果没有具体的上下文信息,这个问题是无解的。

但是,在一些特定的场景下,我们可以推断出特定的对象属于不再使用的内存对象。比如,当页面退出后,我们有理由认为该页面(ViewController)以及该页面的 View 和所有 Subviews 都应该被销毁。因为在页面退出后,这些内存对象就没用了。

业界有很多检测页面内存泄漏的解决方案,比较为大家所熟知的就是 MLeaksFinder 了。
一句话概括 MLeaksFinder 的检测原理,就是在页面退出一段时间后检测该页面及相关 View 是否为空,如果不为空则说明可能出现了内存泄漏。具体原理本文就不再赘述了,大家可以自行了解。

接入 MLeaksFinder 后,在退出页面后如果检测到了内存泄漏,我们就可以输出如下信息:

2021-01-30 21:50:15.869024+0800 Example[15854:39962324] Possibly Memory Leak.
In case that JCRetainCycleViewController should not be dealloced, override -willDealloc in JCRetainCycleViewController by returning NO.
View-ViewController stack: (
    JCRetainCycleViewController
)

引用链获取

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

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

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

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

循环引用链

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

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

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

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

2021-01-30 21:54:23.235166+0800 Example[20059:40002058] retain cycle: {(
        (
        "-> _customView -> JCLeaksCustomView ",
        "-> _block -> __NSMallocBlock__ ",
        "-> JCRetainCycleViewController "
    )
)}

全局对象引用链

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

如果是全局对象持有 ViewController/View ,那么当页面退出时,ViewController/View 无法被释放,MLeaksFinder 就会检测到内存泄漏。但是,此时并不存在 泄漏对象 -> 全局对象 的引用,只有 全局对象 -> 泄漏对象 的引用,因此并没有出现循环引用,无法使用 FBRetainCycleDetector 获取循环引用链。

这个问题的难点在于,我们很容易就能知道泄漏对象引用了哪些对象(向下查找),但是却无法知道 哪些对象引用了泄漏对象(向上查找)。
既然无法直接向上查找,我们就只有一条路可走了:找到所有的全局对象,然后 向下查找 其是否引用了泄漏对象。

获取所有全局对象

怎么找到所有全局对象呢?我们知道全局对象存储在 Mach-O 文件的 __DATA segment __bss section,那就暴力一点,把该section的所有指针都遍历出来吧!

关于 Mach-O 文件格式的详细信息,可参考 developer.apple.com/library/arc…

+ (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
	// ...
	
        // 获取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++) {
		// 过滤section
		// ...
                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;
        }
        // ...
    }
    return objectArray;
}

注意需要判断指针指向的是否为OC对象,如果不是合法的OC对象则需要过滤掉。此处参考 blog.timac.org/2016/1124-t…

输出引用链

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

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

等等,这不是和 FBRetainCycleDetector 的检测机制差不多吗?有没有办法复用 FBRetainCycleDetector 的检测逻辑呢?

好像不行,因为此时并没有出现循环引用?

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

- (void)checkLeakedObject:(NSObject *)leakedObject withGlobalObjects:(NSArray<NSObject *> *)globalObjects {
    // 如果leakedObject被全局对象持有,那么实际不存在循环引用链。这里人工设置associatedObject造成循环引用,以便被detector检测到。
    [FBAssociationManager hook];
    for (NSObject *obj in globalObjects) {
        objc_setAssociatedObject(leakedObject, $(@"jc_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, $(@"jc_apm_fake_%p", obj).UTF8String, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }
    [FBAssociationManager unhook];

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

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

2021-01-30 21:55:12.071707+0800 Example[20059:40002058] retain info: {(
        (
        "-> JCGlobalRetainViewController ",
        "-> [Global] -> __NSArrayM "
    )
)}

总结

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

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