Objective-C 引用計數

引用計數如何存儲

有些對象如果支持使用 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
#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
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 指針中變量對應的含義:

blob.png

在 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 {
  static inline DisguisedPtr getEmptyKey() {
    return DisguisedPtr((T*)(uintptr_t)-1);
  }
  static inline DisguisedPtr getTombstoneKey() {
    return DisguisedPtr((T*)(uintptr_t)-2);
  }
  static unsigned getHashValue(const T *PtrVal) {
      return ptr_hash((uintptr_t)PtrVal);
  }
  static bool isEqual(const DisguisedPtr &LHS, const DisguisedPtr &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 自旋鎖來防止操作表結構時可能的競態條件。

獲取引用計數

在非 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
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
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 RefcountMap;

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

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

1
2
3
4
5
6
7
8
9
10
11
12
#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
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
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 < SIDE_TABLE_DEALLOCATING),如果是,那就將對象標記爲“正在析構”(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
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
// 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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章