将来的自己,会感谢现在努力的自己!

0%

AutoreleasePool底层原理

AutoreleasePool 底层原理(逻辑清晰版)

一、总览:AutoreleasePool 是什么?

1. autoreleasepool 是什么?

@autoreleasepool 是现代 Objective-C / Swift 中管理自动释放池的唯一语法。它的本质是编译器提供的一个语法糖,编译时会被无条件地展开成对 objc_autoreleasePoolPush()objc_autoreleasePoolPop() 两个运行时函数的结构化调用,并保证栈平衡,最终操作由 AutoreleasePoolPage 类实现。
在 ARC 时代,NSAutoreleasePool 作为对象已被弃用,你无法再向它发送 allocdrain 等消息。现在的“AutoreleasePool”是指 AutoreleasePoolPage 这个 C++ 类所管理的、存在于每个线程中的双向链表栈,而非一个具体的对象实例。这就是为什么早期资料会混用这两个术语的原因。
我们用 clang -rewrite-objc 命令看一下 @autoreleasepool {} 的真实面目。大段的 Objective-C 代码会被重写为 C++ 代码,而其中的自动释放池块,会被编译成一个非常简单的结构体实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 在重写后的 .cpp 文件中,原来的 @autoreleasepool {} 被替换为以下内容
struct __AtAutoreleasePool {
// 构造函数:在作用域开始时调用 Push
__AtAutoreleasePool() { atautoreleasepoolobj = objc_autoreleasePoolPush(); }
// 析构函数:在作用域结束时调用 Pop
~__AtAutoreleasePool() { objc_autoreleasePoolPop(atautoreleasepoolobj); }
void * atautoreleasepoolobj;
};

// 在函数中,编译后的代码变成了这样:
{
__AtAutoreleasePool __autoreleasepool;
// ... 原本在 @autoreleasepool {} 内部的代码 ...
}

这个结构体使用 C++ 的 RAII(Resource Acquisition Is Initialization) 机制,在实例创建和销毁时精确调用了 Push 和 Pop 函数。这一切都由编译器自动完成,不依赖于 ARC 是否开启。


2. Page 是什么?

AutoreleasePoolPage 是一个 C++ 类,每个实例的大小固定为 4096 字节(与虚拟内存页大小一致)。它通过双向链表连接,共同组成某个线程的“自动释放池栈”。

@autoreleasepool 是 Objective‑C 中用于管理临时对象生命周期的语法糖。它能够将对象延迟释放到作用域结束,避免在大量临时对象的场景下频繁创建/释放对象导致性能下降。

核心机制:每个线程维护一个由 AutoreleasePoolPage 组成的双向链表,每个 Page 是一个固定大小的内存栈。autorelease 调用将对象压入当前栈顶,@autoreleasepool 则对应一次“压入哨兵 + 后续弹出(释放到哨兵为止)”的操作。


二、核心数据结构:AutoreleasePoolPage

2.1 Page 的大小与布局

  • 每个 Page 大小为 4096 字节(一个虚拟内存页)。
  • Page 开头是一个 AutoreleasePoolPageData 结构体,存储元信息。
  • 剩余空间用作,栈地址从高向低增长(即 next 指针从高地址向低地址移动)。
    (实际实现中,next 指针从 page 的末尾向前移动,但逻辑上可以理解为一个普通的栈)

2.2 AutoreleasePoolPageData 关键成员

成员 类型 作用
magic magic_t 校验 Page 完整性
next id * 指向当前 Page 中下一个可存放对象的位置(栈顶)
thread pthread_t 绑定的线程
parent AutoreleasePoolPage * 前一个 Page
child AutoreleasePoolPage * 后一个 Page
depth uint32_t Page 深度(第几个 Page)
  • parent + child 形成双向链表
  • 当前正在使用的 Page 称为 hotPage(链表尾)。

2.3 栈内存区域

  • 从 Page 内某起始地址开始,next 指向下一个空闲位置
  • 每次压入一个对象(id 指针)或哨兵(POOL_BOUNDARY),next 向低地址移动一个位置。

三、哨兵 POOL_BOUNDARY

3.1 定义

POOL_BOUNDARY 是一个 nil 指针(实际值为 0),它不是一个真实对象。

3.2 作用

  • 标记一个 @autoreleasepool起始边界
  • pop 时,从当前栈顶开始向下释放对象,直到遇到这个哨兵为止。
    哨兵本身不参与 release,只是作为停止标志。

3.3 为什么多个池可以共存?

  • 每个 push 会在栈中压入一个新的 POOL_BOUNDARY
  • 嵌套池时,多个哨兵同时存在于栈中,按 push 顺序排列。
  • 栈的后进先出特性保证了内层池先 pop,不会破坏外层池的边界。

四、pushpop 的流程

4.1 objc_autoreleasePoolPush

1
void *token = objc_autoreleasePoolPush();

内部步骤

  1. 获取当前线程的 hotPage(通过 TLS)。
  2. 如果没有 hotPage,创建第一个 Page。
  3. 调用 hotPage->add(POOL_BOUNDARY)
    • 若当前 Page 有空位,直接将 POOL_BOUNDARY 压入栈中,next 指针前移。
    • 若当前 Page 已满,调用 autoreleaseFullPage 创建新 Page 链接到链表尾,设为新 hotPage,然后在新 Page 中压入 POOL_BOUNDARY
  4. 返回此 POOL_BOUNDARY 的内存地址作为 token。

token 的本质:一个指向当前池边界哨兵位置的指针。

4.2 objc_autoreleasePoolPop(token)

1
objc_autoreleasePoolPop(token);

内部步骤

  1. 根据 token 的地址,通过 pageForPointer(token) 找到 token 所在的 Page(称为 stopPage)和在该 Page 中的偏移(stop 指针)。
  2. 获取当前 hotPagehotPage->next(即栈顶位置)。
  3. 从栈顶开始,向栈底方向逐个 release 对象
    • 如果 hotPage 不是 stopPage,说明当前 Page 中的所有对象都在 token 之后,因此释放当前 Page 中的所有对象,将 Page 清空(next 重置到 Page 起始位置),然后通过 parent 指针切换到前一个 Page,继续释放。
    • 一旦进入 stopPage,从 stopPage->next 开始向下释放直到遇到 stop(即 token 位置).注意:不包括 token 本身,因为 token 不是对象
  4. stopPage->next 设置为 stop(即 token 的位置),表示该池内的内存已被清空,但哨兵依然存在(以备复用)。
  5. 如果某个 Page 被完全清空且不是最前一个 Page,系统可能会将其从链表中解下并释放,以减少内存占用(但在当前实现中,Page 通常会被保留以复用)。

4.3 关键点总结

  • pop 的起始位置永远是调用那一刻的 hotPage->next(当前栈顶)
  • pop 的结束位置由 token 指定,且 token 在栈中的位置必须位于当前栈顶的下方(否则会因 LIFO 违规而 crash)。
  • 所有对象只属于嵌套层中最内层的那个池,因为该池的 pop 会将它们释放。

五、嵌套池的完整示例(栈状态变化)

1
2
3
4
5
6
7
8
9
@autoreleasepool {                // 池 A
id a = [[NSObject alloc] init];
[a autorelease]; // 压入 a

@autoreleasepool { // 池 B
id b = [[NSObject alloc] init];
[b autorelease]; // 压入 b
} // 池 B 结束
} // 池 A 结束

栈状态变化(地址从低到高)

操作 栈内容(低地址→高地址) next 位置 说明
push 池 A [S_A] S_A 之上 S_A 是哨兵
autorelease a [S_A, a] a 之上
push 池 B [S_A, a, S_B] S_B 之上 S_B 是新哨兵
autorelease b [S_A, a, S_B, b] b 之上
pop 池 B [S_A, a, S_B] S_B 之上 释放 b,栈顶回退到 S_B 之后
pop 池 A [S_A] S_A 之上 释放 aS_B(哨兵只是地址),栈顶回到 S_A 之后

重要

  • 池 B 的 pop 并不会影响 a,因为 aS_B 的下方。
  • 池 A 的 pop 开始时,栈顶已经在 S_B 之后(但 S_B 仍然存在,直到被外层 pop 越过)。

六、与线程和 RunLoop 的关系

6.1 每个线程独立的 AutoreleasePool 栈

  • AutoreleasePoolPagethread 成员绑定线程。
  • TLS(Thread Local Storage)存储当前线程的 hotPage 指针。
  • 子线程默认没有 AutoreleasePool,需要手动创建 @autoreleasepool,否则 autorelease 的对象会泄漏(因为没有任何池会在线程退出时自动释放它们)。

6.2 RunLoop 自动管理的池

  • 主线程的 RunLoop 在每次迭代开始时(kCFRunLoopBeforeWaiting)会创建 AutoreleasePool,在迭代结束前(kCFRunLoopAfterWaiting 之后)销毁。
  • 因此,在主线程的事件处理代码中(如 UI 操作、触摸回调),不需要手动添加 @autoreleasepool,系统已经为你加了。
  • 什么时候需要手动添加?
    • 子线程中执行循环或大量产生临时对象的代码。
    • for 循环中创建大量 autorelease 对象,避免峰值内存过高。

6.3 RunLoop 调用时机

  • 主线程的 RunLoop 会自动管理自动释放池,无需开发者手动添加 @autoreleasepool
  • 这是通过在主线程 RunLoop 中注册两个 Observer 实现的,它们在特定的 RunLoop 状态变化时,调用 objc_autoreleasePoolPush()objc_autoreleasePoolPop()
  • 这两个 Observer 的优先级分别设为最高和最低,以确保池的创建在事件处理之前,池的清理在事件处理之后。

Observer 1:监听 kCFRunLoopEntry(即将进入 RunLoop)

  • 触发时机:RunLoop 开始新一轮迭代,尚未处理任何事件(触摸、定时器、输入源等)。
  • 执行动作:调用 objc_autoreleasePoolPush()
    • 该函数在当前线程的 AutoreleasePoolPage 栈顶压入一个 哨兵(POOL_BOUNDARY,即 nil
    • 这个哨兵马一个“自动释放池”的起始边界。
    • 返回哨兵的地址作为 token。
  • 优先级-2147483647(最高)。
  • 目的:确保在处理任何事件之前,已经有一个“活跃的池”可以容纳事件中产生的 autorelease 对象。

注意push() 只压入哨兵,不压入任何业务对象。业务对象是通过后续的 autorelease 调用逐个压入的。


Observer 2:监听 kCFRunLoopBeforeWaitingkCFRunLoopExit

① 监听 kCFRunLoopBeforeWaiting(即将进入休眠)
  • 触发时机:当前迭代的所有事件(触摸、定时器、Source0 等)都已处理完毕,RunLoop 将要进入睡眠状态,等待新的事件。
  • 执行动作
    1. 调用 objc_autoreleasePoolPop(token) —— 释放当前池内的所有 autorelease 对象。
      • 从当前栈顶开始,向低地址方向逐个对对象发送 release 消息,直到遇到当前池的哨兵为止。
      • 哨兵本身不是对象,不会被释放。
      • 释放完成后,栈顶指针被回拨到哨兵位置。
    2. 立即调用 objc_autoreleasePoolPush() —— 压入一个新的哨兵,作为下一个迭代周期的池边界。
  • 优先级2147483647(最低)。
  • 目的:及时回收本轮事件产生的临时对象,避免内存堆积;同时为下一轮事件准备好一个干净的池。
② 监听 kCFRunLoopExit(即将退出 RunLoop)
  • 触发时机:RunLoop 即将永久退出(主线程极少发生,通常在 App 终止时)。
  • 执行动作仅调用 objc_autoreleasePoolPop(token),释放当前池内的所有对象。
  • 不创建新池,因为 RunLoop 即将结束。

一次完整迭代的池变化示例

1
2
3
4
5
6
7
8
9
1. Entry → push()          → 压入哨兵 A(池 A 开始)
2. 处理事件(触摸、Timer 等)→ 事件中的 autorelease 对象被压入池 A 的上方
3. BeforeWaiting → pop(池 A) → 释放池 A 中的所有对象
→ push() → 压入哨兵 B(池 B 开始)
4. 休眠,等待新事件
5. 新事件到达 → 事件中的 autorelease 对象被压入池 B
6. BeforeWaiting → pop(池 B) → 释放池 B 中的所有对象
→ push() → 压入哨兵 C(池 C 开始)
... 循环往复


关键澄清(避免迷惑)

常见(但不精确)的说法 准确的描述
push() 创建了一个新池” push() 在栈顶压入一个哨兵,标记一个新池的边界。池本身不是一个对象,只是一段范围。
pop() 销毁了池” pop() 释放池内的所有对象,并将栈顶指针回拨到哨兵位置。池的“边界”被移除,但 Page 和哨兵位置仍然存在(会被后续 push 覆盖)。
“RunLoop 会创建和销毁自动释放池” RunLoop 通过 Observer 调用 pushpop管理池的边界,实际存储容器(AutoreleasePoolPage)会一直被复用,直到线程销毁。

子线程的注意事项

  • 子线程的 RunLoop 默认不会自动运行,系统也不会为子线程注册自动释放池的 Observer。
  • 因此,在子线程中,必须手动使用 @autoreleasepool 来管理临时对象的释放,否则会产生内存泄漏。
  • 即使手动启动了子线程的 RunLoop,它也不会自动获得 Observer;仍需依赖 @autoreleasepool 或手动添加 Observer。

总结表

RunLoop 状态 系统动作 实际效果
kCFRunLoopEntry push()(压哨兵) 标记新池的开始
kCFRunLoopBeforeWaiting pop() + push() 释放当前池内的所有对象;标记下一个池的开始
kCFRunLoopExit pop() 释放当前池内的所有对象,不再创建新池

一句话总结:主线程 RunLoop 通过最高优先级的 Observer 在 Entry 时压入哨兵,通过最低优先级的 Observer 在 BeforeWaiting 时先释放当前池内对象再压入新哨兵,从而自动化管理 autorelease 对象的生命周期。


七、常见误区澄清

误区 1:@autoreleasepool 会立即释放所有 autorelease 对象

  • 正解:它只释放该池范围内(即从 pushpop 之间)被 autorelease 的对象。外层池的对象不受影响。

误区 2:push 返回的 token 是 NSAutoreleasePool 的实例

  • 正解NSAutoreleasePool 在 ARC 下已被废弃(但底层依然使用相同机制)。token 只是一个指向哨兵的指针,不是对象。

误区 3:pop 会从 token 位置开始释放

  • 正解pop 从当前的栈顶开始,一直释放到 token 为止。token 定义的是终点,不是起点。

误区 4:所有 Page 在一次 pop 后都会被销毁

  • 正解:Page 通常被保留并复用,只有在线程销毁或极度内存压力下才会释放整个链表。

八、性能考量(为什么要用 AutoreleasePool)

优势

  • 将多个对象的释放合并到一次 pop 操作,减少全局引用计数操作的次数。
  • 对于大量临时对象(如循环中创建的 NSString),用 @autoreleasepool 包裹可以显著降低峰值内存。

劣势

  • 对象存活时间延长,可能导致内存峰值高于立即释放。
  • 过多的嵌套池会增加栈深度,但通常影响很小。

九、调试与验证方法

9.1 LLDB 观察栈变化

objc_autoreleasePoolPushobjc_autoreleasePoolPop 处设置断点:

1
2
(lldb) b objc_autoreleasePoolPush
(lldb) b objc_autoreleasePoolPop

然后打印当前线程的 hotPage->nexthotPage->parent

9.2 使用私有函数(仅用于调试)

1
extern void _objc_autoreleasePoolPrint(void);

调用后可以打印当前线程的 autorelease 栈内容,帮助理解嵌套关系。


十、总结

概念 解释
AutoreleasePoolPage 4096 字节的页,包含栈区域和元信息,多个 Page 组成双向链表
POOL_BOUNDARY 哨兵(nil),标记一个池的起始边界
push 压入哨兵,返回哨兵地址(token)
pop 从栈顶释放对象直到遇见 token 对应的哨兵
嵌套 多个哨兵共存,LIFO 顺序保证正确释放
线程 每个线程有独立的链表,通过 TLS 存储 hotPage
RunLoop 主线程自动在每个迭代中创建/销毁池

一句话记忆
@autoreleasepool 就是在线程的栈上压一个哨兵,等到作用域结束,就从栈顶一直释放到那个哨兵为止。