前言
在我开始学 iOS 开发的时候,iOS 的内存管理已经全面进入 ARC 时代了,现在几乎很少还会有人手动管理内存吧。尽管如此,了解 iOS 的内存管理机制依然是十分重要的,毕竟就名字来说 ARC 相对于传统的 MRC,只有一个字母的差异,这说明,两者还是十分接近的。另外,我也觉得这是 iOS 开发进阶的必经之路吧,你必须要去深入了解一些内部的东西,才能够提高自己。
手动引用计数 MRC(Manual Reference Counting)和自动引用计数 ARC(Automatic Reference Counting),就像我上面说的那样,都是用引用计数
的方式来管理内存,区别就是手动与自动。MRC 中的内存管理规则例如“自己生成的对象自己持有、不再持有时释放、引用计数为0时销毁对象…..”这些在 ARC 中都没有改变。ARC 带给我们的好处就是我们不用再去思考何时应该去持有对象,何时应该停止持有对象,编译器接管了这一切,它会在适时的时候插入 retain 、 release 或者 autorelease 等语句来帮助我们管理内存。在 ARC 下,我们已经不能自己手动调用它们了….
在这篇博文中,我并不会讲 MRC 和 ARC 具体的区别,我想要讲的是我对于 AutoRelease Pool 的理解。
NSAutoReleasePool
AutoRelease Pool 的机制就如它名字所说的那样,它就像个池子一样,池子里面存放各种对象,这些对象被寄存在这个池子里,就表示它们的存在要依赖池子的生命周期,所以当池子干了的时候,这些对象也就不能存活了。
当然上面这段类比还是有点问题的,并不是池子干了,里面的对象都不能存活了,实际上只是将里面对象的引用计数减一,如果对象原先的引用计数为一,那池子干了对象也就跟着销毁了,否则,对象还是存在的。
Objective-C 中的池子就用 NSAutoReleasePool
类来表示,调用 autorelease
方法的对象会被加入到 NSAutoReleasePool 创建的池子对象里,加入到池子的对象的存在依赖于 NSAutoReleasePool 对象的生命周期。
下面是一个例子:
1 | NSAutoReleasePool *pool = [[NSAutoReleasePool alloc] init]; // 创建一个 AutoRelease Pool |
此处池子里的对象没有销毁,因为本身这个对象由 obj 强引用着,然后把它加入到自动释放池的时候,其实它的引用计数要加一。这时候池子抽干,那给池子里的对象发送 release 消息,就表示引用计数减一,但这时 obj 还没有超出作用域,所以对象并没有销毁。这肯定不是自动释放池应该使用的方式,此处就是拿来举个例子。
当然我们都知道上面的代码只能在 MRC 方式下工作,因为 ARC 是禁止调用 autorelease 方法的,同时 NSAutoReleasePool 类也是禁止使用的。那么 ARC 下面的 AutoRelease Pool 是怎么创建的呢?答案就是使用下面要讲的 @autoreleasepool {}
@autoreleasepool {}
在 ARC 环境下,上面的代码可以写成:
1 | @autoreleasepool { |
我们都知道在 ARC 下有诸如 __strong
、__weak
等修饰符,一般省略修饰符就代表该修饰符默认为 __strong
。@autoreleasepool {}
要发挥作用,将 obj 指向的对象加入到自动释放池中,这个对象要是 __autoreleasing
修饰的,所以,我猜编译器在这里自动给我们加了转换语句
1 | __autoreleasing id tmp = obj; |
事实上,现在在 MRC 环境下,@autoreleasepool {}
也可以使用,所以苹果推荐我们都用 @autoreleasepool {}
来代替NSAutoReleasePool
创建对象,这种方式性能更佳。
我们可以用 clang -rewrite-objc
命令来看一下编译器在编译的时候,究竟将上面的@autoreleasepool {}
转换成了什么。
1 | extern "C" __declspec(dllimport) void * objc_autoreleasePoolPush(void); |
上面的代码已经是比较底层的了,可以看到 @autoreleasepool
被转换成了一个结构体 __AtAutoreleasePool
。它有一个构造函数,里面调用 objc_autoreleasePoolPush()
,还有一个析构函数,里面调用了objc_autoreleasePoolPop()
。
当使用@autoreleasepool
代码块的时候,编译器最终会使用一个代码块替代,在最开始声明一个__AtAutoreleasePool
的局部变量,这时候构造函数执行,退出代码块的时候,变量超出作用域,析构函数执行。所以可以猜到,这两个 objc_autoreleasePoolPush 和 objc_autoreleasePoolPop 函数应该是创建自动释放池和销毁自动释放池的操作。
在讲底层原理之前,我们下面先来讲一下 AutoRelease Pool 在 Objective-C 层面的大致原理。
大致原理
在 Objective-C 层面上,自动释放池是由 NSAutoReleasePool 对象来表示的。所以,创建 NSAutoReleasePool 对象,就表示创建了一个自动释放池。
当有一个对象 obj 调用 auturelease 方法时,它就被加入到一个自动释放池中了。由于池子里可以加入多个对象,所以我们可以猜测,NSAutoReleasePool 里面应该维护了一个数组,用来存放加入池子的对象。对象加入之后,该对象的引用计数就会加一。当 NSAutoReleasePool 对象即自动释放池对象调用 drain 方法来销毁池子的时候,它会先遍历存放对象的数组,给每一个对象都发送 release 消息,对象的引用计数减一,如果对象本身引用计数就为一,那自动释放池就可以将它销毁了,起到了自动释放的作用。
底层原理
之前讲到的objc_autoreleasePoolPush()
方法和objc_autoreleasePoolPop()
方法本质上是调用了一个叫做 AutoreleasePoolPage
类的 push 和 pop 方法。
1 | void * objc_autoreleasePoolPush(void) { |
除了这两个之外,还有一个函数也要讲一下:
1 | id objc_autorelease(id objc) { |
我们可以猜测 push 方法基本等同于创建自动释放池,而 pop 方法则等同于销毁自动释放池,而 autorelease 方法即将对象加入自动释放池。
这里用 push、pop 是因为本质上,自动释放池是存放在堆栈中的,每一个线程的自动释放池是一个指针的堆栈,该堆栈以 AutoreleasePoolPage 为节点。每次创建一个自动释放池,就 push 到堆栈中,反之销毁则 pop 出来。
那么 AutoreleasePoolPage 到底是何方神圣呢?它是一个双向链表,内部用来实现自动释放池功能的 C++ 数据结构。如它名字所说,“Page”嘛,就代表了一段内存。这篇文章对它有一个很详细的描述。现在我说下我的理解:
这是 AutoreleasePoolPage
的具体数据结构,我就解释一下三个最基本的指针:
parent
指针和child
指针分别指向的是当前 Page 节点的前一个 Page 节点和后一个 Page 节点,要记住所有的 AutoreleasePoolPage 组成的是一个链式堆栈。第一个节点的 parent 值为 nil,最后一个节点的 child 的值为 nil- 因为我前面也说了,一个 Page 代表的是一段内存,所以它内部是可以细分的,事实上它内部是由一个个 id 指针类型的节点组成的,一个节点存储加入到自动释放池的对象,另外还有一种可能是存储一个叫做
POOL_SENTINEL
的哨兵对象,它代表一个 AutoRelease Pool 的边界,这个什么意思下面会细说。这些节点就是由next
指针串联起来的。
Page 是一段内存,所以它会有一定的 Size,这也表明 Page 是可能满的。一个 Page 节点 在它满了的时候,就会创建下一个 Page 节点。
下面来解释一下之前说的 push、pop 和 autorelease 操作,我就不贴源码了,详细可以看我之前说的博文,我说一下我的理解:
当第一次执行 push 的时候,就在该堆栈上创建一个 AutoreleasePoolPage 节点,此时该 Page 节点的 parent = nil,child = nil。在 next 位置插入一个之前说的哨兵对象 POOL_SENTINEL,它代表一个 AutoRelease Pool 的起点,返回这个插入地方的内存地址,它叫做 pool token,这个东西到时候在 pop 的时候会用到。
然后每次执行 autorelease 方法,就会在 next 的位置插入添加到自动释放池的对象。next = begin() 表示当前 Page 是空的,同理 next = end() 表示当前 Page 是满的。每次执行 push 和 autorelease 之前,都会检查一下当前 Page 是否已经满了,如果没满,就插入当前 Page 节点的内存中,如果满了,就重新创建一个 Page 节点,当前 Page 节点的 child 指针指向新创建的 Page 节点。这样每次 push 就插入 POOL_SENTINEL 对象, autorelease 就插入加入自动释放池的对象,next 指针也会跟着移动。
当执行 pop 操作的时候,会传入对应的 AutoRelease Pool 的起点即 POOL_SENTINEL 插入位置的内存地址,也就是之前说的 pool token,在这个地址之后所有加入到自动释放池的对象都会发送 release 消息。也就是堆栈从顶部一直 POP 内部对象退栈,next 指针一直往前走,一个 Page 退完还没有到达 pool token 的地址,就继续到更前面的 Page。直到某个 Page 的 next 指针指向的地址等于 pool token 为止。
下面我画的简易原理图:
上面图中的蓝色色块代表一个 AutoreleasePoolPage,绿色色块代表存储 POOL_SENTINEL 对象,红色色块代表加入到自动释放池的普通对象。图中有三个 POOL_SENTINEL 对象,表明有三个自动释放池。
假设此时我们的第二个自动释放池执行 pop 操作,根据我们上面说的理论,则堆栈会变成下面这个样子:
第二个自动释放池以及后面的自动释放池都会被销毁。
何时销毁对象?何时创建自动释放池?
每一个线程都有一个 NSRunLoop 对象, NSRunLoop 在每次事件循环地开始时,都会创建自动释放池,在一个循环结束的时候就会结束废弃自动释放池,回收内存。每个线程都会维护一个 Autorelease Pool 栈。
我们可以在我们每个 iOS 工程的 main 文件中看到如下代码:
1 | int main(int argc, char * argv[]) { |
这里问什么要创一个自动释放池呢?我认为这是显式地表明程序里的对象都至少嵌套在最外面的自动释放池中,最终都会被释放掉。且如果要在 main 函数中增加其他内容的话,也可以包裹在里面,最后终将被释放。有说法是 LLVM 编译器会为每个线程自动创建自动释放池(就像我们之前说的那样),但如果换了其他编译器可能不会这么做。为了使程序中至少要有一个自动释放池存在,就在 main 函数里面创建了这个,这样才能保证所有的对象都能够被正确释放。
因为 NSRunLoop 会我们自动创建和销毁自动释放池,所以一般情况下我们不必手动去创建。但是有些时候我们还是需要自己来创建的,苹果官方建议在这些情况下手动创建 Autorelease Pool:
- 如果你写的程序不是基于 UI 框架的,例如是命令性项目
- 如果你写的循环中需要创建大量的临时对象,就可以在循环体中创建自动释放池,降低内存峰值
- 如果你创建了一个新线程,在线程执行的时候就要马上创建一个自动释放池,否则就可能造成内存泄漏
第二条的原理是如果没有手动创建,事实上每次循环体结束的时候,里面的临时对象不会马上销毁,所以随着循环地进行会越叠越多,内存占用越来越大。虽然最终等一个 NSRunLoop 循环结束前它们都会被销毁,但会造成内存峰值很大。如果手动加上了自动释放池,那么每次循环体结束,就会销毁当次循环中创建的临时对象,避免了上述问题。
第三条算是一种编程规范,如果创建线程后不手动创建,不会报错,但可能造成内存泄漏。所以要记得创建线程后要将线程执行的内容放在自动释放池中。当然 GCD 没有这个问题,因为我们也并没有手动创建线程。这里主要是使用 NSThread 时需要注意的。
在 ARC 环境下,Cocoa 框架中很多系统方法返回的对象系统都会为我们注册到自动释放池中,这些方法并非变量主动创建,所以无法释放,加入自动释放池可以保证最后能够被释放,例如下面这些方法:
1 | [NSMutableArray array]; |
另外,使用 __weak 关键字的变量,也是被注册到了自动释放池中。
注册到自动释放池中的对象,引用计数会增加一,销毁池子的时候,引用计数减一。
最后一个问题
之前我说的那篇博文的作者在开头使用了一个例子,用来引出自动释放池。对于例子中的实验结果,我本来都觉得十分正确,也符合我对它的理解。
下面是那位作者的实验代码和实验结果
1 | __weak NSString *string_weak_ = nil; |
1 | // 场景 1 |
但是作者后来说,在一些新的设备上实验结果不再成立。
我自己试了一下,果然如作者所说,在新设备上,所有的结果打印出来都不是 null,说明对象都没有被销毁掉。这让我大吃一惊,刚理解一个东西,难道突然告诉我之前的原理又不成立了?
我看了一下作者列出的设备名单,iPhone 5s 之后的设备都不成立,之前的还是成立的。我隐约觉得这个是否和 64 位设备有关。带着这个疑问,我搜索了一下这些关键词,果然找到了原因。后来在《Objective-C 高级编程——iOS与OS X多线程和内存管理》这本书中,我也查到了相应的说明。
对于下面的代码:
1 | { |
编译出来是这样的(模拟):
1 | id obj = obj_msgSend(NSMutableArray, @selector(array)); |
而 array 方法
1 | + (id) array { |
编译出来是这样的(模拟):
1 | + (id) array { |
objc_retainAutoreleasedReturnValue
用于自己持有对象,而objc_autoreleaseReturnValue
返回注册到自动释放池中的对象。上面两段代码结合起来看的话,先调用了objc_autoreleaseReturnValue()
再调用objc_retainAutoreleasedReturnValue()
方法。
这里的关键就是在 64 位环境下,编译器对此处进行了优化,如果检测到调用了 objc_autoreleaseReturnValue 之后紧接着调用了 objc_retainAutoreleasedReturnValue,那么就不将对象注册到自动释放池中,而是直接将对象传递到方法或者函数的调用方。
站在 Objective-C 的层面,这个也是好理解的。这些方法创建出来的对象本来要注册到自动释放池中,但是现在我编译器检测到这个对象后来是被一个变量通过强引用持有,那我就没必要注册到自动释放池了,什么时候你变量不持有了,那就可以释放这个对象了,而不用等到下次释放池子的时候再释放。毕竟注册自动释放池是一种消耗资源的操作。