作者:jerrychu 腾讯PCG客户端开发工程师
|导语 内存优化一直是客户端性能优化的重要组成部分,内存泄漏又是内存问题的一大罪魁祸首。如何高效快速地检测并修复内存泄漏问题呢?本文介绍一种在开发阶段自动化检测页面级别内存泄漏问题的实践方案。
TL;DR
- 使用 MLeaksFinder 找到内存泄漏对象
- 使用 FBRetainCycleDetector 获取循环引用链
- 使用 自研工具 获取全局对象引用链
QNLeaksFinder 组件对以上功能进行了统一封装和接口优化,一行代码即可实现内存泄漏检测,欢迎使用!
[QNLeaksConfig sharedInstance].callback = ^(NSObject * _Nonnull leakedObject, NSSet * _Nonnull retainInfo, NSArray<NSString *> * _Nonnull viewStack) {
// show alert or do something };
同时,QNLeaksFinder 也支持丰富的自定义配置。
interface QNLeaksConfig : NSObject
(QNLeaksConfig *)sharedInstance; /// 内存泄漏检测结果回调 /// leakedObject -> 泄漏对象 /// retainInfo -> 引用链信息,可能包含多个。不用关心`retainInfo`的具体数据,直接调用`[retainInfo description]`输出结果即可。 /// viewStack -> 泄漏对象层级信息 @property(nonatomic, copy) QNLeaksFinderCallback 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
检测效果
QNLeaksFinder 会将泄漏对象、引用信息、View层级返回给业务层,业务层可自定义提示形式。
新闻客户端采用弹出alert的形式。
介绍
所谓内存泄漏,就是程序已分配的内存由于某种原因未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。
一句话概括,就是无法释放不再使用的内存。
在iOS开发中最常遇到的内存泄漏类型有:
- 存在循环引用,导致对象无法释放
- 被全局对象(如单例)持有,导致对象无法释放
- (非ARC管理的对象)没有主动释放
本文主要介绍前两种内存泄漏的检测,第三种内存泄漏问题不在本文的讨论范围内。
目标
- 自动检测内存泄漏,及时告警
- 自动获取引用链,高效修复
总的来说,就是越自动化越好,信息越全越好。
因此,本文不会介绍如何使用 Xcode/Instrument 手动检测内存泄漏。
内存泄漏检测
本文仅介绍页面级别的内存泄漏检测,包括 ViewController 及其 View/Subviews。
检测内存泄漏其实是一个很麻烦的问题。在文章开头的定义中我们知道,内存泄漏指的是无法释放不再使用的内存。那么哪些内存属于不再使用的内存呢?显然,如果没有具体的上下文信息,这个问题是无解的。
但是,在一些特定的场景下,我们可以推断出特定的对象属于不再使用的内存对象。比如,当页面退出后,我们有理由认为该页面(ViewController)以及该页面的 View 和所有 Subviews 都应该被销毁。因为在页面退出后,这些内存对象就没用了。
业界有很多检测页面内存泄漏的解决方案,比较为大家所熟知的就是 MLeaksFinder 了。
一句话概括 MLeaksFinder 的检测原理,就是在页面退出一段时间后检测该页面及相关 View 是否为空,如果不为空则说明可能出现了内存泄漏。具体原理本文就不再赘述了,大家可以自行了解。
接入 MLeaksFinder 后,在退出页面后如果检测到了内存泄漏,我们就可以输出如下信息:
[2020-12-5 19:19:06:759][❌][1277] *QNAPMonitor.m:183: [APM] leaked: [QNShareViewController, 0x13d57c850]
引用链获取
现在我们知道出现了内存泄漏,也知道是哪个对象出现了内存泄漏,但是我们并不知道这个泄漏对象到底被谁引用了。也就是说,我们知道东西丢了,但是并不知道小偷是谁。如何抓到罪魁祸首呢?
如果不借助其他工具,我们只能
- 对着相关代码一行行看
- 重复出问题的场景,在 Xcode 的 Memory Graph 中定位该对象。
显然,这两种方案都不够优雅,费时费力,还不一定能找到问题。有没有办法自动获取泄漏对象的引用链呢?
循环引用链
FBRetainCycleDetector 是一个循环引用检测工具,主要原理是生成对象的引用关系图,然后进行深度优先遍历,如果发现了环的存在,则说明出现了循环引用。
FBRetainCycleDetector *detector = [FBRetainCycleDetector new];
// 添加检测对象 [detector addCandidate:leakedObject]; // 检测循环引用 NSSet *result = [detector findRetainCycles];
FBRetainCycleDetector 的最大问题,就是需要先提供待检测对象(candidate),也就是泄漏对象。泄漏对象如何获得呢?MLeaksFinder 已经帮我们找好了!
MLeaksFinder 负责找到泄漏对象,FBRetainCycleDetector 负责获取泄漏对象的循环引用链,完美!
[2020-12-5 19:19:06:759][❌][1277] *QNAPMonitor.m:183: [APM] leaked: [QNShareViewController,0x13d57c850],retain info:
-> QNShareViewController -> _view -> QNLayoutView -> _layoutSubviewsBlock -> __NSMallocBlock__
全局对象引用链
循环引用场景的自动检测问题已经搞定了,被全局对象持有这个问题怎么解决呢?
如果是全局对象持有 ViewController/View ,那么当页面退出时,ViewController/View 无法被释放,MLeaksFinder 就会检测到内存泄漏。但是,此时并不存在 泄漏对象 -> 全局对象 的引用,只有 全局对象 -> 泄漏对象 的引用,因此并没有出现循环引用,无法使用 FBRetainCycleDetector 获取循环引用链。
这个问题的难点在于,我们很容易就能知道泄漏对象引用了哪些对象(向下查找),但是却无法知道 哪些对象引用了泄漏对象(向上查找)。
既然无法直接向上查找,我们就只有一条路可走了:找到所有的全局对象,然后 向下查找 其是否引用了泄漏对象。
获取所有全局对象
怎么找到所有全局对象呢?我们知道全局对象存储在 Mach-O 文件的 __DATA segment __bss section,那就暴力一点,把该section的所有指针都遍历出来吧!
(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对象则需要过滤掉。此处参考 https://blog.timac.org/2016/1124-testing-if-an-arbitrary-pointer-is-a-valid-objective-c-object/
输出引用链
拿到所有全局对象后,接下来要做的就是找到 哪个全局对象引用了泄漏对象 。
怎么找呢?生成全局对象的引用关系图,然后进行深度优先遍历,如果发现了泄漏对象的存在,则说明该全局对象引用了泄漏对象。
等等,这不是和 FBRetainCycleDetector 的检测机制差不多吗?有没有办法复用 FBRetainCycleDetector 的检测逻辑呢?
好像不行,因为此时并没有出现循环引用?
秉着不重复造轮子的态度,我们决定强行使用 FBRetainCycleDetector 这个轮子。没有循环引用,我们就人工造一个循环引用出来!
- (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] 进行输出,结果就非常清晰了。
[2020-12-5 19:31:06:759][❌][1277] *QNAPMonitor.m:183: [APM] leaked: [QNShareViewController, 0x13d5733c4],retain info:
[Global] -> QNGlobalObject -> _vc -> QNShareViewController
总结
本文介绍了如何通过自动化工具进行页面级别的内存泄漏检测,并输出详细的循环引用和全局对象引用信息,方便开发者快速高效地发现并修复内存泄漏问题。
值得注意的是,内存泄漏的自动化检测必然存在False Positive,也就是把不是内存泄漏的场景判定是内存泄漏。因为对象无论是被循环引用还是被全局对象引用,只要符合预期(对象还有用),那么就不应该被判定为内存泄漏。内存泄漏自动检测工具一般都会提供白名单机制,用于忽略不应该被判定为内存泄漏的场景。
QNLeaksFinder 也提供了对应的配置,支持直接传入白名单 Class 或白名单对象。
近期热文
用“新”了解用户-数据赋能之路
【用研模型】价格敏感测试模型应用到内容研究中
eptest x优测:自动化测试的EPC之路
让我知道你在看