内存管理之 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
2
3
4
5
6
7
8
9
10
@autoreleasepool {
UIImage *newImage = [UIImage imageWithCGImage:imageRef];
if (newImage) {
if (hasAlpha) {
data = UIImagePNGRepresentation([UIImage imageWithCGImage:imageRef]);
} else {
data = UIImageJPEGRepresentation([UIImage imageWithCGImage:imageRef], 0.9); // same as Apple's example
}
}
}

对此,我们来回顾一下函数栈帧的基本工作原理。当函数运行时,进程的栈空间中会为其创建一个对应的栈帧(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
2
3
4
5
6
7
8
int main(int argc, char * argv[]) {
NSString * appDelegateClassName;
@autoreleasepool {
// Setup code that might create autoreleased objects goes here.
appDelegateClassName = NSStringFromClass([AppDelegate class]);
}
return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}

@autoreleasepool

我们使用 clangmain.m 重写转换成 C++ 实现,如下所示。

1
$ xcrun --sdk iphonesimulator clang -rewrite-objc main.m

经过转换后会生成一个 main.cpp 文件,main 函数的内部实现如下所示。

1
2
3
4
5
6
7
int main(int argc, char * argv[]) {
NSString * appDelegateClassName;
/* @autoreleasepool */ { __AtAutoreleasePool __autoreleasepool;
appDelegateClassName = NSStringFromClass(((Class (*)(id, SEL))(void *)objc_msgSend)((id)objc_getClass("AppDelegate"), sel_registerName("class")));
}
return UIApplicationMain(argc, argv, __null, appDelegateClassName);
}

其中,__AtAutoreleasePool 结构的定义如下所示,构造函数执行了 objc_autoreleasePoolPush 函数,析构函数执行了 objc_autoreleasePoolPop 函数,两者分别对应 AutoReleasePool 的入栈操作和出栈操作。

1
2
3
4
5
6
7
8
9
struct __AtAutoreleasePool {
__AtAutoreleasePool() {
atautoreleasepoolobj = objc_autoreleasePoolPush();
}
~__AtAutoreleasePool() {
objc_autoreleasePoolPop(atautoreleasepoolobj);
}
void * atautoreleasepoolobj;
};

@autoreleasepool 关键词本质上隐藏了栈操作的管理细节,让代码更加简洁、可靠。@autoreleasepool 构造了一个作用域,在作用域的起始执行构造函数,在作用域的结尾执行析构函数。总体而言,main 函数的实现可以简化成如下所示。

1
2
3
4
5
6
7
8
9
10
11
int main(int argc, char * argv[]) {
NSString * appDelegateClassName;
/* @autoreleasepool */ {
atautoreleasepoolobj = objc_autoreleasePoolPush();

// do anything

objc_autoreleasePoolPop(atautoreleasepoolobj);
}
return UIApplicationMain(argc, argv, __null, appDelegateClassName);
}

AutoReleasePool

为了深入分析 AutoReleasePool 的实现,我们需要阅读 objc 的源码,详见 传送门

在上一节,我们注意到有两个函数:objc_autoreleasePoolPushobjc_autoreleasePoolPop。它们在 objc 源码中有对应的实现,如下所示。这里就涉及到了 AutoReleasePool 的底层实现原理,其核心就是 AutoReleasePoolPage

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

void
objc_autoreleasePoolPop(void *ctxt) {
if (UseGC) return;
AutoreleasePoolPage::pop(ctxt);
}

AutoReleasePoolPage

这里我们再来看一下 AutoReleasePoolPage 的定义,如下所示。AutoReleasePoolPage 定义了两个指针 parentchild,它分别对应上述图中的 prevnext,作为实现双向链表的关键指针。此外,其自定义了 new 方法,内部可以分配 4096 字节的内存空间,用于存储固定属性和对象指针。

AutoReleasePoolPage 还定义了 next 指针,这个指针很重要,类似于栈顶指针,当添加或删除对象后,next 指针会更新为下一个可存储的内存地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class AutoreleasePoolPage {
PAGE_MAX_SIZE; // 最大 size 4096 字节
magic_t const magic; // 用来校验 AutoreleasePoolPage 的结构是否完整
id *next; // 指向下一个即将产生的 autoreleased 对象的存放位置,类似于栈顶指针
pthread_t const thread; // 指向当前线程,一个 AutoreleasePoolPage 只会对应一个线程,但一个线程可以对应多个 AutoreleasePoolPage;
AutoreleasePoolPage * const parent; // 指向父节点,第一个节点的 parent 值为 nil;
AutoreleasePoolPage *child; // 指向子节点,最后一个节点的 child 值为 nil;
uint32_t const depth; // 表示深度,第一个 page 的 depth 为 0,往后每递增一个 page,depth 会加 1;
uint32_t hiwat;

// SIZE-sizeof(*this) bytes of contents follow

static void * operator new(size_t size) {
return malloc_zone_memalign(malloc_default_zone(), SIZE, SIZE);
}
}

objc_autoreleasePoolPush

objc_autoreleasePoolPush 内部调用了 AutoReleasePoolPagepush 方法,其实现如下所示。可以看出,push 方法的本质就是想 AutoReleasePage 中存入一个哨兵对象 POOL_SENTINEL,用于标识 AutoReleasePool 的边界。这个过程中会遇到一些主要的分支情况,比如:

  • 当前没有任何页面时,需要初始化一个页面
  • 当前页面已满时,需要添加一个新页面
  • 当前页面未满时,则直接存入对象指针

这些分支情况,在 push 方法中均有体现,相关逻辑详见代码具体实现。push 方法中通过 autoreleaseFast 方法存储哨兵对象。事实上,我们使用 ARC 管理对象时,所有的对象都是通过 autoreleaseFast 方法存入 AutoReleasePoolPage 中的。

hotPage 表示末尾最后一个非空页面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
static inline void *push() {
id *dest = autoreleaseFast(POOL_SENTINEL);
return dest;
}

// 管理对象
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);
}
}

// 当前页面存在的逻辑
// 当前页面已满时,则沿着链表 child 指针,正向遍历,即栈顶方向,查找未满的页面。如果没找到,则初始化一个页面加入链表尾部,即栈顶。
// 最后将对象指针加入该未满的页面中。
id *autoreleaseFullPage(id obj, AutoreleasePoolPage *page) {
assert(page == hotPage());
assert(page->full() || DebugPoolAllocation);

do {
if (page->child) page = page->child;
else page = new AutoreleasePoolPage(page);
} while (page->full());

setHotPage(page);
return page->add(obj);
}

// 当前页面不存在的逻辑
// 当链表中没有任何页面时,初始化一个页面,其 parent 设置为 nil,即链表头。
// 此时,如果要加入的对象不是哨兵对象,则加入一个哨兵对象,作为 AutoReleasePool 的边界。然后再加入对象地址。
id *autoreleaseNoPage(id obj) {
// No pool in place.
assert(!hotPage());

if (obj != POOL_SENTINEL && DebugMissingPools) {
// We are pushing an object with no pool in place,
// and no-pool debugging was requested by environment.
_objc_inform("MISSING POOLS: Object %p of class %s "
"autoreleased with no pool in place - "
"just leaking - break on "
"objc_autoreleaseNoPool() to debug",
(void*)obj, object_getClassName(obj));
objc_autoreleaseNoPool(obj);
return nil;
}

// Install the first page.
AutoreleasePoolPage *page = new AutoreleasePoolPage(nil);
setHotPage(page);

// Push an autorelease pool boundary if it wasn't already requested.
if (obj != POOL_SENTINEL) {
page->add(POOL_SENTINEL);
}

// Push the requested object.
return page->add(obj);
}

objc_autoreleasePoolPop

objc_autoreleasePoolPop 内部调用了 AutoReleasePoolPagepop 方法。pop 方法的参数 token 则是当前 AutoReleasePool 的哨兵对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
static inline void pop(void *token) {
AutoreleasePoolPage *page;
id *stop;

page = pageForPointer(token);
stop = (id *)token;
if (DebugPoolAllocation && *stop != POOL_SENTINEL) {
// This check is not valid with DebugPoolAllocation off
// after an autorelease with a pool page but no pool in place.
_objc_fatal("invalid or prematurely-freed autorelease pool %p; ",
token);
}

if (PrintPoolHiwat) printHiwat();

// 释放对象
page->releaseUntil(stop);

// memory: delete empty children
if (DebugPoolAllocation && page->empty()) {
// special case: delete everything during page-per-pool debugging
AutoreleasePoolPage *parent = page->parent;
page->kill();
setHotPage(parent);
} else if (DebugMissingPools && page->empty() && !page->parent) {
// special case: delete everything for pop(top)
// when debugging missing autorelease pools
page->kill();
setHotPage(nil);
}
else if (page->child) {
// hysteresis: keep one empty child if page is more than half full
if (page->lessThanHalfFull()) {
page->child->kill();
}
else if (page->child->child) {
page->child->child->kill();
}
}
}

// 对象释放
void releaseUntil(id *stop) {

while (this->next != stop) {
// 设置 page 为当前非空页面
AutoreleasePoolPage *page = hotPage();

// 通过 parent 指针,反向遍历,即栈顶方向
// 将尾指针指向非空的页面
while (page->empty()) {
page = page->parent;
setHotPage(page);
}

page->unprotect();
// 地址递减,并释放对应位置
id obj = *--page->next;
memset((void*)page->next, SCRIBBLE, sizeof(*page->next));
page->protect();

// 如果对应位置存储了对象,则进行内存释放
if (obj != POOL_SENTINEL) {
objc_release(obj);
}
}

setHotPage(this);
}

我们可以看到对象释放的关键方法 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 正是链表节点的设计。

参考

  1. 函数栈帧的创建与销毁
  2. 函数调用过程栈帧变化详解
  3. 《程序员的自我修养——链接、装载与库》
  4. objc
  5. 自动释放池的前世今生 ---- 深入解析 autoreleasepool