iOS_Memory Leak 内存泄露治理

2023-10-18 14:52:58 浏览数 (2)

1、内存分类

官方文档介绍 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:app 没有引用的内存,无法再次使用或释放(可以使用 Leaks 工具检测)
  • Abandoned memory:app 仍有引用,但没有任何用途的内存
  • Cached memory:app 仍有引用,可能会再次使用以获得更好的性能

Leaked memoryAbandoned memory 都是应该释放而没释放的内存,属于内存泄露。

Leaked memory 可以用 InstrumentLeaks 检测出来。Leaks的实现思路是搜索所有可能包含指向 malloc 内存块指针的内存区域,比如全局数据内存块,寄存器和所有的栈。如果 malloc 内存块的地址被直接或者间接引用,则是 reachable 的,反之则是 leaks

Abandoned memory可以用 InstrumentAllocations 检测出来。检测方法是用 Mark Generation 的方式,当每次点击 Mark Generation 时,Allocations 会生成当前 App 的内存快照,而且 Allocations 会记录从上回内存快照到这次内存快照这个时间段内,新分配的内存信息.


2、Memory Report

Xcode 运行项目时,切换到 Debug navigator 点击 memory 就可以查看 Memory Report,显示 内存使用 的整体情况:

用于定位内存泄露的话用处不大,只能看到内存的概况。


3、Analyze

静态分析入口:

分析案例:

缺陷:只能检查编译时的内存泄漏,并不能检测到所有的内存泄漏,如:发生在运行时,或需要用户操作时产生的泄露。


4、Leaks

4.1、前置设置

首先,修改编译设置生成符号信息,以便 Leaks 分析出调用堆栈函数符号:

Target -> Build Settings -> Build Options -> Debug Information Format -> Debug -> DWAPR with dSYM File

否则 Leaks 无法解析调用堆栈函数名:

no stack trace is available for this leak; it may have been allocated before the Allocation instrument was attached

用 Xcode 把 app 跑起来。打开Leaks:

入口在菜单栏:Xcode -> Open Developer Tool -> Instruments -> 然后选择 Leaks -> Choose (打开操作面板)

4.2、页面介绍

步骤1:选好设备和需要测试的 app

步骤2:点击同行最左边的红色按钮,开始录制(点击开始录制会重启 app)

录制过程中:

  • 左边按钮是停止,右边按钮是暂停:
  • 右侧会出现3种标志: 绿色:没有发现泄露 红色:发现新的泄露 灰色:没有发现新的泄露

4.3、使用

4.3.1、Leaks 页面

默认选择的是 Lesks 页面,下半部分显示的是泄露的详情,左边是目前为止检测到的所有泄露;选中其中一个,右侧显示的是泄露点的调用堆栈,可据此找到泄露点进行修改。

底部栏:

  • snapshots,可以设置检测泄露的时间间隔,也有立即检测按钮:
  • Input Filter可通过线程过滤
  • Detail Filter可通过关键字过滤

也可选择时间段过滤:在起始时间点按下鼠标左键,拖动到截止时间点松开:

4.3.2、Cycles & Roots页面

点击中间栏的左侧切换到Cycles & Roots页面,可查看泄露图:

看图分析应该是因为block导致的循环引用,按调用堆栈找到对应的代码:

4.3.3、Call Tree页面

点击中间栏的左侧切换到Call Tree统计模式,也可通过底部栏的工具进行过滤

Separate By Thread:线程分离,在调用路径中能够清晰看到占用内存最大的线程

Invert Call Tree:反转调用堆栈顺序

Hide System Libraries:隐藏系统库的调用堆栈信息

Flatten Recursion:会将调用栈里递归函数作为一个入口(很少使用)

底部栏可设置各种约束进行过滤(用的比较少):

按符号过滤 or 按库过滤

设置最大最小值进行过滤:

设置 符号/库 变化时/删减掉 进行过滤:


5、Memory Graph

可显示当前所有 已使用内存 的详情

5.1、前置设置

Malloc Scribble:开启将使用预定义的值填充释放的内存,从而在内存泄漏时更加明显。这提高了Xcode识别泄漏的准确性。

Malloc Stack Logging:启用此选项将允许Xcode构建分配回溯,以帮助了解对象从何处引用。

5.2、入口:

Xcode 运行项目时可点击中部栏的Debug Memory Graph按钮,查看内存图:

5.3、使用分析:

5.3.1、分析方式1:

点击左侧 导航栏 - 底部栏 的 Show only leaked allocations 按钮,可过滤出泄露的对象:

例如:动画用到的 CGPath 没有释放:

5.3.2、分析方式2:

退出页面后点击 Debug Memory Graph,在底部Filter栏输入 关键字 过滤出当前还存活的对象,进行分析:

例如:退出直播间应该释放的插件没有释放:

以上介绍的都是 Xcode 自带的可视化工具,下面介绍的是其他代码检测工具。


8、FBRetainCycleDetector

Facebook 开源的 循环引用检测 工具 FBRetainCycleDetector

当确认或怀疑一个对象是否泄露时,都可以使用该工具查找循环引用链。

1). main 里添加对 objc_setAssociatedObject 的查找:

代码语言:javascript复制
#import <FBRetainCycleDetector/FBAssociationManager.h>

int main(int argc, char * argv[]) {
  @autoreleasepool {
    [FBAssociationManager hook];
    return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
  }
}

2). 泄露后查找引用环:

代码语言:javascript复制
#import <FBRetainCycleDetector/FBRetainCycleDetector.h>

FBRetainCycleDetector *detector = [FBRetainCycleDetector new];
[detector addCandidate:self];
NSSet *retainCycles = [detector findRetainCycles];
NSLog(@"retain cycle: %@ %@", [self class], retainCycles);

输出,例如:

代码语言:javascript复制
(
    "-> MyTableViewCell ",
    "-> _callback -> __NSMallocBlock__ "
)

表示:cell 持有 block,block 持有 cell


9、RaftKit

腾讯视频已集成的 RaftKit (未开源)里的有 内存泄露监控 工具(底层用的是Bugly):

打开开关和提示弹框:

打开后,当发现泄露会弹出alert:

打开 RaftKit 在内存泄露工具里,查看内存泄露记录文件:

点击需要分析的泄露对象,查看详情:

内部也是使用FBRetainCycleDetector进行引用循环链的查找:

也可将文件导出:FloatingWebVC.txt

分析详情中的循环引用链:左边是实例名,右边实例的类型;从第一个到最后一个形成了一个引用环。

找到对应的类进行分析:

QNBUALiveShowLayoutBridgeBase 是持有 jsBridge 的,且 jsBridge 又间接持有该 block,所以在 block 里直接使用 self 就形成了引用环了。

(26个Handler,95% block 的写法都导致了循环引用)

没有引用环的,可以打开 Memory graph 分析被谁持有的。


10、MLeaksFinder

Tencent 的开源检测内存泄露库:MLeaksFinder

可在日常开放中默认打开,以便及时获得泄露警告,而不用特意打开以上工具去排查。

10.1、使用:

podfile里添加导入,然后执行 pod install

代码语言:javascript复制
pod 'MLeaksFinder'
pod 'FBRetainCycleDetector'

使用 MLeaksFinder.h 的宏 MEMORY_LEAKS_FINDER_ENABLED 控制该工具是否可用.

MLeaksFinder 发现内存泄露时会弹出 Memory Leak 的 alert :

代码语言:javascript复制
Memory Leak
(
    MyTableViewController,
    UITableView,
    UITableViewWrapperView,
    MyTableViewCell
)

表示:MyTableViewController,UITableView,UITableViewWrapperView 都已成功释放,但其 subView MyTableViewCell 没有释放。

并会持续追踪该对象的生命周期,并在该对象释放时给出 Object Deallocated 的 alert :

代码语言:javascript复制
Object Deallocated
(
    MyTableViewController,
    UITableView,
    UITableViewWrapperView,
    MyTableViewCell
)

10.2、分析 alert:

10.2.1、单例 or 被 cache 起来的对象

如下所示,在第一次 pop 时报了 Memory Leak,在之后重复 push 并 pop 同一个 ViewController 过程中,即不报 Object Deallocted,也不报 Memory Leak。这种情况可以确定该对象是被设计成单例 or 被 cache 起来了。

代码语言:javascript复制
    pop             push           pop           push          pop
----------> Leak ----------> | ----------> | ----------> | ---------->
10.2.2、释放不及时

如下所示,在第一次 pop 时报 Memory Leak,在之后的重复 push 和 pop 同一个 ViewController 过程中,对于同一个类不断地报 Object DeallocatedMemory Leak。这种情况属于释放不及时。

代码语言:javascript复制
    pop             push                 pop             push                 pop
----------> Leak ----------> Dealloc ----------> Leak ----------> Dealloc ----------> Leak
10.2.3、真正的泄露

如下所示,在第一次 pop 时报 Memory Leak,在之后的重复 push 和 pop 同一个 ViewController 过程中,不报 Object Deallocated,但每次 pop 之后又报 Memory Leak。这种每次进入并退出一个页面后都报内存泄露,且被报泄露对象又从来没有释放过,可以确定是真正的内存泄露。

代码语言:javascript复制
    pop             push           pop             push           pop
----------> Leak ----------> | ----------> Leak ----------> | ----------> Leak

10.3、查找循环引用链:

MLeaksFinder里也用了FBRetainCycleDetector来找找循环引用链:

MEMORY_LEAKS_FINDER_ENABLED控制是否启用FBRetainCycleDetector查找循环引用链;

_INTERNAL_MLF_RC_ENABLED设置alert弹框是否显示Retain Cycle按钮;

也可以打开 Memory graph 分析被谁持有的。

10.4、原理

NSObject新增一个-willDealloc方法:在 2s 后给弱引用的self发送assertNotDealloc消息:

self被释放则不会执行;

self未被释放则会执行assertNotDeall

然后在UIViewControllerdismiss方法里调用willDealloc:遍历 childVCspresentVCssubViews触发他们的willDealloc方法检测是否有泄露:

10.4、扩展:

MLeaksFinder 目前只检测 ViewController 跟 View 对象。为此,MLeaksFinder 提供了一个手动扩展的机制,开发者可以从 UIViewController 跟 UIView 出发,去检测其它类型的对象的内存泄露。如下所示,可以检测 UIViewController 持有的 View Model:

代码语言:javascript复制
- (BOOL)willDealloc {
    if (![super willDealloc]) {
        return NO;
    }
    MLCheck(self.viewModel);
    return YES;
}

11、泄露总结:

通过排查腾讯视频直播间的整体泄露后,发现泄露类型基本都是以下5类:

11.1、Block

Block 会强引用捕获到的对象,如果该对象 直接 或 间接 强引用该 Block,则会导致循环引用:

11.2、NSTimer

NSTimer 为什么这么容易导致内存泄露:

很重要的一点是因为 RunLoop 会强引用 NSTimer(系统实现的无法做修改)。

所以开发者必须在恰当的时机将NSTimer释放掉。

而一般最佳释放时机为持有 NSTimerselfdealloc 方法里:

代码语言:javascript复制
- (void)dealloc {
    [self.timer invalidate];
    self.timer = nil;
}

iOS10之前的方法,需要传入target(一般我们用self)作为代理,执行需要定时触发的方法。

因为NSTimer会强引用传入的target(这也是系统实现的无法修改)。

当开发者直接传入 self 时,就导致了 self 无法被释放,进而在 dealloc 里释放 NSTimer 的代码也不会执行,从而导致了内存泄露:RunLoop -> NSTimer -> self (不是引用环,但是无法释放)

iOS10苹果新出了3个方法,采用block的形式实现代理方法,不需要传入self(block中还是需要用weakSelf),从而保证了selfdealloc的执行。

更多计时器介绍可见:iOS_定时器:NSTimer、GCDTimer、DisplayLink (最佳实践推荐 6.1)

11.3、malloc -> free

malloc 申请的内存没有使用 free 释放,用 Leaks 检测比较方便:

11.4、CFBridgingRetain - CFBridgingRelease

调用了 CFBridgingRetain 进行 1 持有后,没有调用 CFBridgingRelease 进行 -1 的:


11.5、被static持有了

例如:用了一个static静态变量记录了上一次滑动的 scrollView,导致退出页面后改 scrollView 没有被释放

代码语言:javascript复制
/// 记录用户最后滑动的 scrollView (case: 刚拖拽完tab1,立马切换到tab2)
static UIScrollView *gCurrentScrollView = nil; 

- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
	...
	gCurrentScrollView = scrollView;
	...
}
- (BOOL)enableHandleScrollView:(UIScrollView *)scrollView {
    ...
    if (![gCurrentScrollView isEqual:scrollView]) {      
    	return NO;  /// 已经切换tab了,还收到其他tab的回调,不处理 
    }
    ...
}

修复方案:可以使用代理类若引用该 scrollView:

代码语言:javascript复制
/// 记录用户最后滑动的 scrollView (case: 刚拖拽完tab1,立马切换到tab2)
static QLWeakProxy *gCurrentScrollViewProxy = nil;

- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
	...
    gCurrentScrollViewProxy = [[QLWeakProxy alloc] initWithTarget:scrollView];
   	...
}
- (BOOL)enableHandleScrollView:(UIScrollView *)scrollView {
    ...
    if (![gCurrentScrollViewProxy.target isEqual:scrollView]) {
		return NO;  /// 已经切换tab了,还收到其他tab的回调,不处理 
    }
    ...
}

11.6、单例滥用

一个点赞动效使用了单例,退出直播间没有释放:

12、工具总结:

Memory Report:只能看到内存使用的整体情况,用处不大

Analyze:只能检查编译时期的内存泄漏,不能检测运行时产生的泄露

Leaks:适合发现持续的泄露

Memory Graph:适合发现退出后没有释放的内存泄露

FBRetainCycleDetector:用于查找循环引用链,搭配其他查找泄露对象工具使用

MLeaksFinder:可查找VC和View的泄露,代码开源也可进行DIY拓展

参考:

iOS内存泄漏检查&原理

iOS内存分析原理

检测和诊断 App 内存问题

MLeaksFinder

MLeaksFinder 新特性

MLeaksFinder:精准 iOS 内存泄露检测工具

MLeaksFinder 原理

0 人点赞