AutoreleasePool 底层原理(逻辑清晰版)
一、总览:AutoreleasePool 是什么?
1. autoreleasepool 是什么?
@autoreleasepool 是现代 Objective-C / Swift 中管理自动释放池的唯一语法。它的本质是编译器提供的一个语法糖,编译时会被无条件地展开成对 objc_autoreleasePoolPush() 和 objc_autoreleasePoolPop() 两个运行时函数的结构化调用,并保证栈平衡,最终操作由 AutoreleasePoolPage 类实现。
在 ARC 时代,NSAutoreleasePool 作为对象已被弃用,你无法再向它发送 alloc、drain 等消息。现在的“AutoreleasePool”是指 AutoreleasePoolPage 这个 C++ 类所管理的、存在于每个线程中的双向链表栈,而非一个具体的对象实例。这就是为什么早期资料会混用这两个术语的原因。
我们用 clang -rewrite-objc 命令看一下 @autoreleasepool {} 的真实面目。大段的 Objective-C 代码会被重写为 C++ 代码,而其中的自动释放池块,会被编译成一个非常简单的结构体实例:
1 | // 在重写后的 .cpp 文件中,原来的 @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,不会破坏外层池的边界。
四、push 和 pop 的流程
4.1 objc_autoreleasePoolPush
1 | void *token = objc_autoreleasePoolPush(); |
内部步骤:
- 获取当前线程的
hotPage(通过 TLS)。 - 如果没有
hotPage,创建第一个 Page。 - 调用
hotPage->add(POOL_BOUNDARY):- 若当前 Page 有空位,直接将
POOL_BOUNDARY压入栈中,next指针前移。 - 若当前 Page 已满,调用
autoreleaseFullPage创建新 Page 链接到链表尾,设为新hotPage,然后在新 Page 中压入POOL_BOUNDARY。
- 若当前 Page 有空位,直接将
- 返回此
POOL_BOUNDARY的内存地址作为 token。
token 的本质:一个指向当前池边界哨兵位置的指针。
4.2 objc_autoreleasePoolPop(token)
1 | objc_autoreleasePoolPop(token); |
内部步骤:
- 根据 token 的地址,通过
pageForPointer(token)找到 token 所在的 Page(称为stopPage)和在该 Page 中的偏移(stop指针)。 - 获取当前
hotPage和hotPage->next(即栈顶位置)。 - 从栈顶开始,向栈底方向逐个 release 对象:
- 如果
hotPage不是stopPage,说明当前 Page 中的所有对象都在 token 之后,因此释放当前 Page 中的所有对象,将 Page 清空(next重置到 Page 起始位置),然后通过parent指针切换到前一个 Page,继续释放。 - 一旦进入
stopPage,从stopPage->next开始向下释放直到遇到stop(即 token 位置).注意:不包括 token 本身,因为 token 不是对象。
- 如果
- 将
stopPage->next设置为stop(即 token 的位置),表示该池内的内存已被清空,但哨兵依然存在(以备复用)。 - 如果某个 Page 被完全清空且不是最前一个 Page,系统可能会将其从链表中解下并释放,以减少内存占用(但在当前实现中,Page 通常会被保留以复用)。
4.3 关键点总结
pop的起始位置永远是调用那一刻的hotPage->next(当前栈顶)。pop的结束位置由token指定,且 token 在栈中的位置必须位于当前栈顶的下方(否则会因 LIFO 违规而 crash)。- 所有对象只属于嵌套层中最内层的那个池,因为该池的
pop会将它们释放。
五、嵌套池的完整示例(栈状态变化)
1 | @autoreleasepool { // 池 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 之上 | 释放 a 和 S_B(哨兵只是地址),栈顶回到 S_A 之后 |
重要:
- 池 B 的
pop并不会影响a,因为a在S_B的下方。 - 池 A 的
pop开始时,栈顶已经在S_B之后(但S_B仍然存在,直到被外层pop越过)。
六、与线程和 RunLoop 的关系
6.1 每个线程独立的 AutoreleasePool 栈
AutoreleasePoolPage的thread成员绑定线程。- 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:监听 kCFRunLoopBeforeWaiting 和 kCFRunLoopExit
① 监听 kCFRunLoopBeforeWaiting(即将进入休眠)
- 触发时机:当前迭代的所有事件(触摸、定时器、Source0 等)都已处理完毕,RunLoop 将要进入睡眠状态,等待新的事件。
- 执行动作:
- 调用
objc_autoreleasePoolPop(token)—— 释放当前池内的所有autorelease对象。- 从当前栈顶开始,向低地址方向逐个对对象发送
release消息,直到遇到当前池的哨兵为止。 - 哨兵本身不是对象,不会被释放。
- 释放完成后,栈顶指针被回拨到哨兵位置。
- 从当前栈顶开始,向低地址方向逐个对对象发送
- 立即调用
objc_autoreleasePoolPush()—— 压入一个新的哨兵,作为下一个迭代周期的池边界。
- 调用
- 优先级:
2147483647(最低)。 - 目的:及时回收本轮事件产生的临时对象,避免内存堆积;同时为下一轮事件准备好一个干净的池。
② 监听 kCFRunLoopExit(即将退出 RunLoop)
- 触发时机:RunLoop 即将永久退出(主线程极少发生,通常在 App 终止时)。
- 执行动作:仅调用
objc_autoreleasePoolPop(token),释放当前池内的所有对象。 - 不创建新池,因为 RunLoop 即将结束。
一次完整迭代的池变化示例
1 | 1. Entry → push() → 压入哨兵 A(池 A 开始) |

关键澄清(避免迷惑)
| 常见(但不精确)的说法 | 准确的描述 |
|---|---|
“push() 创建了一个新池” |
push() 在栈顶压入一个哨兵,标记一个新池的边界。池本身不是一个对象,只是一段范围。 |
“pop() 销毁了池” |
pop() 释放池内的所有对象,并将栈顶指针回拨到哨兵位置。池的“边界”被移除,但 Page 和哨兵位置仍然存在(会被后续 push 覆盖)。 |
| “RunLoop 会创建和销毁自动释放池” | RunLoop 通过 Observer 调用 push 和 pop 来管理池的边界,实际存储容器(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 对象
- 正解:它只释放该池范围内(即从
push到pop之间)被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_autoreleasePoolPush 和 objc_autoreleasePoolPop 处设置断点:
1 | (lldb) b objc_autoreleasePoolPush |
然后打印当前线程的 hotPage->next 和 hotPage->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 就是在线程的栈上压一个哨兵,等到作用域结束,就从栈顶一直释放到那个哨兵为止。