探寻Objective-C引用计数本质

本文涉及到的CPU架构为arm64,其它架构大同小异。
源码来自苹果开源-runtime

Objective-C中采用引用计数机制来管理内存,在MRC时代,需要我们手动retainrelease,在苹果引入ARC后大部分时间我们不用再关心引用计数问题。但是为了深入Objective-C本质,引用计数究竟是怎么实现的还是值得我们去探寻的。

ISA

OC中的对象的实质其实是结构体,其中大部分对象都有isa,指向类对象(有一种神奇的存在叫做Tagged Pointer),源码中关于对象结构体objc_object定义如下:

1
2
3
4
5
6
7
8
9
// objc-private.h
struct objc_object {
private:
isa_t isa;
public:
id retain();
void release();
id autorelease();
... //省略了其它方法,感兴趣可以直接看源码

Tagged Pointer

除了有一种特殊的对象Tagged Pointer,这种类型的对象值就存在指针当中,存取性能高。可以用来存储少量数据的对象,例如NSNumber、NSDate、NSString。(更多Tagged Pointer知识,具体看这篇Tagged Pointer小记)。也就没有引用计数、内存释放的问题。

NONPOINTER ISA

arm64架构isa占64位,苹果为了优化性能,存储类对象地址只用了33位,剩下的位用来存储一些其它信息,比如本文讨论的引用计数。

NONPOINTER ISA存储的字段定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# if __arm64__
# define ISA_MASK 0x0000000ffffffff8ULL
# define ISA_MAGIC_MASK 0x000003f000000001ULL
# define ISA_MAGIC_VALUE 0x000001a000000001ULL
struct {
uintptr_t nonpointer : 1;
uintptr_t has_assoc : 1;
uintptr_t has_cxx_dtor : 1;
uintptr_t shiftcls : 33; // MACH_VM_MAX_ADDRESS 0x1000000000
uintptr_t magic : 6;
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)
};

extra_rc

那引用计数存在哪里呢?秘密就在extra_rc中。

extra_rc只是存储了额外的引用计数,实际的引用计数计算公式:引用计数=extra_rc+1

extra_rc占了19位,可以存储的最大引用计数:$2^{19}-1+1=524288$,超过它就需要进位到SideTables。SideTables是一个Hash表,根据对象地址可以找到对应的SideTableSideTable内包含一个RefcountMap,根据对象地址取出其引用计数,类型是size_t
它是一个unsigned long,最低两位是标志位,剩下的62位用来存储引用计数。我们可以计算出引用计数的理论最大值:$2^{62+19}=2.417851639229258e24$。

其实isa能存储的524288在日常开发已经完全够用了,为什么还要搞个Side Table?我猜测是因为历史问题,以前cpu是32位的,isa中能存储的引用计数就只有$2^{7}=128$。因此在arm64下,引用计数通常是存储在isa中的。

retain

有了前面的铺垫,我们知道引用计数怎么存储的了,那引用计数又是怎么改变的呢?通过剖析retain源码我们就可以得出结论了。
objc_object的方法全部定义在objc-object.h文件中,全是内联函数,应该是为了性能的考虑。

我们来看看retain的函数定义

1
2
3
4
5
6
7
8
9
10
11
inline id 
objc_object::retain()
{
assert(!isTaggedPointer());

if (fastpath(!ISA()->hasCustomRR())) {
return rootRetain();
}

return ((id(*)(objc_object *, SEL))objc_msgSend)(this, SEL_retain);
}

这层比较简单,做了三件事情:

  1. 判断指针是不是Tagged Pointer
  2. 判断是否有自定义retain,如果有调用自定义的。
  3. 最后调用rootRetain

我们来看看关键函数rootRetain的实现(为了便于阅读,代码有所删减)

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
ALWAYS_INLINE id 
objc_object::rootRetain(bool tryRetain, bool handleOverflow)
{
isa_t oldisa;
isa_t newisa;

// 加锁,用汇编指令ldxr来保证原子性
oldisa = LoadExclusive(&isa.bits);
newisa = oldisa;

if (newisa.nonpointer = 0) {
// newisa.nonpointer = 0说明所有位数都是地址值
// 释放锁,使用汇编指令clrex
ClearExclusive(&isa.bits);

// 由于所有位数都是地址值,直接使用sidetable来存储引用计数
return sidetable_retain();
}

// 存储extra_rc++后的结果
uintptr_t carry;
// extra_rc++
newisa.bits = addc(newisa.bits, RC_ONE, 0, &carry);

if (carry == 0) {
// extra_rc++后溢出,进位到side table
newisa.extra_rc = RC_HALF;
newisa.has_sidetable_rc = true;
sidetable_addExtraRC_nolock(RC_HALF);
}

// 将newisa写入isa
StoreExclusive(&isa.bits, oldisa.bits, newisa.bits)
return (id)this;
}

有一个细节可以了解下,如何用汇编来实现原子性操作。

1
2
3
4
5
6
7
8
9
10
11
static ALWAYS_INLINE
uintptr_t
LoadExclusive(uintptr_t *src)
{
uintptr_t result;
// 在多核CPU下,对一个地址的访问可能引起冲突,ldxr解决了冲突,保证原子性。
asm("ldxr %x0, [%x1]"
: "=r" (result)
: "r" (src), "m" (*src));
return result;
}

release

release代码逻辑基本上就是retain反过来走一遍,有点不同的是在引用计数减到0时,会调用对象的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
32
33
34
35
36
37
38
39
40
41
ALWAYS_INLINE bool
objc_object::rootRelease(bool performDealloc, bool handleUnderflow)
{
isa_t oldisa;
isa_t newisa;

retry:
oldisa = LoadExclusive(&isa.bits);
newisa = oldisa;
if (newisa.nonpointer == 0) {
ClearExclusive(&isa.bits);
if (sideTableLocked) sidetable_unlock();
return sidetable_release(performDealloc);
}

uintptr_t carry;
// extra_rc--
newisa.bits = subc(newisa.bits, RC_ONE, 0, &carry);
if (carry == 0) {
// 需要从SideTable借位,或者引用计数为0
goto underflow;
}

// 存储引用计数到isa
StoreReleaseExclusive(&isa.bits,
oldisa.bits, newisa.bits)
return false;

underflow:
// 从SideTable借位
// 或引用计数为0,调用delloc

// 此处省略N多代码
// 总结一下:修改Side Table与extra_rc,

// 引用计数减为0时,调用dealloc
if (performDealloc) {
((void(*)(objc_object *, SEL))objc_msgSend)(this, SEL_dealloc);
}
return true;
}

小结

引用计数存在哪?

  1. Tagged Pointer不需要引用计数
  2. NONPOINTER ISA(isa的第一位为1)的引用计数优先存在isa中,大于524288了进位到Side Tables
  3. NONPOINTER ISA引用计数存在Side Tables

retain/release的实质

  • 找到引用计数存储区域,然后+1/-1
  • 如果是NONPOINTER ISA,还要处理进位/借位的情况
  • release在引用计数减为0时,调用dealloc

参考