iOS 中的 Autorelease Pool

前言

Objective-C 对象的生命周期取决于其引用计数。在 Objective-C 的引用计数架构中,有一项特性叫做 Autorelease Pool(自动释放池)。释放对象有两种方式:

  1. 调用 release 方法,使其保留计数立即减 1
  2. 调用 autorelease 方法,将其加入 Autorelease Pool 中

Autorelease Pool 用于存放那些需要在稍后某个时刻释放的对象。当 Pool drain(清空)时,系统会向其中的对象发送 release 消息。


详解

创建 Autorelease Pool 所使用语法如下:

1
2
3
@autoreleasepool {
...
}

如果在没有创建 Autorelease Pool 的情况下给对象发送 autorelease 消息,那么控制台会输出这样一条信息:

1
__NSAutoreleaseNoPool(): Object 0x350270 of class NSCFString autoreleased with no pool in place - just leaking - break on objc_ autoreleaseNoPool() to debug

而然,一般情况下无须担心 Autorelease Pool 的创建问题。Mac OS X 与 iOS 应用程序分别运行于 Cocoa 及 Cocoa Touch 环境中。系统会自动创建一些线程,比如主线程或者是 GCD 机制中的线程,这些线程默认都有 Autorelease Pool,每次执行 Event Loop(事件循环)时,就会将其清空。因此,不需要自己来创建。

通常只有一个地方需要创建 Autorelease Pool,那就是在 main 函数里,我们用 Autorelease Pool 来包裹应用程序的入口点。比如说,iOS 程序的 main 函数经常这样写:

1
2
3
4
5
int main(int argc, char * argv[]) {
@autoreleasepool {
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
}

其实从技术角度看,不是非得有个 @autoreleasepool {} 才行。因为块的末尾恰好就是应用程序的终止处,而此时操作系统会把程序所占的全部内存都释放掉。虽说如此,但是如果不写这个块的话,那么由 UIApplicationMain 函数所自动释放的那些对象,就没有 Autorelease Pool 可以容纳了,于是系统会发出警告信息来表明这一情况。所以说,这个池可以理解成最外围捕捉全部自动释放对象所用的池。


示例

下面这段代码中的花括号定义了 Autorelease Pool 的范围。Autorelease Pool 于 {(左花括号)处创建,并于对应的 }(右花括号)处自动清空。位于 Autorelease Pool 范围内的对象,将在此范围末尾处收到 release 消息。Autorelease Pool 可以嵌套。系统在自动释放对象时,会把它放到最内层的池里。比如说:

1
2
3
4
5
6
@autoreleasepool {
NSString *string = [NSString stringWithFormat:@"1 = %i", 1];
@autoreleasepool {
NSNumber *number = [NSNumber numberWithInt:1];
}
}

本例中有两个对象,它们都由类的工厂方法所创建,这样创建出来的对象会自动释放。NSString 对象放在外围的 Autorelease Pool 中,而 NSNumber 对象则放在里层的 Autorelease Pool 中。将 Autorelease Pool 嵌套使用的好处是,可以借此控制应用程序的内存峰值(high-memory waterline),使其不致过高。

考虑下面这段代码:

1
2
3
for (int i = 0; i < 100000; i++) {
[self doSomethingWithInt:i];
}

如果 doSomethingWithInt: 方法要创建临时对象,那么这些对象很可能会放在 Autorelease Pool 里。比方说,它们可能是一些临时字符串。但是,即便这些对象在调用完方法之后就不再使用了,它们也依然处于]存活状态,因为目前还在 Autorelease Pool 里,等待系统稍后将其释放并回收。然而,Autorelease Pool 要等到线程执行下一个事件循环时才会清空。这就意味着在执行 for 循环时,会持续有新的对象创建出来,并加入 Autorelease Pool 中。所有这种对象都要等 for 循环执行完才会释放。这样一来,在执行 for 循环时,应用程序所占内存量就会持续上涨,而等到所有临时对象都释放之后,内存量又会突然下降。


实现原理

AutoreleasePoolPage

ARC 下,我们使用 @autoreleasepool {} 来使用一个 Autorelease Pool,随后编译器将其改写成下面的样子:

1
2
3
void *context = objc_autoreleasePoolPush();
// {}中的代码
objc_autoreleasePoolPop(context);

而这两个函数都是对 AutoreleasePoolPage 的简单封装,所以自动释放机制的核心就在于这个类。

AutoreleasePoolPage 是一个 C++ 实现的类:

  1. Autorelease Pool 并没有单独的结构,而是由若干个 AutoreleasePoolPage双向链表的形式组合而成(分别对应结构中的 parent 指针和 child 指针)
  2. Autorelease Pool 是按线程一一对应的(结构中的 thread 指针指向当前线程)
  3. AutoreleasePoolPage 每个对象会开辟 4096 字节内存(也就是虚拟内存一页的大小),除了上面的实例变量所占空间,剩下的空间全部用来储存 autorelease 对象的地址
  4. 上面的 id *next 指针作为游标指向栈顶最新 add 进来的 autorelease 对象的下一个位置
  5. 一个 AutoreleasePoolPage 的空间被占满时,会新建一个 AutoreleasePoolPage 对象,连接链表,后来的 autorelease 对象在新的 Page 加入

  • magic 用来校验 AutoreleasePoolPage 的结构是否完整
  • next 指向最新添加的 autoreleased 对象的下一个位置,初始化时指向 begin()
  • thread 指向当前线程
  • parent 指向父结点,第一个结点的 parent 值为 nil
  • child 指向子结点,最后一个结点的 child 值为 nil
  • depth 代表深度,从 0 开始,往后递增 1
  • hiwat 代表 high water mark

另外,当 next == begin() 时,表示 AutoreleasePoolPage 为空;当 next == end() 时,表示 AutoreleasePoolPage 已满。

所以,若当前线程中只有一个 AutoreleasePoolPage 对象,并记录了很多 autorelease 对象地址时内存如下图:

图中的情况,这一页再加入一个 autorelease 对象就要满了(也就是 next 指针马上指向栈顶),这时就要执行上面说的操作,建立下一页 Page 对象,与这一页链表连接完成后,新 Page 的 next 指针被初始化在栈底( begin 的位置),然后继续向栈顶添加新对象。

所以,向一个对象发送 autorelease 消息,就是将这个对象加入到当前 AutoreleasePoolPage 的栈顶 next 指针指向的位置

释放时机

每当进行一次 objc_autoreleasePoolPush 调用时,Runtime 向当前的 AutoreleasePoolPage中 add 进一个哨兵对象(POOL_SENTINEL),值为 0(也就是个 nil),那么这一个 page 就变成了下面的样子:

objc_autoreleasePoolPush 的返回值正是这个哨兵对象的地址,被 objc_autoreleasePoolPop(哨兵对象)作为入参,于是:

  1. 根据传入的哨兵对象地址找到哨兵对象所处的 Page
  2. 在当前 Page 中,将晚于哨兵对象插入的所有 autorelease 对象都发送一次 release 消息,并向回移动next指针到正确位置
  3. 补充 2:从最新加入的对象一直向前清理,可以向前跨越若干个 Page,直到哨兵所在的 Page

刚才的 objc_autoreleasePoolPop 执行后,最终变成了下面的样子:


@autoreleasepool {}

我们使用 clang -rewrite-objc 命令将下面的 Objective-C 代码重写成 C++ 代码:

1
2
3
@autoreleasepool {

}

将会得到以下输出结果(只保留了相关代码):

1
2
3
4
5
6
7
8
9
10
11
12
extern "C" __declspec(dllimport) void * objc_autoreleasePoolPush(void);
extern "C" __declspec(dllimport) void objc_autoreleasePoolPop(void *);

struct __AtAutoreleasePool {
__AtAutoreleasePool() {atautoreleasepoolobj = objc_autoreleasePoolPush();}
~__AtAutoreleasePool() {objc_autoreleasePoolPop(atautoreleasepoolobj);}
void * atautoreleasepoolobj;
};

/* @autoreleasepool */ { __AtAutoreleasePool __autoreleasepool;

}

苹果通过声明一个 __AtAutoreleasePool 类型的局部变量 __autoreleasepool 来实现 @autoreleasepool {}。当声明 __autoreleasepool 变量时,构造函数 __AtAutoreleasePool() 被调用,即执行 atautoreleasepoolobj = objc_autoreleasePoolPush();;当出了当前作用域时,析构函数 ~__AtAutoreleasePool() 被调用,即执行 objc_autoreleasePoolPop(atautoreleasepoolobj);。也就是说 @autoreleasepool {} 的实现代码可以进一步简化如下:

1
2
3
4
5
/* @autoreleasepool */ {
void *atautoreleasepoolobj = objc_autoreleasePoolPush();
// 用户代码,所有接收到 autorelease 消息的对象会被添加到这个 Autorelease Pool 中
objc_autoreleasePoolPop(atautoreleasepoolobj);
}

因此,单个 Autorelease Pool 的运行过程可以简单地理解为 objc_autoreleasePoolPush()[obj autorelease]objc_autoreleasePoolPop(void *) 三个过程。

push 操作

上面提到的 objc_autoreleasePoolPush() 函数本质上就是调用的 AutoreleasePoolPagepush 函数:

1
2
3
4
5
6
void *
objc_autoreleasePoolPush(void)
{
if (UseGC) return nil;
return AutoreleasePoolPage::push();
}

因此,我们接下来看看 AutoreleasePoolPagepush 函数的作用和执行过程。一个 push 操作其实就是创建一个新的 Autorelease Pool,对应 AutoreleasePoolPage 的具体实现就是往 AutoreleasePoolPage 中的 next 位置插入一个 POOL_SENTINEL(哨兵对象),并且返回插入的 POOL_SENTINEL 的内存地址。这个地址也就是我们前面提到的 pool token ,在执行 pop 操作的时候作为函数的入参:

1
2
3
4
5
6
static inline void *push()
{
id *dest = autoreleaseFast(POOL_SENTINEL);
assert(*dest == POOL_SENTINEL);
return dest;
}

push 函数通过调用 autoreleaseFast 函数来执行具体的插入操作:

1
2
3
4
5
6
7
8
9
10
11
static inline id *autoreleaseFast(id obj)
{
AutoreleasePoolPage *page = hotPage();
if (page && !page->full()) {
return page->add(obj);
} else if (page) {
return autoreleaseFullPage(obj, page);
} else {
return autoreleaseNoPage(obj);
}
}

autoreleaseFast 函数在执行一个具体的插入操作时,分别对三种情况进行了不同的处理:

  1. 当前 Page 存在且没有满时,直接将对象添加到当前 Page 中,即 next 指向的位置
  2. 当前 Page 存在且已满时,创建一个新的 Page ,并将对象添加到新创建的 Page 中
  3. 当前 Page 不存在时,即还没有 Page 时,创建第一个 Page ,并将对象添加到新创建的 Page 中

每调用一次 push 操作就会创建一个新的 Autorelease Pool,即往 AutoreleasePoolPage 中插入一个 POOL_SENTINEL,并且返回插入的 POOL_SENTINEL 的内存地址。

autorelease 操作

1
2
3
- (id)autorelease {
return ((id)self)->rootAutorelease();
}

通过查看 ((id)self)->rootAutorelease() 的方法调用,我们发现最终调用的就是 AutoreleasePoolPageautorelease 函数:

1
2
3
4
5
6
7
__attribute__((noinline,used))
id
objc_object::rootAutorelease2()
{
assert(!isTaggedPointer());
return AutoreleasePoolPage::autorelease((id)this);
}

AutoreleasePoolPageautorelease 函数的实现对我们来说就比较容量理解了,它跟 push 操作的实现非常相似。只不过 push 操作插入的是一个 POOL_SENTINEL,而 autorelease 操作插入的是一个具体的 autoreleased 对象:

1
2
3
4
5
6
7
8
static inline id autorelease(id obj)
{
assert(obj);
assert(!obj->isTaggedPointer());
id *dest __unused = autoreleaseFast(obj);
assert(!dest || *dest == obj);
return obj;
}

pop 操作

同理,前面提到的 objc_autoreleasePoolPop(void *) 函数本质上也是调用的 AutoreleasePoolPagepop 函数:

1
2
3
4
5
6
7
8
9
10
void
objc_autoreleasePoolPop(void *ctxt)
{
if (UseGC) return;

// fixme rdar://9167170
if (!ctxt) return;

AutoreleasePoolPage::pop(ctxt);
}

pop 函数的入参就是 push 函数的返回值,也就是 POOL_SENTINEL 的内存地址,即 pool token。当执行 pop 操作时,内存地址在 pool token 之后的所有 autoreleased 对象都会被 release。直到 pool token 所在 Page 的 next 指向 pool token 为止。

下面是某个线程的 Autorelease Pool 堆栈的内存结构图,在这个 Autorelease Pool 堆栈中总共有两个 POOL_SENTINEL,即有两个 Autorelease Pool。该堆栈由三个 AutoreleasePoolPage 结点组成,第一个 AutoreleasePoolPage 结点为 coldPage(),最后一个 AutoreleasePoolPage 结点为 hotPage()。其中,前两个结点已经满了,最后一个结点中保存了最新添加的 autoreleased 对象 objr3 的内存地址:

此时,如果执行 pop(token1) 操作,那么该 Autorelease Pool 堆栈的内存结构将会变成如下图所示:


总结

这种情况不甚理想,尤其当循环长度无法预知的情况下更是如此。比方说,要从数据库中读出许多对象。代码可能会这么写:

1
2
3
4
5
6
NSArray *databaseRecords = /* ... */;
NSMutableArray *people = [NSMutableArray new];
for (NSDictionary *record in databaseRecords) {
EOCPerson *person = [[EOCPerson alloc] initWithRecord:record];
[people addObject:person];
}

EOCPerson 的初始化函数也许会像上列那样,再创建出一些临时对象。若记录有很多条,则内存中也会有很多不必要的临时对象,它们本来应该提早回收的。增加一个 Autorelease Pool 即可解决此问题。如果把循环内的代码包裹在 @autoreleasepool {} 中,那么在循环中自动释放的对象就会放在这个 Pool,而不是线程的主池里面。例如:

1
2
3
4
5
6
7
8
NSArray *databaseRecords = /* ... */;
NSMutableArray *people = [NSMutableArray new];
for (NSDictionary *record in databaseRecords) {
@autoreleasepool {
EOCPerson *person = [[EOCPerson alloc] initWithRecord:record];
[people addObject:person];
}
}

加上这个 Autorelease Pool 之后,应用程序在执行循环时的内存峰值就会降低,不再像原来那么高了。内存峰值(high-memory waterline)是指应用程序在某个特定时间段内的最大内存用量(highest memory footprint)。新增的 @autoreleasepool {} 可以减少这个峰值,因为系统会在块的末尾把某些对象回收掉。而刚才提到的那种临时对象,就在回收之列。

是否应该用池来优化效率,完全取决于具体的应用程序。首先得监控内存用量,判断其中有没有需要解决的问题,如果没有完成这一步,那就别急着优化。尽管 @autoreleasepool {} 的开销不太大,但毕竟还是有的,所以尽量不要建立额外的 Autorelease Pool。

MRC vs. ARC

如果在 ARC 出现之前就写过 Objective-C 程序,那么可能还记得有种老式写法,就是使用 NSAutoreleasePool 对象。这个特殊的对象与普通对象不同,它专门用来表示 Autorelease Pool,就像新语法中的 @autoreleasepool {} 一样。但是这种写法并不会在每次执行 for 循环时都清空池,此对象更为重量级,通常用来创建那种偶尔需要清空的池,比方说:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
NSArray *databaseRecords = /* ... */;
NSMutableArray *people = [NSMutableArray new];
int i = 0;

NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
for (NSDictionary *record in databaseRecords) {
EOCPerson *person = [[EOCPerson alloc] initWithRecord:record];
[people addObject:person];

// Drain the pool only every 10 cycles
if (++i == 10) {
[pool drain];
i = 0;
}
}

// Also drain at the end in case the loop is not a multiple of 10
[pool drain];

现在不需要再这样写代码了。采用随着 ARC 所引入的新语法,可以创建出更为轻量级的 Autorelease Pool。原来所写的代码可能会每执行 n 次循环清空一次 Autorelease Pool,现在可以改用 @autoreleasepool {} 把 for 循环中的语句包起来,这样的话,每次执行循环时都会建立并清空 Autorelease Pool。

@autoreleasepool {} 语法还有个好处:每个 Autorelease Pool 均有其范围,可以避免无意间误用了那些在清空池后已为系统所回收的对象。比方说,考虑下面这段采用旧式写法的代码:

1
2
3
4
NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
id object = [self createObject];
[pool drain];
[self useObject:object];

这样写虽然稍显夸张,但却能说明问题。调用 useObject: 方法时所传入的那个对象,可能已经为系统所回收了。同样的代码改用新式写法就变成了:

1
2
3
4
@autoreleasepool {
id object = [self createObject];
}
[self useObject:object];

这次根本就无法编译,因为 object 变量出了 Autorelease Pool 的外围后就不可用了,所以在调用 useObject: 方法时不能用它做参数。

NSThread、NSRunLoop 和 NSAutoreleasePool

根据苹果官方文档中对 NSRunLoop 的描述,我们可以知道每一个线程,包括主线程,都会拥有一个专属的 NSRunLoop 对象,并且会在有需要的时候自动创建。

在主线程的 NSRunLoop 对象(在系统级别的其他线程中应该也是如此,比如通过 dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0) 获取到的线程)的每个(事件循环)Event Loop 开始前,系统会自动创建一个 Autorelease Pool ,并在 Event Loop 结束时 drain 。

另外,NSAutoreleasePool 中还提到,每一个线程都会维护自己的 Autorelease Pool 堆栈。换句话说 Autorelease Pool 是与线程紧密相关的,每一个 Autorelease Pool 只对应一个线程。

弄清楚 NSThreadNSRunLoopNSAutoreleasePool 三者之间的关系可以帮助我们从整体上了解 Objective-C 的内存管理机制

使用场景

  1. 你编写是命令行工具的代码,而不是基于 UI 框架的代码
  2. 你需要写一个循环,里面会创建很多临时的对象
    • 这时候你可以在循环内部的代码块里使用一个 @autoreleasepool {},这样这些对象就能在一次迭代完成后被释放掉。这种方式可以降低内存最大占用
  3. 当你大量使用辅助线程
    • 你需要在线程的任务代码中创建自己的 @autoreleasepool {}

要点

  • Autorelease Pool 排布在栈中,对象收到 autorelease 消息后,系统将其放入最顶端的池里。
  • 合理运用 Autorelease Pool,可降低应用程序的内存峰值。
  • @autoreleasepool {} 这种新式写法能创建出更为轻便的 Autorelease Pool。

参考