前言
在陽神的 黑幕背后的Autorelease 文章中已經把 AutoreleasePool 核心邏輯講明白了,不過多是結論性的東西,筆者通讀源碼以探究更多的細節,驗證一下老生常談的一些結論。
源碼基于 Runtime 750。
一、@autoreleasepool {} 干了些什么
main.m 文件代碼:
int main(int argc, const char * argv[]) {
@autoreleasepool {}
return 0;
}
使用 clang -rewrite-objc main.m 查看經過編譯器前端處理的代碼:
struct __AtAutoreleasePool {
__AtAutoreleasePool() {atautoreleasepoolobj = objc_autoreleasePoolPush();}
~__AtAutoreleasePool() {objc_autoreleasePoolPop(atautoreleasepoolobj);}
void * atautoreleasepoolobj;
};
int main(int argc, const char * argv[]) {
/* @autoreleasepool */ { __AtAutoreleasePool __autoreleasepool; }
return 0;
}
可以看出@autoreleasepool{}
會創建一個__AtAutoreleasePool
類型的局部變量并包含在當前作用域,__AtAutoreleasePool
構造和析構時分別調用了兩個方法,所以簡化過程如下:
void *context = objc_autoreleasePoolPush()
// 對象調用 autorelease 裝入自動釋放池
objc_autoreleasePoolPop(context)
可以猜測 push 和 pop 操作是實現自動釋放的關鍵。
二、AutoreleasePoolPage 內存分布
官方文檔 中提到了,主線程以及非顯式創建的線程(比如 GCD)都會有一個 event loop (RunLoop 就是具體實現),在 loop 的每一個循環周期的開始和結束會分別調用自動釋放池的 push 和 pop 方法,由此來實現自動的內存管理。
objc_autoreleasePoolPush()
和objc_autoreleasePoolPop(...)
實際上會調用到AutoreleasePoolPage
類的push()
和pop()
方法,先看一下這個類的數據結構:
class AutoreleasePoolPage {
...
magic_t const magic;
id *next;
pthread_t const thread;
AutoreleasePoolPage * const parent;
AutoreleasePoolPage *child;
static void * operator new(size_t size) {
return malloc_zone_memalign(malloc_default_zone(), SIZE, SIZE);
}
id * begin() {
return (id *) ((uint8_t *)this+sizeof(*this));
}
id * end() {
return (id *) ((uint8_t *)this+SIZE);
}
...
}
-
parent
和child
正是指向前驅和后繼指針,自動釋放池就是一個以AutoreleasePoolPage
為節點的雙向鏈表(后文驗證)。 -
thread
是指當前 page 所對應的線程。 -
magic
用于校驗內存是否損壞。 -
next
指向當前可插入對象的地址。
內存對齊
重寫了new
運算符,使用了malloc_zone_memalign(...)
進行內存分配:
extern void *malloc_zone_memalign(malloc_zone_t *zone, size_t alignment, size_t size) ;
/*
* Allocates a new pointer of size size whose address is an exact multiple of alignment.
* alignment must be a power of two and at least as large as sizeof(void *).
* zone must be non-NULL.
*/
注釋說得很清楚了,這個方法以alignment
對齊的地址分配size
的內存空間。調用時兩個參數都使用了SIZE
宏,實際上就是虛擬內存頁的大小:
#define I386_PGBYTES 4096
一個 page 的內存空間設置過小會導致更多的開辟空間操作降低效率,大量的parent/child
指針變量也會占用可觀的內存;空間設置過大可能會導致一個 page 的利用率低浪費過多內存。設置為 4096 是比較考究的,在保證內存對齊的情況下最大化利用空間避免內存碎片。這么做過后 page 的地址總是 4096 的整數倍,可以讓某些運算更便捷(比如后文會說的通過指針地址尋找對應的 page)。
begin() 與 end()
AutoreleasePoolPage
本身的大小遠不及 4096,而超出的空間正是用來存放“期望被自動管理的對象”。begin()
和end()
方法標記了這個范圍。
sizeof(*this)
表示AutoreleasePoolPage
本身的大小,那么(uint8_t *)this+sizeof(*this)
就是最低地址,(uint8_t *)this+SIZE
就是最高地址。逐個插入對象時,next
指針從begin()
到end()
逐個移動,后面的full()
方法就是指next == end()
,empty()
就是指next == begin()
。
值得注意的是next/end()/begin()
等都是id *
類型的,即指向指針的指針,進行 +1 -1 運算時移動的是一個id
大小的距離。
三、push 邏輯
push()
方法會調用autoreleaseFast(POOL_BOUNDARY)
:
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);
}
}
hotPage 指的是當前可插入對象的 page,放到后面一點分析,先來看插入對象的邏輯,分三種情況:
1、當 page 存在且沒滿時,直接添加對象:
id *add(id obj)
{
assert(!full());
unprotect();
id *ret = next; // faster than `return next-1` because of aliasing
*next++ = obj;
protect();
return ret;
}
unprotect()/protect()
內部使用了int mprotect(void *a, size_t b, int c)
,設置內存起點a
長度b
的內存區域為c
類型的訪問限制:
inline void protect() {
#if PROTECT_AUTORELEASEPOOL
mprotect(this, SIZE, PROT_READ);
check();
#endif
}
inline void unprotect() {
#if PROTECT_AUTORELEASEPOOL
check();
mprotect(this, SIZE, PROT_READ | PROT_WRITE);
#endif
}
unprotect()
設置為可讀可寫,protect()
設置為只讀,所以這里的目的是保證 page 寫安全。不過有#define PROTECT_AUTORELEASEPOOL 0
定義說明目前版本還沒有開放這個保護功能。
2、當 page 存在且滿了時,拓展 page 節點并添加對象:
static __attribute__((noinline))
id *autoreleaseFullPage(id obj, AutoreleasePoolPage *page)
{ ...
do {
if (page->child) page = page->child;
else page = new AutoreleasePoolPage(page);
} while (page->full());
setHotPage(page);
return page->add(obj);
}
循環的邏輯:從 child 方向找到未滿的 page,若找不到則創建一個新 page 拼接到鏈表尾部(AutoreleasePoolPage 構造方法會把傳入的 page 參數作為 parent 前驅對象)。后面再設置最新的 page 為 hotpage 并將 obj 添加進 page。
3、當 page 不存在時,初始化一個
static __attribute__((noinline))
id *autoreleaseNoPage(id obj)
{ ...
AutoreleasePoolPage *page = new AutoreleasePoolPage(nil);
setHotPage(page);
...
return page->add(obj);
}
這個方法核心就是創建第一個 page 然后加入線程局部存儲。
hotPage
從上面的push()
方法分析可知,被自動管理的對象會不斷插入雙向鏈表從前到后第一個未滿 page ,hotPage()
其實就是指向這個 page,還有個coldPage()
方法是根據hotPage()
找到第一個 page。
既然自動釋放池是由AutoreleasePoolPage
組成的雙向鏈表,那這個鏈表該如何訪問呢?可能常規的思路是創建一個全局變量來訪問它,不過這里使用了另外一個方式:
static inline AutoreleasePoolPage *hotPage()
{
AutoreleasePoolPage *result = (AutoreleasePoolPage *)
tls_get_direct(key);
// EMPTY_POOL_PLACEHOLDER 表示沒有 page
if ((id *)result == EMPTY_POOL_PLACEHOLDER) return nil;
if (result) result->fastcheck();
return result;
}
static inline void setHotPage(AutoreleasePoolPage *page)
{
if (page) page->fastcheck();
tls_set_direct(key, (void *)page);
}
tls_get_direct(...)
和tls_set_direct(...)
內部就是使用線程的局部存儲(TLS: Thread Local Storage)將 page 存儲起來,這樣可以避免維護額外的空間來記錄尾部的 page。由此也驗證了自動釋放池與線程一一對應的關系。
在 YYKit 中有一個使用廣泛的技巧:將某個對象最后使用時放在異步線程,如果這個對象釋放就(可能?)會在這個異步線程,從而降低主線程壓力。實際上就是編譯器插入 autorelease 代碼將對象加入到異步線程的自動釋放池,而如果異步線程的釋放池先于主線程的釋放池pop()
而調用對象的release()
方法,那么這個對象如果釋放就會在異步線程。所以筆者認為這個優化并非絕對有效(這里衍生出一個問題:一個對象被多個自動釋放池管理,若對象釋放這些釋放池怎么避免的野指針問題?)。
POOL_BOUNDARY
push()
方法調用autoreleaseFast(POOL_BOUNDARY)
時傳入的是一個 POOL_BOUNDARY 并非需要被管理的對象,它的定義如下:
# define POOL_BOUNDARY nil
在調用autoreleaseFast(obj)
方法會返回指向obj
指針的指針,它是一個id *
類型,也就是說,這個返回值關心的只是obj
指針的地址,而不是obj
值的地址,obj
指針的地址就是對應AutoreleasePoolPage
對象內存中的某段區域。
再看一下上層調用:
void *context = objc_autoreleasePoolPush()
...
objc_autoreleasePoolPop(context)
pop 時會將這個obj
指針的地址傳入進去。pop 的邏輯是把 hotPage 里面裝的對象依次移除并發送 release 消息(后面會詳細分析),當前 page 移除完了,繼續移除 parent 節點內的對象,以此反復,而移除對象操作何時停止就是到這個obj
指針的地址。
所以,push 操作加入一個 POOL_BOUNDARY 實際上就是加一個邊界,pop 操作時根據邊界判斷范圍,這就是一個入棧與出棧的過程。
magic 校驗
多次出現的check()
方法如下:
void check(bool die = true) {
if (!magic.check() || !pthread_equal(thread, pthread_self())) busted(die);
}
void fastcheck(bool die = true) {
//補充:#define CHECK_AUTORELEASEPOOL (DEBUG)
#if CHECK_AUTORELEASEPOOL
check(die);
#else
if (! magic.fastcheck()) busted(die);
#endif
}
可以看到,它們都調用了magic
的 check 方法,在 DEBUG 時還會去檢查當前線程是否與 page 的線程一致。
magic
是magic_t
類型的,這個結構體主要是有個uint32_t m[4];
數組,構造時內存直接會寫為0xA1A1A1A1 AUTORELEASE!
,然后check()
邏輯就是判斷構造時的值是否發生了改變,若發生改變說明這個 page 已經被破壞。
四、autorelease 邏輯
上層對象調用 autorelease 方法會調用到AutoreleasePoolPage
的以下方法:
static inline id autorelease(id obj)
{
assert(obj);
assert(!obj->isTaggedPointer());
id *dest __unused = autoreleaseFast(obj);
assert(!dest || dest == EMPTY_POOL_PLACEHOLDER || *dest == obj);
return obj;
}
顯然,最終還是會調用前面解析的autoreleaseFast(...)
方法進行對象插入。由此也可以推斷,在一個 Thread 沒有 Runloop 自動執行自動釋放池的 push 和 pop 時,對象進行 autorelease 時若發現沒有自動釋放池節點會自動創建 page 并加入線程局部存儲(參考前面的autoreleaseNoPage(...)
方法分析)。
五、pop 邏輯
objc_autoreleasePoolPop(context)
的context
參數是objc_autoreleasePoolPush()
返回的,實際上就是POOL_BOUNDARY
對應的在AutoreleasePoolPage
中的地址。最終會調用到pop()
方法:
static inline void pop(void *token)
{
AutoreleasePoolPage *page;
id *stop;
...
// 拿到 token 邊界對應的 page
page = pageForPointer(token);
stop = (id *)token;
...
// pop 內部對象直到 stop 邊界
page->releaseUntil(stop);
...
// 刪除空的 child 鏈表節點,如果當前頁對象超過一半,保留下一個空節點
if (page->lessThanHalfFull()) {
page->child->kill();
}
else if (page->child->child) {
page->child->child->kill();
}
}
pop()
的邏輯應該很好理解了,token
參數就是邊界,下面分別分析步驟:
找到邊界對應的 page
static AutoreleasePoolPage *pageForPointer(const void *p) {
return pageForPointer((uintptr_t)p);
}
static AutoreleasePoolPage *pageForPointer(uintptr_t p) {
AutoreleasePoolPage *result;
uintptr_t offset = p % SIZE;
....
result = (AutoreleasePoolPage *)(p - offset);
result->fastcheck();
return result;
}
看上面個函數,const void *p
是指針的指針,((uintptr_t)p)
才表示POOL_BOUNDARY
指針在對應 page 中的地址。
看下面個函數,前面分析過內存對齊的處理,那么 page 的起始地址必然是 SIZE (也就是頁大小 4096) 的倍數,那么p % SIZE
就得到了這個p
在 page 中的地址偏移,最后通過p - offset
就拿到了 page 的起始地址,這個處理比較秀。
移除被管理對象并發送 release 消息
void releaseUntil(id *stop) {
while (this->next != stop) {
AutoreleasePoolPage *page = hotPage();
// 如果當前 page 空了,指向 parent
while (page->empty()) {
page = page->parent;
setHotPage(page);
}
// 將即將要移除對象對應 page 中的內存置為 SCRIBBLE
page->unprotect();
id obj = *--page->next;
memset((void*)page->next, SCRIBBLE, sizeof(*page->next));
page->protect();
// 調用對象的 release 方法
if (obj != POOL_BOUNDARY) {
objc_release(obj);
}
}
// 把當前 page 設置 hotpage(調用時 this 就是對應期望釋放邊界的 page)
setHotPage(this);
...
}
清除 child
void kill() {
AutoreleasePoolPage *page = this;
while (page->child) page = page->child;
AutoreleasePoolPage *deathptr;
do {
deathptr = page;
page = page->parent;
if (page) {
page->unprotect();
page->child = nil;
page->protect();
}
delete deathptr;
} while (deathptr != this);
}
這個邏輯一目了然了:找到當前 page 的 child 方向尾部 page,然后反向挨著釋放并且把其 parent 節點的 child 指針置空。前面也說明了unprotect
和protect
內部并沒有開啟寫入安全保護。
后語
以上就是自動釋放池大部分源碼的分析了,這部分源碼沒有涉及匯編并且代碼量比較少,所以看起來相對容易。多理解一些內存管理底層有利于理解各種上層特性、定位內存難題,也有助于寫出更穩定的代碼。并且在這個過程中,不可避免需要接觸操作系統和編譯原理相關知識,也算是能培養通識性能力。
讀源碼遠比記結論重要,遇到某些優秀的代碼細節往往令人驚喜,不失為一種樂趣。