内存管理之 Objective-C AutoReleasePool
关于 Objective-C 的 AutoReleasePool 的机制,网上有非常多的文章对源码进行了详尽的剖析。不过,这些文章大多迷失在代码细节之中,没有从整体设计来进行介绍。本文,我们将从正向设计的角度来进行介绍。
这里我们先预设两个问题,后续再进行解答。
- 为什么 AutoReleasePool 底层是双向链表结构?
- 为什么 AutoReleasePool 采用基于 AutoReleasePoolPage 的分页机制?
栈结构
一切要从栈开始说起。栈(Stack)是一种遵循先进后出(FILO,First In Last Out)的逻辑数据结构。栈有两个重要属性:栈底指针、栈顶指针。栈的底层实现一般有两种,分别是:
- 基于数组实现
- 基于链表实现
基于数组的栈
基于数组实现的栈结构,一般会使用动态数组来存储元素。
对于栈底指针,本质上保存了一个指向数组头部元素的索引;对于栈顶指针,也会维持一个指向数组尾部元素的索引,当执行 push 操作时,索引加一,当执行 pop 操作时,索引减一。
要注意的是,动态数组初始化时会分配一定大小的连续内存空间,当栈空间不足时(本质上是底层动态数组空间不足),会重新分配一段更大的连续内存空间,将原来的元素复制到新的内存空间中,并释放旧的内存空间。
基于链表的栈
基于链表的栈结构,是绝大多数标准库的选择,比如:C++ STL 中的 stack 就是基于 list 实现的,这里的 list 是一个双向链表。
为什么要使用双向链表?根本原因在于栈顶指针的控制。当执行 push 操作时,必须要正向延伸序列,此时需要有一个指向正方向的指针;当执行 pop 操作时,必须要反向回缩序列,此时则需要一个指向反方向的指针。对此,双向链表完美地契合了这个要求。
那么为什么栈结构大多数使用双向链表实现,而不是动态数组呢?根本原因在于动态数组要求占用一段连续的内存空间,对于内存提出了更高的要求;而且当分配的内存空间不足时,需要重新分配一段更大的内存空间,同时还要拷贝原始数据,产生了更大的性能开销。
函数栈帧
如下所示是 YYImage 中的一个 AutoReleasePool 的使用示例,使用大括号管理其作用域下的对象,这与函数(或方法)使用大括号管理其作用域下的局部变量非常相似。
1 | @autoreleasepool { |
对此,我们来回顾一下函数栈帧的基本工作原理。当函数运行时,进程的栈空间中会为其创建一个对应的栈帧(Stack Frame)来记录运行时产生的相关信息,当函数返回时则会销毁对应的栈帧。
栈帧结构
下图所示为栈帧的通用结构。
每个栈帧会通过两个寄存器来表示其内存界限,分别是:
- EBP(Extended Base Pointer),保存当前栈帧的基址,即开始位置,主要用于访问函数的参数和局部变量。
- ESP(Extended Stack Pointer),保存当前栈顶的地址,即结束位置,主要用于进行栈操作。
栈帧内部保存运行时的相关信息,比如:
- 调用者的栈帧基址,用于恢复调用者栈帧基址
- 调用者的寄存器,用于恢复调用者的状态
- 局部变量
- 下一个函数的参数
- 返回地址,用于表示调用者调用完成后继续执行的指令地址
AutoReleasePool 内存管理
AutoReleasePool 的用法与函数调用类似,其底层实现与函数栈帧类似。AutoReleasePool 的管理是基于栈结构设计的,栈结构底层使用双向链表实现;而函数栈帧的栈结构底层则是使用动态数组实现,本质上是连续的栈空间。
设计思想
一般而言,链表节点是存储空间固定的数据结构,然而 AutoReleasePool 管理的对象数量是不固定的。那么该如何解决这个问题?
对此,提出了页面的概念——AutoReleasePoolPage。每个 AutoReleasePoolPage 可以存储固定数量的对象指针。一个 AutoReleasePool 由一个或多个 AutoReleasePoolPage 组成,其数量取决于要管理的对象数量。AutoReleasePool 底层就是以 AutoReleasePoolPage 作为链表节点实现的。
结构实现
由于一个 AutoReleasePool 由一个或多个 AutoReleasePoolPage 组成,我们很容易想到如下所示的一种直观、清晰的结构。
上图所示的结构中,一个 AutoReleasePoolPage 只属于一个 AutoReleasePool。细心的读者可能会发现,这种结构存在一个问题,即 存在内存碎片。比如:AutoReleasePoolPage 1 中,只存储了一个对象地址,剩余的空间都没有被利用。
为了解决这个问题,AutoReleasePool 的优化方法是:
- 多个 AutoReleasePool 可以共享一个 AutoReleasePoolPage
- 不同 AutoReleasePool 之间使用 哨兵对象 POOL_SENTINEL 来划分边界
通过哨兵对象,我们解决了内存碎片的问题,当然也改变了 AutoReleasePool 的边界定义。
- 当我们声明一个 AutoReleasePool 时,必须添加一个新哨兵对象,从而标识当前 AutoReleasePool 的边界。
- 当我们释放一个 AutoReleasePool 时,必须追溯最近的哨兵对象,从而找到当前 AutoReleasePool 的边界。
在此基础上,我们可以大致描绘出 AutoReleasePool 是如何管理对象的。
- 当 AutoReleasePool
中实例化一个对象时,我们根据链表的尾指针,正向遍历,查看末端的
AutoReleasePoolPage 是否未满。
- 如果未满,则将对象指针存入 AutoReleasePoolPage 中;
- 否则,链表尾部新增一个 AutoReleasePoolPage 后,再将对象指针存入其中。
- 当 AutoReleasePool 要释放全部对象时,我们根据链表的尾指针,反向遍历,找到最近的哨兵对象,并把中间部分的 AutoReleasePoolPage 、对象指针、哨兵对象全部释放。
代码分析
下面,我们通过分析 Objective-C 的 main
函数来了解
AutoReleasePool 的具体执行逻辑。main.m
中的定义如下所示,可以看出整个应用被包含在了一个 AutoReleasePool
中。
1 | int main(int argc, char * argv[]) { |
@autoreleasepool
我们使用 clang
将 main.m
重写转换成 C++
实现,如下所示。
1 | $ xcrun --sdk iphonesimulator clang -rewrite-objc main.m |
经过转换后会生成一个 main.cpp
文件,main
函数的内部实现如下所示。
1 | int main(int argc, char * argv[]) { |
其中,__AtAutoreleasePool
结构的定义如下所示,构造函数执行了 objc_autoreleasePoolPush
函数,析构函数执行了 objc_autoreleasePoolPop
函数,两者分别对应 AutoReleasePool 的入栈操作和出栈操作。
1 | struct __AtAutoreleasePool { |
@autoreleasepool
关键词本质上隐藏了栈操作的管理细节,让代码更加简洁、可靠。@autoreleasepool
构造了一个作用域,在作用域的起始执行构造函数,在作用域的结尾执行析构函数。总体而言,main
函数的实现可以简化成如下所示。
1 | int main(int argc, char * argv[]) { |
AutoReleasePool
为了深入分析 AutoReleasePool 的实现,我们需要阅读 objc 的源码,详见 传送门。
在上一节,我们注意到有两个函数:objc_autoreleasePoolPush
和 objc_autoreleasePoolPop
。它们在 objc
源码中有对应的实现,如下所示。这里就涉及到了 AutoReleasePool
的底层实现原理,其核心就是 AutoReleasePoolPage
。
1 | void * |
AutoReleasePoolPage
这里我们再来看一下 AutoReleasePoolPage
的定义,如下所示。AutoReleasePoolPage
定义了两个指针
parent
和 child
,它分别对应上述图中的
prev
和
next
,作为实现双向链表的关键指针。此外,其自定义了
new
方法,内部可以分配 4096
字节的内存空间,用于存储固定属性和对象指针。
AutoReleasePoolPage
还定义了 next
指针,这个指针很重要,类似于栈顶指针,当添加或删除对象后,next
指针会更新为下一个可存储的内存地址。
1 | class AutoreleasePoolPage { |
objc_autoreleasePoolPush
objc_autoreleasePoolPush
内部调用了
AutoReleasePoolPage
的 push
方法,其实现如下所示。可以看出,push
方法的本质就是想
AutoReleasePage 中存入一个哨兵对象 POOL_SENTINEL
,用于标识
AutoReleasePool 的边界。这个过程中会遇到一些主要的分支情况,比如:
- 当前没有任何页面时,需要初始化一个页面
- 当前页面已满时,需要添加一个新页面
- 当前页面未满时,则直接存入对象指针
这些分支情况,在 push
方法中均有体现,相关逻辑详见代码具体实现。push
方法中通过
autoreleaseFast
方法存储哨兵对象。事实上,我们使用 ARC
管理对象时,所有的对象都是通过 autoreleaseFast
方法存入
AutoReleasePoolPage
中的。
hotPage 表示末尾最后一个非空页面。
1 | static inline void *push() { |
objc_autoreleasePoolPop
objc_autoreleasePoolPop
内部调用了
AutoReleasePoolPage
的 pop
方法。pop
方法的参数 token
则是当前
AutoReleasePool 的哨兵对象。
1 | static inline void pop(void *token) { |
我们可以看到对象释放的关键方法
releaseUntil
。其通过循环不断寻找非空的页面,同时对于每个页面,使用其
next
指针,逐步释放每一个对象及其内存,直到找到目标对象,即哨兵对象。
需要注意的是,releaseUntil
只是重置了其记录的对象指针,并释放了对象的内存,但是并没有释放
AutoReleasePoolPage
本身。如果 AutoReleasePool
管理的对象非常多,那岂不是有很多未使用的
AutoReleasePoolPage
没有被使用,也没有被释放吗?
对此,pop
方法中的最后部分解决了这个问题,它通过
kill
方法来释放空的页面。这里有两种情况:
- 当 hotPage 存储已过半时,仅保留一个空页面。因为页面快要满了,很快就会分配一个新的页面,为了提高性能,保存一个即将使用的空页面。
- 当 hotPage 存储未过半时,不包含任何空页面。
总结
首先,我们介绍了栈的两种实现方式,分别是数组和双向链表。
其次,我们对比了函数定义和 AutoReleasePool 定义,两者具有相似之处。作为对比,我们介绍了函数调用是如何通过栈帧管理局部变量的。进一步,我们又介绍了 AutoReleasePool 管理对象的思路。
考虑到性能问题,AutoReleasePool 不能像函数调用栈一样,通过类似数组的方式,即连续内存空间来管理对象,而是通过双向链表的方式进行管理。于是,我们进一步设想了链表节点的定义,即 AutoReleasePoolPage 的定义。
于是,我们设计了一种直观的管理方式:一个 AutoReleasePool 独享一个或多个 AutoReleasePoolPage。但是这种方式存在内存碎片问题。对此,我们重新设计了一种管理方式:一个 AutoReleasePool 可以被多个 AutoReleasePool 共享。其本质上通过哨兵对象来进行定义边界,这种设计正是 AutoReleasePool 的设计方案。
通过正向设计,我们可以深刻理解 AutoReleasePool 的设计思想及其设计原因。
最后,我们再来回顾文章开头的两个问题,想必你已经有答案了。
- 问:为什么 AutoReleasePool 底层是双向链表结构?
- 答:AutoReleasePool 的本质是通过栈来管理对象,而双向链表是应用层实现栈的一种效率最高的数据结构。
- 问:为什么 AutoReleasePool 采用基于 AutoReleasePoolPage 的分页机制?
- 答:基于双向链表结构的设计中,必须要设计一个固定结构的链表节点,AutoReleasePoolPage 正是链表节点的设计。
参考
- 函数栈帧的创建与销毁
- 函数调用过程栈帧变化详解
- 《程序员的自我修养——链接、装载与库》
- objc
- 自动释放池的前世今生 ---- 深入解析 autoreleasepool