iOS 内存监控分析

概述

最近有在看一些内存监控开源库的实现,所以有做引用和总结。
这篇文章描述了关于内存监控知名开源库的一些实现思路,使用方式以及优缺点等。

FBMemoryProfiler

循环引用

Objective-C 使用引用计数去管理内存和释放不使用的对象。内存中的任何一个对象都可以持有(retain)其他的对象,只要前面的对象需要它,对象就会一直保持在内存中。查看这个的一个方法是这个对象持有其他的对象。

在大部分时间内,这都工作的很好,但当两个对象互相持有的时候,这就会陷入一个僵局。直接,或者更常见的,通过间接对象连接它们。这种持有引用的环我们叫做循环引用(Retain cycles)。

retain cycle

循环引用会导致一些列的问题。最好的情况下,对象只会在内存中占有一点点位置。如果这个被泄露的对象正积极地做个一些不平凡的事情,应用程序的其他部分就只会有更少的内存。最坏的情况下,如果泄露导致使用超出可用内存的容量,那么,应用程序会崩溃。

在手动性能分析期间,我们发现,我们往往有一些循环引用。我们很容易引起内存泄露,但是很难找到它们。循环引用检测器可以很容易的找到它们。

这有点抽象,但效果很好。我们必须确保我们可以像节点一样使用对象,对于每个对象,我们都可以获取到它引用的所有对象。这些引用可能是weak,也可能是strong。只有强引用才会导致循环引用。对于每个对象来说,我们需要知道如何找出这些引用。

幸运的是,Objective-C提供了一个强有力的、内省的运行时库。这让我们在图中可以有足够的数据去挖掘。

图中的节点可以是对象,也可以是Block。让我们来分别讨论一下。

对象

运行时有很多工具允许我们对对象进行内省。

我们要做的第一件事是获取对象的实例变量的布局(ivar layout)。

const char *class_getIvarLayout(Class cls);
const char *class_getWeakIvarLayout(Class cls);

对于对象,实例变量的布局描述了我们在哪儿可以找到其他对象的引用。它会提供给我们一个索引(index),这代表我们需要在对象地址上添加一个偏移量(offset),就可以得到它所引用的对象的地址。运行时也允许我们获取“弱引用实例变量布局(weak ivar layout)”。

这也部分支持Objective-C++。在Objective-C++中,我们可以在结构体中定义对象,但是这不会在实例变量布局中获取到。运行时提供了“类型编码(type encoding)”来处理这个问题。对于每一个实例变量来说,类型编码描述了变量是如何结构化的。如果这是一个结构体,它会描述它包含了哪些字段和类型。我们计算出它们的偏移量,在图中,找出它们所指向的对象。

也有一些边缘条件我们不会深入。大部分是一些不同的集合,我们不得不列举它们去获得它们持有的对象,这可能会导致一些副作用。

Block

Block和对象有一点不一样。运行时不会让我们很轻易的看到它们的布局,但是我们仍然可以猜测。

在处理Block的时候,我们可以使用 Mike Ash 在他的项目Circle(第一时间启发FBRetainCycleDetector的项目)中提出的想法。

我们可以使用的是ABI(application binary interface for blocks - 应用程序二进制Block接口)。它描述了Block在内存中的样子。如果我们知道我们在处理的引用是一个Block,我们可以把它丢在一个假的结构体中来模仿Block。在放到一个C语言的结构体之后,我们可以知道Block所持有的对象。不幸的是,我们不知道这些引用是强引用还是弱引用。

为了解决这个问题,我们使用了一个黑盒技术。我们创建一个对象来假扮我们想要调查的Block。因为我们知道Block的接口,我们知道在哪可以找到Block持有的引用。我们伪造的对象将会拥有“释放检测(release detectors)”来代替这些引用。释放检测器是一些很小的对象,它们会观察发送给它们的释放消息。当持有者想要放弃它的持有的时候,这些消息会发送给强引用对象。当我们释放我们伪造的对象的时候,我们可以检测哪些检测器接收到了这些消息。只要知道哪些索引在伪造的对象的检测器中,我们就可以找到原来Block中实际持有的对象。

block

自动化

让这工具真正闪光的是,在工程师内部构建的时候,它会连续的、自动的运行。

客户端部分自动化是简单的。我们在定时器上运行循环引用检测器,定期扫描内存去寻找循环引用,虽然这不是完全没有问题。当我们第一次运行分析器的时候,我们意识到它不足以很快的扫描整个内存空间。当它开始检测的时候,我们需要给它提供一组候选对象。

为了更有效的解决这个问题,我们开发了FBAllocationTracker。这个工具会主动跟踪NSObject子类的创建和释放。它可以以一个很小的性能开销来获取任何类的任何实例。

对于客户端的自动化,只要在NSTimer上使用FBRetainCycleDetector,再用FBAllocationTracker来抓取实例来配合跟踪就行。

现在,让我们来仔细看看后台会发生什么。

循环引用可以包含任何数量的对象。一个坏的连接会导致很多环的时候,这就复杂了。

cycles

在环中,A→B是一个坏连接,创建了两个环:A-B-C-D 和 A-B-C-E。

这有两个问题:

  • 我们不想给一个坏连接导致的两个循环引用分别标记。
  • 我们不想给可能代表两个问题的两个循环引用一起标记,即使它们共享一个连接。

所以我们需要给循环引用定义簇组(clusters),鉴于这些启发,我们写了个算法来找到这些问题。

  • 在给定的时间收集所有的环。
  • 对于每一个环,提取Facebook特定的类名。
  • 对于每一个环,找到包含在环内的被报告的最小的环。
  • 依据上面的最小环,将环添加到组中。
  • 只报告最小环。

最后一部分是找出谁第一时间偶然引入了循环引用。我们可以通过环中的”git/hg责任”的部分代码来猜测最近的变化所导致的问题。最后一个接触这个代码的人将会收到修复代码的任务。

整个系统如下:

c/s

手动性能分析

虽然自动化有助于简化发现循环引用的过程,降低人员的消耗,手动性能分析依然有它的用武之地。我们创建的另一个工具允许任何人查看内存使用,甚至不需要把他的手机插到电脑上。

FBMemoryProfiler可以很容易的添加到任何应用程序,可以让你手动配置构建文件,可以让你在应用程序内运行循环应用检测。它会借用FBAllocationTracker和FBRetainCycleDetector来实现此功能。

生成(Generations)

FBMemoryProfiler的一个很伟大的特性是“生成追踪(generation tracking)”,类似于苹果的Instruments的生成追踪。生成只是简单的在两次标记之间拍摄所有仍然活着的对象的快照。

使用FBMemoryProfiler的界面,我们可以标记生成,例如,分配三个对象。然后我们标记另一个生成,之后继续分配对象。第一个生成包含我们一开始的三个对象。如果任意一个对象被释放了,它会从我们第二个生成中移除。

generation

当我们有一个重复的任务,我们认为可能会内存泄露的时候,生成追踪是很有用的,例如,导航View Controller的进出。在每次开始我们的任务的时候,我们标记一个生成,然后,对之后的每个生成进行调查。如果一个对象不应该活这么长时间,我们可以在FBMemoryProfiler界面清楚地看到。

MLeaksFinder by 微信读书

Leaks

先看看 Leaks,从苹果的开发者文档里可以看到,一个 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 和 Abandoned memory 都属于应该释放而没释放的内存,都是内存泄露,而 Leaks 工具只负责检测 Leaked memory,而不管 Abandoned memory。在 MRC 时代 Leaked memory 很常见,因为很容易忘了调用 release,但在 ARC 时代更常见的内存泄露是循环引用导致的 Abandoned memory,Leaks 工具查不出这类内存泄露,应用有限。

Allocations

对于 Abandoned memory,可以用 Instrument 的 Allocations 检测出来。检测方法是用 Mark Generation 的方式,当你每次点击 Mark Generation 时,Allocations 会生成当前 App 的内存快照,而且 Allocations 会记录从上回内存快照到这次内存快照这个时间段内,新分配的内存信息。举一个最简单的例子:

我们可以不断重复 push 和 pop 同一个 UIViewController,理论上来说,push 之前跟 pop 之后,app 会回到相同的状态。因此,在 push 过程中新分配的内存,在 pop 之后应该被 dealloc 掉,除了前几次 push 可能有预热数据和 cache 数据的情况。如果在数次 push 跟 pop 之后,内存还不断增长,则有内存泄露。因此,我们在每回 push 之前跟 pop 之后,都 Mark Generation 一下,以此观察内存是不是无限制增长。这个方法在 WWDC 的视频里:Session 311 - Advanced Memory Analysis with Instruments,以及苹果的开发者文档:Finding Abandoned Memory 里有介绍。

用这种方法来发现内存泄露还是很不方便的:

  • 首先,你得打开 Allocations
  • 其次,你得一个个场景去重复的操作
  • 无法及时得知泄露,得专门做一遍上述操作,十分繁琐

MLeaksFinder

MLeaksFinder 具备以下优点:

  • 使用简单,不侵入业务逻辑代码,不用打开 Instrument
  • 不需要额外的操作,你只需开发你的业务逻辑,在你运行调试时就能帮你检测
  • 内存泄露发现及时,更改完代码后一运行即能发现(这点很重要,你马上就能意识到哪里写错了)
  • 精准,能准确地告诉你哪个对象没被释放
原理

MLeaksFinder 一开始从 UIViewController 入手。我们知道,当一个 UIViewController 被 pop 或 dismiss 后,该 UIViewController 包括它的 view,view 的 subviews 等等将很快被释放(除非你把它设计成单例,或者持有它的强引用,但一般很少这样做)。于是,我们只需在一个 ViewController 被 pop 或 dismiss 一小段时间后,看看该 UIViewController,它的 view,view 的 subviews 等等是否还存在。

具体的方法是,为基类 NSObject 添加一个方法 -willDealloc 方法,该方法的作用是,先用一个弱指针指向 self,并在一小段时间(3秒)后,通过这个弱指针调用 -assertNotDealloc,而 -assertNotDealloc 主要作用是直接中断言。

- (BOOL)willDealloc {

    __weak id weakSelf = self;
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        [weakSelf assertNotDealloc];
    });
    return YES;
}


- (void)assertNotDealloc {

     NSAssert(NO, @“”);
}

这样,当我们认为某个对象应该要被释放了,在释放前调用这个方法,如果3秒后它被释放成功,weakSelf 就指向 nil,不会调用到 -assertNotDealloc 方法,也就不会中断言,如果它没被释放(泄露了),-assertNotDealloc 就会被调用中断言。这样,当一个 UIViewController 被 pop 或 dismiss 时(我们认为它应该要被释放了),我们遍历该 UIViewController 上的所有 view,依次调 -willDealloc,若3秒后没被释放,就会中断言。

在这里,有几个问题需要解决:

1.不入侵开发代码

这里使用了 AOP 技术,hook 掉 UIViewController 和 UINavigationController 的 pop 跟 dismiss 方法,关于如何 hook,请参考 Method Swizzling。

2.遍历相关对象

在实际项目中,我们发现有时候一个 UIViewController 被释放了,但它的 view 没被释放,或者一个 UIView 被释放了,但它的某个 subview 没被释放。这种内存泄露的情况很常见,因此,我们有必要遍历基于 UIViewController 的整棵 View-ViewController 树。我们通过 UIViewController 的 presentedViewController 和 view 属性,UIView 的 subviews 属性等递归遍历。对于某些 ViewController,如 UINavigationController,UISplitViewController 等,我们还需要遍历 viewControllers 属性。

3.构建堆栈信息

需要构建 View-ViewController stack 信息以告诉开发者是哪个对象没被释放。在递归遍历 View-ViewController 树时,子节点的 stack 信息由父节点的 stack 信息加上子结点信息即可。

4.例外机制

对于有些 ViewController,在被 pop 或 dismiss 后,不会被释放(比如单例),因此需要提供机制让开发者指定哪个对象不会被释放,这里可以通过重载上面的 -willDealloc 方法,直接 return NO 即可。

5.特殊情况

对于某些特殊情况,释放的时机不大一样(比如系统手势返回时,在划到一半时 hold 住,虽然已被 pop,但这时还不会被释放,ViewController 要等到完全 disappear 后才释放),需要做特殊处理,具体的特殊处理视具体情况而定。

6.系统View

某些系统的私有 View,不会被释放(可能是系统 bug 或者是系统出于某些原因故意这样做的,这里就不去深究了),因此需要建立白名单

7.手动扩展

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

- (BOOL)willDealloc {

    if (![super willDealloc]) {
        return NO;
    }
    MLCheck(self.viewModel);
    return YES;
}

这里的原理跟上面的是一样的,宏 MLCheck() 做的事就是为传进来的对象建立 View-ViewController stack 信息,并对传进来的对象调用 -willDealloc 方法。

未来

MLeaksFinder 目前还在起步阶段,它的内存泄露检测的想法是很简单,很直接的。虽然目前只能自动地检测 UIViewController 和 UIView 相关的对象,然而在我们几个大的项目中,已经起到很大的作用,帮助我们发现很多历史存在的内存泄露,而且确保新提交的 UI 相关代码不会引进新的问题。MLeaksFinder 会继续探索覆盖更广的情况,提供更全面的检测,包括网络层,数据存储层等等。

PLeakSniffer

实现思路

我们绝大部分时候都是在编写UIViewController,UIViewController就像一个根节点,持有并管理着很多的子节点对象,这些子节点的生命周期都依赖于Controller,Controller释放的时候,他们也随之释放。用一张图简单的描述他们的关系:

mvp

根据各个应用使用的设计模式不同(MVC,MVP,MVVM等),Controller所持有的Property也不相同。这里我们使用MVP作为例子,Controller所包含的对象就包括各种View对象,和Presenter,Model对象。当然每个对象又有可能持有更多的子对象。

PLeakSniffer 基于这样一个假设: > 如果Controller被释放了,但其曾经持有过的子对象如果还存在,那么这些子对象就是泄漏的可疑目标。

当然这个假设并不是一个100%适用的真理,不同工程师编写代码的方式风格差别很大,有些会把某些 UIViewController 做成单例(个人觉得这不是个好主意。。),有些会把某些 View 缓存起来(即使 Controller 已被释放),还会有其他考虑不到的场景。但在80%以上的场景,我们在Controller 结束生命周期之后会将其持有的资源一并释放。这时候PLeakSniffer可以发挥用处,给你一些免费的泄漏建议。

那么怎么在Controller被释放之后,知道其持有的对象没有被释放呢?

一个小技巧可以达成这个目标:子对象(比如view)建立一个对 controller 的 weak 引用,如果Controller 被释放,这个 weak 引用也随之置为 nil。那怎么知道子对象没有被释放呢?用一个单例对象每个一小段时间发出一个 ping 通知去 ping 这个子对象,如果子对象还活着就会一个 pong 通知。所以结论就是:如果子对象的 controller 已不存在,但还能响应这个 ping 通知,那么这个对象就是可疑的泄漏对象。完整的结构可以用下图表示:

ping-pong

通知移除需要一个时机,这里我们使用 Associated Object 机制给每一个子对象再生成一个Proxy 对象,在 Proxy 对象的 dealloc 里面移除通知。

当然什么时候去判断一个对象的生命周期开始,什么时候判断为结束,需要一个精挑细选的机制。View,Controller,Property 各不相同。

PLeakSniffer 采取保守的策略,通过 Objective C 的 runtime 机制,递归的将一个 Controller 所有强引用的 property 找出,并安装 proxy 监听 Ping 通知。在我的测试下,基本上能将 property 泄漏的场景找出。

总结

个人来讲比较倾向于使用 FB 的一套工具,当然可能觉得重的话使用微信读书的 MLeaksFinder 也不错,不过检查以 VC 为节点检查子视图控制器以及子视图可能覆盖不到那些非视图类的对象。最后的 PLeakSniffer 通过发送通知来检查对象是否释放也类似于 MLeaksFinder 是否能响应消息的方式,不过实现的过程中在遍历属性获取值时通过 valueForKey: 的方法并不安全,所以通过前两者是个不错的方式。

Comments
Write a Comment