引用計數如何儲存
有些物件如果支援使用 TaggedPointer,蘋果會直接將其指標值作為引用計數返回;如果當前裝置是 64 位環境並且使用 Objective-C 2.0,那麼“一些”物件會使用其 isa
指標的一部分空間來儲存它的引用計數;否則 Runtime 會使用一張雜湊表來管理引用計數。
其實還有一種情況會改變引用計數的儲存策略,那就是是否使用垃圾回收(用UseGC
屬性判斷),但這種早已棄用的東西就不要管了,而且初始化垃圾回收機制的 void gc_init(BOOL wantsGC)
方法一直被傳入 NO
。
TaggedPointer
判斷當前物件是否在使用 TaggedPointer 是看標誌位是否為 1 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
#if SUPPORT_MSB_TAGGED_POINTERS # define TAG_MASK (1ULL<<63) #else # define TAG_MASK 1 inline bool objc_object::isTaggedPointer() { #if SUPPORT_TAGGED_POINTERS return ((uintptr_t)this & TAG_MASK); #else return false; #endif } |
id
其實就是 objc_object *
的簡寫(typedef struct objc_object *id;
),它的 isTaggedPointer()
方法經常會在操作引用計數時用到,因為這決定了儲存引用計數的策略。
isa 指標(NONPOINTER_ISA)
用 64 bit 儲存一個記憶體地址顯然是種浪費,畢竟很少有那麼大記憶體的裝置。於是可以優化儲存方案,用一部分額外空間儲存其他內容。isa
指標第一位為 1 即表示使用優化的 isa
指標,這裡列出不同架構下的 64 位環境中 isa
指標結構:
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 |
union isa_t { isa_t() { } isa_t(uintptr_t value) : bits(value) { } Class cls; uintptr_t bits; #if SUPPORT_NONPOINTER_ISA # if __arm64__ # define ISA_MASK 0x00000001fffffff8ULL # define ISA_MAGIC_MASK 0x000003fe00000001ULL # define ISA_MAGIC_VALUE 0x000001a400000001ULL struct { uintptr_t indexed : 1; uintptr_t has_assoc : 1; uintptr_t has_cxx_dtor : 1; uintptr_t shiftcls : 30; // MACH_VM_MAX_ADDRESS 0x1a0000000 uintptr_t magic : 9; uintptr_t weakly_referenced : 1; uintptr_t deallocating : 1; uintptr_t has_sidetable_rc : 1; uintptr_t extra_rc : 19; # define RC_ONE (1ULL<<45) # define RC_HALF (1ULL<<18) }; # elif __x86_64__ # define ISA_MASK 0x00007ffffffffff8ULL # define ISA_MAGIC_MASK 0x0000000000000001ULL # define ISA_MAGIC_VALUE 0x0000000000000001ULL struct { uintptr_t indexed : 1; uintptr_t has_assoc : 1; uintptr_t has_cxx_dtor : 1; uintptr_t shiftcls : 44; // MACH_VM_MAX_ADDRESS 0x7fffffe00000 uintptr_t weakly_referenced : 1; uintptr_t deallocating : 1; uintptr_t has_sidetable_rc : 1; uintptr_t extra_rc : 14; # define RC_ONE (1ULL<<50) # define RC_HALF (1ULL<<13) }; # else // Available bits in isa field are architecture-specific. # error unknown architecture # endif // SUPPORT_NONPOINTER_ISA #endif }; |
SUPPORT_NONPOINTER_ISA
用於標記是否支援優化的 isa
指標,其字面含義意思是 isa
的內容不再是類的指標了,而是包含了更多資訊,比如引用計數,析構狀態,被其他 weak 變數引用情況。判斷方法也是根據裝置型別:
1 2 3 4 5 6 |
// Define SUPPORT_NONPOINTER_ISA=1 to enable extra data in the isa field. #if !__LP64__ || TARGET_OS_WIN32 || TARGET_IPHONE_SIMULATOR || __x86_64__ # define SUPPORT_NONPOINTER_ISA 0 #else # define SUPPORT_NONPOINTER_ISA 1 #endif |
綜合看來目前只有 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
迭代器快速查詢遍歷這些鍵值對。接著說鍵值對的格式:鍵的型別為 DisguisedPtr
,DisguisedPtr
類是對 objc_object *
指標及其一些操作進行的封裝,目的就是為了讓它給人看起來不會有記憶體洩露的樣子(真是心機裱),其內容可以理解為物件的記憶體地址;值的型別為 __darwin_size_t
,在 darwin 核心一般等同於 unsigned long
。其實這裡儲存的值也是等於引用計數減一。使用雜湊表儲存引用計數的設計很好,即使出現故障導致物件的記憶體塊損壞,只要引用計數表沒有被破壞,依然可以順藤摸瓜找到記憶體塊的位置。
之前說引用計數表是個雜湊表,這裡簡要說下雜湊的方法。有個專門處理鍵的 DenseMapInfo
結構體,它針對 DisguisedPtr
做了些優化匹配鍵值速度的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
struct DenseMapInfo<DisguisedPtr<T>> { static inline DisguisedPtr<T> getEmptyKey() { return DisguisedPtr<T>((T*)(uintptr_t)-1); } static inline DisguisedPtr<T> getTombstoneKey() { return DisguisedPtr<T>((T*)(uintptr_t)-2); } static unsigned getHashValue(const T *PtrVal) { return ptr_hash((uintptr_t)PtrVal); } static bool isEqual(const DisguisedPtr<T> &LHS, const DisguisedPtr<T> &RHS) { return LHS == RHS; } }; |
當然這裡的雜湊演算法會根據是否為 64 位平臺來進行優化,演算法具體細節就不深究了,我總覺得蘋果在這裡的 hardcode 是隨便寫的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
#if __LP64__ static inline uint32_t ptr_hash(uint64_t key) { key ^= key >> 4; key *= 0x8a970be7488fda55; key ^= __builtin_bswap64(key); return (uint32_t)key; } #else static inline uint32_t ptr_hash(uint32_t key) { key ^= key >> 4; key *= 0x5052acdb; key ^= __builtin_bswap32(key); return key; } #endif |
再介紹下 SideTable
這個類,它用於管理引用計數表和 weak
表,並使用 spinlock_lock
自旋鎖來防止操作表結構時可能的競態條件。它用一個 64*128 大小的 uint8_t
靜態陣列作為 buffer 來儲存所有的 SideTable
例項。並提供三個公有屬性:
1 2 3 |
spinlock_t slock;//保證原子操作的自選鎖 RefcountMap refcnts;//儲存引用計數的雜湊表 weak_table_t weak_table;//儲存 weak 引用的全域性雜湊表 |
還提供了一個工廠方法,用於根據物件的地址在 buffer 中尋找對應的 SideTable
例項:
1 |
static SideTable *tableForPointer(const void *p) |
weak
表的作用是在物件執行 dealloc
的時候將所有指向該物件的 weak
指標的值設為 nil
,避免懸空指標。這是 weak
表的結構:
1 2 3 4 5 6 |
struct weak_table_t { weak_entry_t *weak_entries; size_t num_entries; uintptr_t mask; uintptr_t max_hash_displacement; }; |
蘋果使用一個全域性的 weak
表來儲存所有的 weak
引用。並將物件作為鍵,weak_entry_t
作為值。weak_entry_t
中儲存了所有指向該物件的 weak
指標。
獲取引用計數
在非 ARC 環境可以使用 retainCount
方法獲取某個物件的引用計數,其會呼叫 objc_object
的 rootRetainCount()
方法:
1 2 3 |
- (NSUInteger)retainCount { return ((id)self)->rootRetainCount(); } |
在 ARC 時代除了使用 Core Foundation 庫的 CFGetRetainCount()
方法,也可以使用 Runtime 的 _objc_rootRetainCount(id obj)
方法來獲取引用計數,此時需要引入 標頭檔案。這個函式也是呼叫
objc_object
的 rootRetainCount()
方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
inline uintptr_t objc_object::rootRetainCount() { assert(!UseGC); if (isTaggedPointer()) return (uintptr_t)this; sidetable_lock(); isa_t bits = LoadExclusive(&isa.bits); if (bits.indexed) { uintptr_t rc = 1 + bits.extra_rc; if (bits.has_sidetable_rc) { rc += sidetable_getExtraRC_nolock(); } sidetable_unlock(); return rc; } sidetable_unlock(); return sidetable_retainCount(); } |
rootRetainCount()
方法對引用計數儲存邏輯進行了判斷,因為 TaggedPointer 前面已經說過了,可以直接獲取引用計數;64 位環境優化的 isa
指標前面也說過了,所以這裡的重頭戲是在 TaggedPointer 無法使用時呼叫的 sidetable_retainCount()
方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
uintptr_t objc_object::sidetable_retainCount() { SideTable *table = SideTable::tableForPointer(this); size_t refcnt_result = 1; spinlock_lock(&table->slock); RefcountMap::iterator it = table->refcnts.find(this); if (it != table->refcnts.end()) { // this is valid for SIDE_TABLE_RC_PINNED too refcnt_result += it->second >> SIDE_TABLE_RC_SHIFT; } spinlock_unlock(&table->slock); return refcnt_result; } |
sidetable_retainCount()
方法的邏輯就是先從 SideTable
的靜態方法獲取當前例項對應的 SideTable
物件,其 refcnts
屬性就是之前說的儲存引用計數的雜湊表,這裡將其型別簡寫為 RefcountMap
:
1 |
typedef objc::DenseMap<DisguisedPtr<objc_object>,size_t,true> RefcountMap; |
然後在引用計數表中用迭代器查詢當前例項對應的鍵值對,獲取引用計數值,並在此基礎上 +1 並將結果返回。這也就是為什麼之前說引用計數表儲存的值為實際引用計數減一。
需要注意的是為什麼這裡把鍵值對的值做了向右移位操作(it->second >> SIDE_TABLE_RC_SHIFT
):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
#ifdef __LP64__ # define WORD_BITS 64 #else # define WORD_BITS 32 #endif // The order of these bits is important. #define SIDE_TABLE_WEAKLY_REFERENCED (1UL<<0) #define SIDE_TABLE_DEALLOCATING (1UL<<1) // MSB-ward of weak bit #define SIDE_TABLE_RC_ONE (1UL<<2) // MSB-ward of deallocating bit #define SIDE_TABLE_RC_PINNED (1UL<<(WORD_BITS-1)) #define SIDE_TABLE_RC_SHIFT 2 #define SIDE_TABLE_FLAG_MASK (SIDE_TABLE_RC_ONE-1)RefcountMap |
可以看出值的第一個 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 環境下可以使用 retain
和 release
方法對引用計數進行加一減一操作,它們分別呼叫了 _objc_rootRetain(id obj)
和 _objc_rootRelease(id obj)
函式,不過後兩者在 ARC 環境下也可使用。最後這兩個函式又會呼叫 objc_object
的下面兩個方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
inline id objc_object::rootRetain() { assert(!UseGC); if (isTaggedPointer()) return (id)this; return sidetable_retain(); } inline bool objc_object::rootRelease() { assert(!UseGC); if (isTaggedPointer()) return false; return sidetable_release(true); } |
這樣的實現跟獲取引用計數類似,先是看是否支援 TaggedPointer(畢竟資料存在棧指標而不是堆中,棧的管理本來就是自動的),否則去操作 SideTable
中的 refcnts
屬性,這與獲取引用計數策略類似。sidetable_retain()
將 引用計數加一後返回物件,sidetable_release()
返回是否要執行 dealloc
方法:
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 |
bool objc_object::sidetable_release(bool performDealloc) { #if SUPPORT_NONPOINTER_ISA assert(!isa.indexed); #endif SideTable *table = SideTable::tableForPointer(this); bool do_dealloc = false; if (spinlock_trylock(&table->slock)) { RefcountMap::iterator it = table->refcnts.find(this); if (it == table->refcnts.end()) { do_dealloc = true; table->refcnts[this] = SIDE_TABLE_DEALLOCATING; } else if (it->second < SIDE_TABLE_DEALLOCATING) { // SIDE_TABLE_WEAKLY_REFERENCED may be set. Don't change it. do_dealloc = true; it->second |= SIDE_TABLE_DEALLOCATING; } else if (! (it->second & SIDE_TABLE_RC_PINNED)) { it->second -= SIDE_TABLE_RC_ONE; } spinlock_unlock(&table->slock); if (do_dealloc && performDealloc) { ((void(*)(objc_object *, SEL))objc_msgSend)(this, SEL_dealloc); } return do_dealloc; } return sidetable_release_slow(table, performDealloc); } |
看到這裡知道為什麼在儲存引用計數時總是真正的引用計數值減一了吧。因為 release 本來是要將引用計數減一,所以儲存引用計數時先預留了個“一”,在減一之前先看看儲存的引用計數值是否為 0 (it->second ),如果是,那就將物件標記為“正在析構”(
it->second |= SIDE_TABLE_DEALLOCATING
),併傳送 dealloc
訊息,返回 YES
;否則就將引用計數減一(it->second -= SIDE_TABLE_RC_ONE
)。這樣做避免了負數的產生。
除此之外,Core Foundation 庫中也提供了增減引用計數的方法。比如在使用 Toll-Free Bridge 轉換時使用的 CFBridgingRetain
和 CFBridgingRelease
方法,其本質是使用 __bridge_retained
和 __bridge_transfer
告訴編譯器此處需要如何修改引用計數:
1 2 3 4 5 6 7 |
NS_INLINE CF_RETURNS_RETAINED CFTypeRef __nullable CFBridgingRetain(id __nullable X) { return (__bridge_retained CFTypeRef)X; } NS_INLINE id __nullable CFBridgingRelease(CFTypeRef CF_CONSUMED __nullable X) { return (__bridge_transfer id)X; } |
此外 Objective-C 很多實現是靠 Core Foundation Runtime 來實現, Objective-C Runtime 原始碼中有些地方明確註明:”// Replaced by CF
“,那就是意思說這塊任務被 Core Foundation 庫接管了。當然 Core Foundation 有一部分是開源的。還有一些 Objective-C Runtime 函式的實現被諸如 ObjectAlloc
和 NSZombie
這樣的記憶體管理工具所替代:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// Replaced by ObjectAlloc + (id)allocWithZone:(struct _NSZone *)zone { return _objc_rootAllocWithZone(self, (malloc_zone_t *)zone); } // Replaced by CF (throws an NSException) + (id)init { return (id)self; } // Replaced by NSZombies - (void)dealloc { _objc_rootDealloc(self); } |
alloc, new, copy, mutableCopy
根據編譯器的約定,這以這四個單詞開頭的方法都會使引用計數加一。而 new
相當於呼叫 alloc
後再呼叫 init
:
1 2 3 4 5 6 7 8 9 10 11 |
id _objc_rootAlloc(Class cls) { return callAlloc(cls, false/*checkNil*/, true/*allocWithZone*/); } + (id)alloc { return _objc_rootAlloc(self); } + (id)new { return [callAlloc(self, false/*checkNil*/) init]; } |
可以看出 alloc
和 new
最終都會呼叫 callAlloc
,預設使用 Objective-C 2.0 且忽視垃圾回收和 NSZone,那麼後續的呼叫順序依次是為:
1 2 3 |
class_createInstance() _class_createInstanceFromZone() calloc() |
calloc()
函式相比於 malloc()
函式的優點是它將分配的記憶體區域初始化為0,相當於 malloc()
後再用 memset()
方法初始化一遍。
copy
和 mutableCopy
都是基於 NSCopying
和 NSMutableCopying
方法約定,分別呼叫各類自己實現的 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