Objective-C weak 弱引用實現
在編寫程式碼時,弱引用一般以下面兩種形式出現:
weak __weak
這裡我們可以統一把第一種形式看作使用__weak
關鍵字修飾成員變數。
__weak
修飾的變數有兩大特點:
- 不會增加指向物件的引用計數 (規避迴圈引用)
- 指向物件釋放後,變數會自動置 nil (規避野指標訪問錯誤)
下文會從原始碼的視角分析 runtime 是如何實現弱引用自動置 nil 的。
實現簡述
設定__weak
修飾的變數時,runtime 會生成對應的 entry 結構放入 weak hash table 中,以賦值物件地址生成的 hash 值為 key,以包裝__weak
修飾的指標變數地址的 entry 為 value,當賦值物件釋放時,runtime 會在目標物件的 dealloc 處理過程中,以物件地址(self)為 key 去 weak hash table 查詢 entry ,置空 entry 指向的的所有物件指標。
實際上 entry 使用陣列儲存指標變數地址,當地址數量不大於 4 時,這個陣列就是個普通的內建陣列,在地址數量大於 4 時,這個陣列就會擴充成一個 hash table。
實現模仿
首先,我們看下如下程式碼:
@interface A : NSObject @end @implementation A @end int main(int argc, const char * argv[]) { @autoreleasepool { __unsafe_unretained NSObject *w1; @autoreleasepool { NSObject *obj = [A new]; w1 = obj; } NSLog(@"%@", w1); } return 0; } // EXC_BAD_ACCESS
由於__unsafe_unretained
修飾的變數會始終保留對像地址,所以在 obj 指向的物件釋放後,訪問 w1 會出現 EXC_BAD_ACCESS 錯誤,我們要做的就是模仿__weak
的實現,在 obj 指向的物件釋放之後,將 w1 置為 nil。
以下是根據實現簡述編寫的程式碼:
// { 物件地址 : [ 物件指標地址1、 物件指標地址1] } static NSMutableDictionary *weakTable; @interface A : NSObject @end @implementation A - (void)dealloc { // 獲取指向此物件的所有指標變數地址 for (NSNumber *ptrPtrNumber in weakTable[@((uintptr_t)self)]) { // 根據指標變數地址,將指標變數置為 nil // 這裡就是 w1 置 nil uintptr_t **ptrPtr = (uintptr_t **)[ptrPtrNumber unsignedLongValue]; *ptrPtr = nil; } // 移除和此物件相關的資料 [weakTable removeObjectForKey:@((uintptr_t)self)]; } @end int main(int argc, const char * argv[]) { @autoreleasepool { weakTable = @{}.mutableCopy; __unsafe_unretained NSObject *w1; @autoreleasepool { NSObject *obj = [A new]; uintptr_t objAddr = (uintptr_t)obj; w1 = obj; // 將物件地址和需要自動置 nil 的指標變數的地址儲存至 map 中 // 使用可變陣列方便處理多個需要置 nil 的變數指向 obj weakTable[@(objAddr)] = @[@((uintptr_t)&w1)].mutableCopy; } NSLog(@"%@", w1); } return 0; } // (null) (null)
考慮到 w1 變數的作用域可能會在指向物件釋放前結束,我們還需要在作用域結束時,將儲存的 w1 地址清除:
int main(int argc, const char * argv[]) { @autoreleasepool { NSObject *obj = [A new]; weakTable = @{}.mutableCopy; { __unsafe_unretained NSObject *w1; uintptr_t objAddr = (uintptr_t)obj; w1 = obj; weakTable[@(objAddr)] = @[@((uintptr_t)&w1)].mutableCopy; // 即將走出 w1 所在作用域,將 w1 的地址從 map 中清除 [weakTable[@((uintptr_t)w1)] removeObject:@((uintptr_t)&w1)]; } } return 0; }
即使出了作用域,只要棧幀還在,並且 w1 變數所處的地址沒被覆蓋,那麼通過 w1 的地址訪問 w1 變數(即訪問 obj 指向的物件)還是沒有問題的,只不過既然 w1 對於外界不可見,就沒有繼續在 map 中維護其地址的必要了。
以上就是我所理解的弱引用置 nil 的粗略實現,接下來我們看看 runtime 是如何實現這個特性的。
objc 的實現
以下原始碼分析基於 runtime 750 版本
我們使用以下程式碼分析 runtime 是如何在 weak hash table 中建立及銷燬__weak
修飾變數資訊的:
int main(int argc, const char * argv[]) { __weak NSObject *w0; @autoreleasepool { NSObject *obj = [A new]; w0 = obj; { __unused __weak NSObject *w1 = obj; } } return 0; }
初步分析
在設定 w1/w0 變數時,runtime 觸發了以下呼叫棧:
objc_initWeak / objc_storeWeak storeWeak weak_register_no_lock weak_entry_insert
在將要走出 w1 變數的作用域時,runtime 觸發了以下呼叫棧:
objc_destroyWeak storeWeak weak_unregister_no_lock remove_referrer
在 obj 釋放時,runtime 觸發了以下呼叫棧:
objc_storeStrong objc_release objc_object::release objc_object::rootRelease() objc_object::rootRelease(bool, bool) dealloc _objc_rootDealloc objc_object::rootDealloc object_dispose objc_destructInstance objc_object::clearDeallocating objc_object::clearDeallocating_slow weak_clear_no_lock
我們順著這幾個函式呼叫棧,抽取關鍵資訊進行分析。
建立關聯資訊
id objc_initWeak(id *location, id newObj) { if (!newObj) { *location = nil; return nil; } // <false, true, true> return storeWeak<DontHaveOld, DoHaveNew, DoCrashIfDeallocating> (location, (objc_object*)newObj); } id objc_storeWeak(id *location, id newObj) { // <true, true, true> return storeWeak<DoHaveOld, DoHaveNew, DoCrashIfDeallocating> (location, (objc_object *)newObj); }
objc_initWeak
只是簡單地判空處理後,呼叫了storeWeak
函式,並傳入一些模版引數,這裡的 location 就是__weak
修飾的指標變數地址,newObj 為賦值物件的地址,objc_storeWeak
同理。
static id storeWeak(id *location, objc_object *newObj) { ... id oldObj; SideTable *oldTable; SideTable *newTable; if (haveOld) { // 獲取老物件的地址 oldObj = *location; oldTable = &SideTables()[oldObj]; } else { oldTable = nil; } if (haveNew) { newTable = &SideTables()[newObj]; } else { newTable = nil; } ... if (haveOld) { // __weak 修飾的指標變數已經指向過某物件 // 需要把這個物件和此指標變數的關聯斷開 // 這個函式呼叫在銷燬關聯資訊一節再分析 weak_unregister_no_lock(&oldTable->weak_table, oldObj, location); } if (haveNew) { // 關聯新物件和 __weak 修飾的指標變數 newObj = (objc_object *) weak_register_no_lock(&newTable->weak_table, (id)newObj, location, crashIfDeallocating); if (newObj&&!newObj->isTaggedPointer()) { // 設定 isa 指標的 weakly_referenced 位 / sidetable 中的 SIDE_TABLE_WEAKLY_REFERENCED 位 // 標記此物件被 __weak 修飾的指標變數指向了,dealloc 時可以加速置 nil 處理 newObj->setWeaklyReferenced_nolock(); } // 設定 __weak 修飾的指標變數的值為 newObj *location = (id)newObj; } return (id)newObj; }
這裡的 SideTable 我們可以簡單地把它視為儲存物件引用計數和弱引用表的結構,對於一個物件來說這個結構例項是唯一的。一般來說,objc 2.0 的物件引用計數都會優先儲存在 isa 的 extra_rc 位段中,只有超出了儲存的限制才會將超出部分儲存到對應的 SideTable 中,isa 使用 has_sidetable_rc 標記是否超出限制。
在設定新的關聯前,如果__weak
修飾的指標變數已經關聯了其他物件,那麼此函式會先解除舊關聯,再設定新的。如果 newObjc 是 nil,那麼只會進行解除關聯以及指標置 nil 操作,objc_destroyWeak
就以這種方式呼叫storeWeak
來執行銷燬動作。
id weak_register_no_lock(weak_table_t *weak_table, id referent_id, id *referrer_id, bool crashIfDeallocating) { // 物件地址 objc_object *referent = (objc_object *)referent_id; // __weak 修飾的變數指標地址 objc_object **referrer = (objc_object **)referrer_id; if (!referent||referent->isTaggedPointer()) return referent_id; // 確保物件沒有在釋放中 ... // 新增關聯資訊 weak_entry_t *entry; if ((entry = weak_entry_for_referent(weak_table, referent))) { // 將 weak 變數地址加入到 entry 中 append_referrer(entry, referrer); } else { weak_entry_t new_entry(referent, referrer); // 如果 weak_table 容量不夠,就建立更多空間 weak_grow_maybe(weak_table); // 將新的 entry 插入 weak_table 中 weak_entry_insert(weak_table, &new_entry); } return referent_id; }
這裡面涉及到了兩個結構weak_table_t
和weak_entry_t
,其結構如下:
struct weak_table_t { weak_entry_t *weak_entries;// 儲存物件地址 和 __weak 修飾變數的 hash table size_tnum_entries;// hash table 大小 uintptr_t mask;// 輔助計算 hash 索引的位遮罩 uintptr_t max_hash_displacement;// hash 索引最大偏移量 (下文會說明用處) }; struct weak_entry_t { DisguisedPtr<objc_object> referent; // 包含物件地址的結構 union { struct { weak_referrer_t *referrers;// DisguisedPtr 指標(DisguisedPtr 有 __weak 指標變數地址資訊) uintptr_tout_of_line_ness : 2;// 是否超出預設陣列長度 uintptr_tnum_refs : PTR_MINUS_2;// 和 referent 關聯的 __weak 修飾的指標變數個數 uintptr_tmask;// 同 weak_table_t uintptr_tmax_hash_displacement;// 同 weak_table_t }; struct { // out_of_line_ness field is low bits of inline_referrers[1] weak_referrer_tinline_referrers[WEAK_INLINE_COUNT]; // DisguisedPtr 定長陣列(4) }; }; ... };
後面會頻繁提及這兩個結構。
回到weak_register_no_lock
函式,由於是第一次設定__weak
變數,沒有現成的 entry,需要新建一個,所以走的是 else 新增邏輯分支,如果是多個__weak
變數指向同個物件時,entry 是可以同時儲存這幾個變數的地址的,這時候就是走的append_referrer
分支。
static void weak_entry_insert(weak_table_t *weak_table, weak_entry_t *new_entry) { weak_entry_t *weak_entries = weak_table->weak_entries; assert(weak_entries != nil); // 根據物件地址,生成 hash 值 (hash_pointer 就是雜湊函式) // 然後將 hash 值和 mask 位遮罩做按位與操作,其值作為陣列索引 // mask 的值為 weak_table 的 size - 1 ,所以算出來的索引不會越界 size_t begin = hash_pointer(new_entry->referent) & (weak_table->mask); size_t index = begin; size_t hash_displacement = 0; // index 上的值不為空的話,可以視為 hash 碰撞,繼續查詢後續的空值索引儲存 // max_hash_displacement 只有在有 index 重複了才會用到 while (weak_entries[index].referent != nil) { // 這樣寫可以讓 index 訪問 mask 內的所有索引 // 如 mask 為 4,begin 為 3,則最差情況下 index 的值可能為 3 4 0 1 2 // hash_displacement 最終結果為 4 // 後續獲取時,如果 hash_displacement 超過 4,則視為訪問失敗 index = (index+1) & weak_table->mask; if (index == begin) bad_weak_table(weak_entries); hash_displacement++; } // 設定新的 entry 並增加 entry 計數 weak_entries[index] = *new_entry; weak_table->num_entries++; if (hash_displacement > weak_table->max_hash_displacement) { // 設定 hash 索引最大偏移量(刪除時會用來判斷是否超出最大偏移值) weak_table->max_hash_displacement = hash_displacement; } }
可以看到,上方weak_table
的weak_entries
欄位可視為雜湊表,key 由物件地址生成,value 是記錄__weak
修飾變數地址的 entry 結構。weak_entry_for_referent
函式從雜湊表中獲取 entry,和weak_entry_insert
實現類似,這裡不做贅述。
呼叫weak_entry_insert
函式之後,一次弱引用記錄的建立就算完成了,
銷燬關聯資訊
void objc_destroyWeak(id *location) { // <true, false, false> (void)storeWeak<DoHaveOld, DontHaveNew, DontCrashIfDeallocating> (location, nil); }
objc_destroyWeak
傳入了 nil ,用以清空 location 地址上的物件指標,並且由於沒有非 nil 新值,storeWeak
只會刪除不會新建關聯資訊。storeWeak
上一節已經分析過,這裡直接看weak_unregister_no_lock
函式。
void weak_unregister_no_lock(weak_table_t *weak_table, id referent_id, id *referrer_id) { objc_object *referent = (objc_object *)referent_id; objc_object **referrer = (objc_object **)referrer_id; weak_entry_t *entry; if (!referent) return; // 根據物件地址獲取 entry if ((entry = weak_entry_for_referent(weak_table, referent))) { // 移除 entry 中值為 referrer 的指標變數地址 remove_referrer(entry, referrer); bool empty = true; // entry 中是否有關聯的指標變數地址 if (entry->out_of_line()&&entry->num_refs != 0) { empty = false; } else { for (size_t i = 0; i < WEAK_INLINE_COUNT; i++) { if (entry->inline_referrers[i]) { empty = false; break; } } } if (empty) { // 如果 entry 是空的話,就從 weak_table 中移除掉 weak_entry_remove(weak_table, entry); } } }
這裡將銷燬的__weak
變數地址從 entry 中刪除。
指標變數置 nil
void weak_clear_no_lock(weak_table_t *weak_table, id referent_id) { objc_object *referent = (objc_object *)referent_id; // 根據物件地址獲取 entry weak_entry_t *entry = weak_entry_for_referent(weak_table, referent); if (entry == nil) { return; } weak_referrer_t *referrers; size_t count; // 獲取需要置 nil 的指標變數個數 if (entry->out_of_line()) { referrers = entry->referrers; count = TABLE_SIZE(entry); } else { referrers = entry->inline_referrers; count = WEAK_INLINE_COUNT; } for (size_t i = 0; i < count; ++i) { // 獲取指標變數的地址 objc_object **referrer = referrers[i]; if (referrer) { if (*referrer == referent) { // 將指標變數置為 nil *referrer = nil; } else if (*referrer) { _objc_inform("__weak variable at %p holds %p instead of %p. " "This is probably incorrect use of " "objc_storeWeak() and objc_loadWeak(). " "Break on objc_weak_error to debug.\n", referrer, (void*)*referrer, (void*)referent); objc_weak_error(); } } } // 將 entry 從 weak_table 中移除 weak_entry_remove(weak_table, entry); }
刨去前面dealloc
相關的呼叫函式,weak_clear_no_lock
只是根據釋放物件的地址,查詢關聯的 entry ,遍歷 entry 中的地址,置 nil 地址上的指標變數。
weak_entry_t 的兩種形式
上面分析基本圍繞著weak_table_t
展開,實際上它只是第一層雜湊表,其儲存的weak_entry_t
value 內部也可以實現為一個雜湊表,只不過weak_table_t
使用物件地址生成 hash 值,而weak_entry_t
使用__weak
修飾的指標變數地址生成 hash 值。
這裡回到weak_entry_t
的結構:
struct weak_entry_t { DisguisedPtr<objc_object> referent; // 包含物件地址的結構 union { struct { weak_referrer_t *referrers;// DisguisedPtr 指標(DisguisedPtr 有 __weak 指標變數地址資訊) uintptr_tout_of_line_ness : 2;// 是否超出預設陣列長度 uintptr_tnum_refs : PTR_MINUS_2;// 和 referent 關聯的 __weak 修飾的指標變數個數 uintptr_tmask;// 同 weak_table_t uintptr_tmax_hash_displacement;// 同 weak_table_t }; struct { // out_of_line_ness field is low bits of inline_referrers[1] weak_referrer_tinline_referrers[WEAK_INLINE_COUNT]; // DisguisedPtr 定長陣列(4) }; }; ... };
weak_entry_t
定義了一個 union ,其中 WEAK_INLINE_COUNT 巨集為 4 ,也就是說在初始狀態下,這個 union 的空間有weak_referrer_t inline_referrers[4]
這麼大,當 entry 儲存指標變數地址的個數不大於 4 個時,我們就可以直接使用inline_referrers
陣列,這樣寫的話,訪問更加快速便捷。
我們再看下關聯的變數個數大於 4 的情況:
int main(int argc, const char * argv[]) { @autoreleasepool { NSObject *obj = [A new]; __unused __weak NSObject *w0 = obj; __unused __weak NSObject *w1 = obj; __unused __weak NSObject *w2 = obj; __unused __weak NSObject *w3 = obj; __unused __weak NSObject *w4 = obj; } return 0; }
當 entry 已經存在時,再關聯指標變數則會走append_referrer
函式,也就是上方的 w1 開始到 w4 都走的append_referrer
。
static void append_referrer(weak_entry_t *entry, objc_object **new_referrer) { // 關聯變數個數是否超出 4 if (! entry->out_of_line()) { for (size_t i = 0; i < WEAK_INLINE_COUNT; i++) { // 預設陣列是否有閒餘的位置存放新增加的關聯資料 if (entry->inline_referrers[i] == nil) { entry->inline_referrers[i] = new_referrer; return; } } // inline_referrers 陣列滿了,把其中的資料暫存到新申請的記憶體中 // 由於 union 特性,num_refs 這些欄位和 inline_referrers 陣列使用的同一塊記憶體 // 為了後面修改 num_refs 等欄位不會影響到關聯資料,所以需要提前暫存 weak_referrer_t *new_referrers = (weak_referrer_t *) calloc(WEAK_INLINE_COUNT, sizeof(weak_referrer_t)); for (size_t i = 0; i < WEAK_INLINE_COUNT; i++) { new_referrers[i] = entry->inline_referrers[i]; } entry->referrers = new_referrers; entry->num_refs = WEAK_INLINE_COUNT; // 設定超出界限標識 entry->out_of_line_ness = REFERRERS_OUT_OF_LINE; // mask 為 table size - 1 entry->mask = WEAK_INLINE_COUNT-1; entry->max_hash_displacement = 0; } assert(entry->out_of_line()); // 關聯資料個數是否大於 table size 的 3/4 if (entry->num_refs >= TABLE_SIZE(entry) * 3/4) { // 增加 table 大小,並插入,其內部會呼叫 append_referrer return grow_refs_and_insert(entry, new_referrer); } // 這裡的插入邏輯,hash 索引計算同 weak_table_t size_t begin = w_hash_pointer(new_referrer) & (entry->mask); size_t index = begin; size_t hash_displacement = 0; while (entry->referrers[index] != nil) { hash_displacement++; index = (index+1) & entry->mask; if (index == begin) bad_weak_table(entry); } if (hash_displacement > entry->max_hash_displacement) { // 設定最大 hash 偏移 entry->max_hash_displacement = hash_displacement; } // 設定新關聯資訊 weak_referrer_t &ref = entry->referrers[index]; ref = new_referrer; // 設定實際儲存的關聯指標變數個數 entry->num_refs++; }
可以看到,w1-w3 會直接使用inline_referrers
,一旦設定 w4,關聯資料就大於 4 了,weak_entry_t
將不會使用內建陣列,而是使用grow_refs_and_insert
函式申請新的記憶體。
__attribute__((noinline, used)) static void grow_refs_and_insert(weak_entry_t *entry, objc_object **new_referrer) { assert(entry->out_of_line()); size_t old_size = TABLE_SIZE(entry); // 每次申請,都在原來的容量上乘 2 倍 size_t new_size = old_size ? old_size * 2 : 8; size_t num_refs = entry->num_refs; weak_referrer_t *old_refs = entry->referrers; // mask 為 table size - 1 entry->mask = new_size - 1; // 重新申請記憶體 entry->referrers = (weak_referrer_t *) calloc(TABLE_SIZE(entry), sizeof(weak_referrer_t)); entry->num_refs = 0; entry->max_hash_displacement = 0; // 把舊資料儲存到新記憶體中 for (size_t i = 0; i < old_size && num_refs > 0; i++) { if (old_refs[i] != nil) { append_referrer(entry, old_refs[i]); num_refs--; } } // 將新資料儲存到新記憶體中 append_referrer(entry, new_referrer); if (old_refs) free(old_refs); }
這裡呼叫append_referrer
時,由於已經設定了out_of_line_ness
,out_of_line
函式將會返回 true,在資料再次溢位 hash table 之前,我們可以直接走插入流程。