autoreleasepool 为什么不在每次都释放,而是在一次 runloop 后释放

这是一个非常经典且触及 iOS 内存管理核心机制的问题。

简单直接的回答是:为了在“跨作用域对象生命周期延续”和“性能开销”之间取得最佳平衡。

autorelease 的目标不是让“所有对象都等一轮 RunLoop 才释放”,而是为非拥有返回值和临时对象提供一个延迟发送 release 的边界。主线程事件循环恰好会维护外层自动释放池,所以很多 autoreleased 对象会在 RunLoop 进入等待前被清理;嵌套的 @autoreleasepool、框架内部池和后台线程会形成更早或不同的释放边界。

下面从 设计逻辑(生命周期)性能考量(开销)底层实现(RunLoop 机制) 三个维度详细解析。


1. 逻辑层面:解决“跨作用域”的生命周期问题

这是 最本质的存在意义:延迟释放

如果我们在一个方法内部创建对象并希望将其作为返回值给调用方使用:

- (NSString *)createString {
    NSString *str = [NSString stringWithFormat:@"Hello"];
    return str;
}
  • 在 MRC 所有权约定下: 不以 new/copy/alloc/mutableCopy 开头的方法通常返回调用方不拥有的对象,常通过 autorelease 让它在返回后继续有效。直接在 return 前把最后一个所有权释放掉,确实可能产生悬空指针。

  • 如果不释放: 谁来负责释放?如果由调用方负责,不符合“谁创建谁释放”的原则,且代码会变得极度复杂。

  • ARC 下的情况: 编译器根据方法命名和返回值约定插入 retain/release,并可使用 objc_autoreleaseReturnValue / objc_retainAutoreleasedReturnValue 一类握手机制消除不必要的池往返。因此“能返回对象”不等于每次都必须真的进入池,更不能固定承诺活到当前事件循环结束。

2. 性能层面:降低频繁创建/销毁 Pool 的开销

如果在这个极端假设下:每次 autorelease 甚至每个小作用域都进行一次 Pool 的 Push 和 Pop,开销是巨大的。

为什么会有开销?

在底层(ObjC Runtime),autoreleasepool 是基于 AutoreleasePoolPage双向链表实现的。

  • objc_autoreleasePoolPush():需要查找当前 Page,可能需要开辟新内存页,压入哨兵对象(SENTINEL)。

  • objc_autoreleasePoolPop():需要遍历链表,向池中所有对象发送 release 消息,直到遇到哨兵对象,并回收空页。

RunLoop 的批处理策略

RunLoop 一次迭代可能处理多个已经就绪的 Source、Timer、Block 和主队列任务,不严格等于一个事件,也不等于一帧。在这一批工作中可能产生许多 autoreleased 临时对象。

  • 不合理的做法: 为这 1000 个对象创建 1000 次 Pool 或执行 1000 次清理逻辑。

  • RunLoop 的做法:

    1. 事件开始前:Push 一个 Pool。

    2. 处理事件:所有产生的临时对象都扔进这个 Pool(只是简单的指针入栈操作,极快)。

    3. 事件处理完(准备休眠):Pop 这个 Pool,一次性批量释放这 1000 个对象。

这种按事件循环边界批量清理的策略减少了过细的池边界,也控制了临时对象的存活范围。但 UIKit 的具体 Observer 顺序属于实现细节,不应把它描述成严格的“一帧一清”。


3. 底层实现:RunLoop 是如何配合的?

系统在主线程的 RunLoop 中注册了两个关键的 Observer,用来监听 RunLoop 的状态变化,从而控制 AutoreleasePool 的 Push 和 Pop。

我们可以把 RunLoop 的一圈看作以下过程:

  1. 监测 Entry (kCFRunLoopEntry):

    • RunLoop 刚进入。

    • 操作: 调用 _objc_autoreleasePoolPush()

    • 目的: 为接下来可能发生的所有事件处理创建一个“兜底”的池子。

  2. 监测 BeforeWaiting (kCFRunLoopBeforeWaiting):

    • 关键点! 此时 RunLoop 已经处理完这一轮的所有事件(Source0/Source1/Timer/Block),准备进入休眠(Sleep)等待下一次唤醒。

    • 操作:

      1. 调用 _objc_autoreleasePoolPop():释放旧池子,清理这一轮产生的脏数据(内存)

      2. 调用 _objc_autoreleasePoolPush():创建一个新池子,为下一次唤醒做准备。

  3. 监测 Exit (kCFRunLoopExit):

    • RunLoop 退出。

    • 操作: 调用 _objc_autoreleasePoolPop(),释放最后的内存。

示意图

代码段

graph TD A[RunLoop Start] --> B(Entry Push Pool) B --> C{Processing Event} C -->|Generate Objects| D[Objects added to Pool] D --> C C -->|Finished| E(BeforeWaiting: Pop old & Push new) E --> F[Sleep / Wait for Event] F -->|Wake Up| C

4. 特殊情况:什么时候我们需要手动干预?

既然 RunLoop 会帮我们释放,为什么我们有时候还需要手写 @autoreleasepool {}

答案是:降低内存峰值 (High Memory Watermark)。

如果你的代码在一个 RunLoop 循环内(例如一个巨大的 for 循环)生成了大量临时对象(比如处理 10,000 张图片),这些对象默认要等到循环彻底结束、RunLoop 准备休眠时才会被释放。

在此期间,内存会持续飙升,可能触发 OOM (Out Of Memory) 崩溃。

// 错误示范:内存可能瞬间飙升
for (int i = 0; i < 10000; i++) {
    NSData *data = [NSData dataWithContentsOfFile:path];
    [self process:data];
    // 临时对象和框架内部对象可能继续留在当前外层池
}

// 正确示范:手动介入
for (int i = 0; i < 10000; i++) {
    @autoreleasepool {
        NSData *data = [NSData dataWithContentsOfFile:path];
        [self process:data];
    } // 这里会排空内层池;对象若还有其他强引用,当然不会销毁
}

总结

autoreleasepool 选择在 RunLoop 一次迭代结束后释放,而不是每次释放,是因为:

  1. 所有权语义上: autorelease 为调用方不拥有的返回值提供延迟 release 机制;ARC 还会通过编译器与 Runtime 协作优化这条路径。

  2. 性能上: 利用 RunLoop 的休眠机制进行批处理,避免了高频创建/销毁 Pool 的 CPU 开销。