Objective-C 引用計數原理

發表於2016-04-12

引用計數如何儲存

有些物件如果支援使用 TaggedPointer,蘋果會直接將其指標值作為引用計數返回;如果當前裝置是 64 位環境並且使用 Objective-C 2.0,那麼“一些”物件會使用其 isa 指標的一部分空間來儲存它的引用計數;否則 Runtime 會使用一張雜湊表來管理引用計數。

其實還有一種情況會改變引用計數的儲存策略,那就是是否使用垃圾回收(用UseGC屬性判斷),但這種早已棄用的東西就不要管了,而且初始化垃圾回收機制的 void gc_init(BOOL wantsGC) 方法一直被傳入 NO

TaggedPointer

判斷當前物件是否在使用 TaggedPointer 是看標誌位是否為 1 :

id 其實就是 objc_object * 的簡寫(typedef struct objc_object *id;),它的 isTaggedPointer() 方法經常會在操作引用計數時用到,因為這決定了儲存引用計數的策略。

isa 指標(NONPOINTER_ISA)

用 64 bit 儲存一個記憶體地址顯然是種浪費,畢竟很少有那麼大記憶體的裝置。於是可以優化儲存方案,用一部分額外空間儲存其他內容。isa 指標第一位為 1 即表示使用優化的 isa 指標,這裡列出不同架構下的 64 位環境中 isa 指標結構:

SUPPORT_NONPOINTER_ISA 用於標記是否支援優化的 isa 指標,其字面含義意思是 isa 的內容不再是類的指標了,而是包含了更多資訊,比如引用計數,析構狀態,被其他 weak 變數引用情況。判斷方法也是根據裝置型別:

綜合看來目前只有 arm64 架構的裝置支援,下面列出了 isa 指標中變數對應的含義:

變數名 含義
indexed 0 表示普通的 isa 指標,1 表示使用優化,儲存引用計數
has_assoc 表示該物件是否包含 associated object,如果沒有,則析構時會更快
has_cxx_dtor 表示該物件是否有 C++ 或 ARC 的解構函式,如果沒有,則析構時更快
shiftcls 類的指標
magic 固定值為 0xd2,用於在除錯時分辨物件是否未完成初始化。
weakly_referenced 表示該物件是否有過 weak 物件,如果沒有,則析構時更快
deallocating 表示該物件是否正在析構
has_sidetable_rc 表示該物件的引用計數值是否過大無法儲存在 isa 指標
extra_rc 儲存引用計數值減一後的結果

在 64 位環境下,優化的 isa 指標並不是就一定會儲存引用計數,畢竟用 19bit (iOS 系統)儲存引用計數不一定夠。需要注意的是這 19 位儲存的是引用計數的值減一has_sidetable_rc 的值如果為 1,那麼引用計數會儲存在一個叫 SideTable 的類的屬性中,後面會詳細講。

雜湊表

雜湊表來儲存引用計數具體是用 DenseMap 類來實現,這個類中包含好多對映例項到其引用計數的鍵值對,並支援用 DenseMapIterator 迭代器快速查詢遍歷這些鍵值對。接著說鍵值對的格式:鍵的型別為 DisguisedPtrDisguisedPtr 類是對 objc_object * 指標及其一些操作進行的封裝,目的就是為了讓它給人看起來不會有記憶體洩露的樣子(真是心機裱),其內容可以理解為物件的記憶體地址;值的型別為 __darwin_size_t,在 darwin 核心一般等同於 unsigned long。其實這裡儲存的值也是等於引用計數減一。使用雜湊表儲存引用計數的設計很好,即使出現故障導致物件的記憶體塊損壞,只要引用計數表沒有被破壞,依然可以順藤摸瓜找到記憶體塊的位置。

之前說引用計數表是個雜湊表,這裡簡要說下雜湊的方法。有個專門處理鍵的 DenseMapInfo 結構體,它針對 DisguisedPtr 做了些優化匹配鍵值速度的方法:

當然這裡的雜湊演算法會根據是否為 64 位平臺來進行優化,演算法具體細節就不深究了,我總覺得蘋果在這裡的 hardcode 是隨便寫的:

再介紹下 SideTable 這個類,它用於管理引用計數表和 weak 表,並使用 spinlock_lock 自旋鎖來防止操作表結構時可能的競態條件。它用一個 64*128 大小的 uint8_t 靜態陣列作為 buffer 來儲存所有的 SideTable 例項。並提供三個公有屬性:

還提供了一個工廠方法,用於根據物件的地址在 buffer 中尋找對應的 SideTable 例項:

weak 表的作用是在物件執行 dealloc 的時候將所有指向該物件的 weak 指標的值設為 nil,避免懸空指標。這是 weak 表的結構:

蘋果使用一個全域性的 weak 表來儲存所有的 weak 引用。並將物件作為鍵,weak_entry_t 作為值。weak_entry_t 中儲存了所有指向該物件的 weak 指標。

獲取引用計數

在非 ARC 環境可以使用 retainCount 方法獲取某個物件的引用計數,其會呼叫 objc_objectrootRetainCount() 方法:

在 ARC 時代除了使用 Core Foundation 庫的 CFGetRetainCount() 方法,也可以使用 Runtime 的 _objc_rootRetainCount(id obj) 方法來獲取引用計數,此時需要引入 標頭檔案。這個函式也是呼叫 objc_objectrootRetainCount() 方法:

rootRetainCount() 方法對引用計數儲存邏輯進行了判斷,因為 TaggedPointer 前面已經說過了,可以直接獲取引用計數;64 位環境優化的 isa 指標前面也說過了,所以這裡的重頭戲是在 TaggedPointer 無法使用時呼叫的 sidetable_retainCount() 方法:

sidetable_retainCount() 方法的邏輯就是先從 SideTable 的靜態方法獲取當前例項對應的 SideTable 物件,其 refcnts 屬性就是之前說的儲存引用計數的雜湊表,這裡將其型別簡寫為 RefcountMap

然後在引用計數表中用迭代器查詢當前例項對應的鍵值對,獲取引用計數值,並在此基礎上 +1 並將結果返回。這也就是為什麼之前說引用計數表儲存的值為實際引用計數減一

需要注意的是為什麼這裡把鍵值對的值做了向右移位操作(it->second >> SIDE_TABLE_RC_SHIFT):

可以看出值的第一個 bit 表示該物件是否有過 weak 物件,如果沒有,在析構釋放記憶體時可以更快;第二個 bit 表示該物件是否正在析構。從第三個 bit 開始才是儲存引用計數數值的地方。所以這裡要做向右移兩位的操作,而對引用計數的 +1 和 -1 可以使用 SIDE_TABLE_RC_ONE,還可以用 SIDE_TABLE_RC_PINNED 來判斷是否引用計數值有可能溢位。

當然不能夠完全信任這個 _objc_rootRetainCount(id obj) 函式,對於已釋放的物件以及不正確的物件地址,有時也返回 “1”。它所返回的引用計數只是某個給定時間點上的值,該方法並未考慮到系統稍後會把自動釋放吃池清空,因而不會將後續的釋放操作從返回值裡減去。clang 會盡可能把 NSString 實現成單例物件,其引用計數會很大。如果使用了 TaggedPointer,NSNumber 的內容有可能就不再放到堆中,而是直接寫在寬敞的64位棧指標值裡。其看上去和真正的 NSNumber 物件一樣,只是使用 TaggedPointer 優化了下,但其引用計數可能不準確。

修改引用計數

retain 和 release

在非 ARC 環境下可以使用 retainrelease 方法對引用計數進行加一減一操作,它們分別呼叫了 _objc_rootRetain(id obj)_objc_rootRelease(id obj) 函式,不過後兩者在 ARC 環境下也可使用。最後這兩個函式又會呼叫 objc_object 的下面兩個方法:

這樣的實現跟獲取引用計數類似,先是看是否支援 TaggedPointer(畢竟資料存在棧指標而不是堆中,棧的管理本來就是自動的),否則去操作 SideTable 中的 refcnts 屬性,這與獲取引用計數策略類似。sidetable_retain() 將 引用計數加一後返回物件,sidetable_release() 返回是否要執行 dealloc 方法:

看到這裡知道為什麼在儲存引用計數時總是真正的引用計數值減一了吧。因為 release 本來是要將引用計數減一,所以儲存引用計數時先預留了個“一”,在減一之前先看看儲存的引用計數值是否為 0 (it->second ),如果是,那就將物件標記為“正在析構”(it->second |= SIDE_TABLE_DEALLOCATING),併傳送 dealloc 訊息,返回 YES;否則就將引用計數減一(it->second -= SIDE_TABLE_RC_ONE)。這樣做避免了負數的產生。

除此之外,Core Foundation 庫中也提供了增減引用計數的方法。比如在使用 Toll-Free Bridge 轉換時使用的 CFBridgingRetainCFBridgingRelease 方法,其本質是使用 __bridge_retained__bridge_transfer 告訴編譯器此處需要如何修改引用計數:

此外 Objective-C 很多實現是靠 Core Foundation Runtime 來實現, Objective-C Runtime 原始碼中有些地方明確註明:”// Replaced by CF“,那就是意思說這塊任務被 Core Foundation 庫接管了。當然 Core Foundation 有一部分是開源的。還有一些 Objective-C Runtime 函式的實現被諸如 ObjectAllocNSZombie 這樣的記憶體管理工具所替代:

alloc, new, copy, mutableCopy

根據編譯器的約定,這以這四個單詞開頭的方法都會使引用計數加一。而 new 相當於呼叫 alloc 後再呼叫 init

可以看出 allocnew 最終都會呼叫 callAlloc,預設使用 Objective-C 2.0 且忽視垃圾回收和 NSZone,那麼後續的呼叫順序依次是為:

calloc() 函式相比於 malloc() 函式的優點是它將分配的記憶體區域初始化為0,相當於 malloc() 後再用 memset() 方法初始化一遍。

copymutableCopy 都是基於 NSCopyingNSMutableCopying 方法約定,分別呼叫各類自己實現的 copyWithZone:mutableCopyWithZone: 方法。這些方法無論實現方式是深拷貝還是淺拷貝,都會增加引用計數。(有些類的策略是懶拷貝,只增加引用計數但並不真的拷貝,等物件內容發生變化時再拷貝一份出來,比如 NSArray)。

retain 方法加符號斷點會發現 alloc, new, copy, mutableCopy 這四個方法都會通過 Core Foundation 的 CFBasicHashAddValue() 函式來呼叫 retain 方法。其實 CF 有個修改和檢視引用計數的入口函式 __CFDoExternRefOperation,在 CFRuntime.c 檔案中實現。

autorelease

本想貼上一堆 Runtime 中關於自動釋放池的原始碼然後說上一大堆,然後發現了太陽神的這篇黑幕背後的Autorelease把我想說的都說了,把我不知道的也說了,簡直太屌了。

其實通過看原始碼可以知道好多細節,沒事點進去各種巨集定義往往會得到驚喜:哇,原來是這麼回事,XX 就是 XX 之類。。。

Reference

http://www.sealiesoftware.com/blog/archive/2013/09/24/objc_explain_Non-pointer_isa.html

http://www.opensource.apple.com

相關文章