OC内存管理-引用计数器

引用计数的存储策略

  1. 有些对象如果支持使用Tagged Pointer,苹果会直接将其指针值作为引用计数返回;
  2. 如果当前设备是64位环境并且使用Objective-C 2.0,那么“一些”对象会使用其isa指针的一部分空间来存储它的引用计数;
  3. 否则Runtime会使用一张散列表来管理引用计数。

Tagged Pointer

从64bit开始,iOS引入了Tagged Pointer技术,用来优化NSNumberNSDateNSString等小对象的内存。

我们知道在没有使用Tagged Pointer之前,NSNumberNSDateNSString等对象需要动态分配内存,维护引用计数等。对象的指针存的是一个指向堆空间的地址值。以NSNumber为例,我们在创建NSObject对象的时候,至少需要16个字节的内存大小(这个与OC内存对齐所使用的系数有关),另外还要用其他的内存空间来存NSNumber所对应的值,看上去这无疑在增加内存的开销。

在使用了Tagged Pointer之后,它的指针不再是地址了,而是真正的值,准确的说是Tag+Data。所以,实际上它不再是一个对象了,它只是一个披着对象皮的普通变量而已。所以,它的内存并不存储在堆中,也不需要mallocfree

在内存的读取上使用了Tagged Pointer技术的对象也会比之前快很多。

下面的代码用来反映在64位系统下Tagged Pointer的应用:

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
int main(int argc, char * argv[]) {
@autoreleasepool {
NSNumber *number1 = @1;
NSNumber *number2 = @2;
NSNumber *number3 = @3;
NSNumber *number4 = @4;

NSNumber *numberLarger = @(MAXFLOAT);

NSLog(@"number1 pointer is %p", number1);
NSLog(@"number2 pointer is %p", number2);
NSLog(@"number3 pointer is %p", number3);
NSLog(@"number4 pointer is %p", number4);
NSLog(@"numberLarger pointer is %p", numberLarger);

return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
}

// 打印结果:
2018-09-25 15:26:05.788382+0800 NSObjectProject[68029:24580896] number1 pointer is 0x9c344c19d780bc93
2018-09-25 15:26:05.789257+0800 NSObjectProject[68029:24580896] number2 pointer is 0x9c344c19d780bca3
2018-09-25 15:26:05.789383+0800 NSObjectProject[68029:24580896] number3 pointer is 0x9c344c19d780bcb3
2018-09-25 15:26:05.789489+0800 NSObjectProject[68029:24580896] number4 pointer is 0x9c344c19d780bcc3
2018-09-25 15:26:05.789579+0800 NSObjectProject[68029:24580896] numberLarger pointer is 0x600001e60d80

上面的打印结果中,除了numberLarger这个变量之外,其他变量都使用Tagged Pointer技术。那如何知道对象是否是Tagged Pointer的对象呢?接着看一下源码中的定义:

1
2
3
4
5
6
7
inline bool objc_object::isTaggedPointer() {
return _objc_isTaggedPointer(this);
}

static inline bool _objc_isTaggedPointer(const void * _Nullable ptr) {
return ((uintptr_t)ptr & _OBJC_TAG_MASK) == _OBJC_TAG_MASK;
}

_OBJC_TAG_MASK是一个依赖于环境的宏,其相关定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
#if (TARGET_OS_OSX || TARGET_OS_IOSMAC) && __x86_64__
// 64-bit Mac - tag bit is LSB
# define OBJC_MSB_TAGGED_POINTERS 0
#else
// Everything else - tag bit is MSB
# define OBJC_MSB_TAGGED_POINTERS 1
#endif

#if OBJC_MSB_TAGGED_POINTERS
# define _OBJC_TAG_MASK (1UL<<63)
#else
# define _OBJC_TAG_MASK 1UL
#endif

如果是mac OS 64bit的环境下,_OBJC_TAG_MASK1UL,如果是iOS 64bit的环境下,_OBJC_TAG_MASK1UL<<631UL就是1)。

所以判断是否Tagged Pointer的对象需要分环境考虑。如果是mac 64bit的环境下,则查看其指针最低有效位是否为1,如果是iOS 64bit的环境下,则使用1UL<<63,即1往左偏移63即0x1000000000000000,即查看最高有效位是否为1。

上述实验是在iphone手机下运行的结果。我们将上面地址(16进制)的第一位转成二进制,分别是10011001100110010110,做$运算后,除了numberLarger对应的变量以外,其他的都是0x1000000000000000。另外我们在创建对象的时候,只要是从堆空间里创建的对象,它的最低有效位一定是0。这还是因为OC对象的内存对齐使用的系数为16,所以内存地址一定是16的倍数。但是使用了Tagged Pointer技术的对象,它的最低有效位就不一定是0了。从这里也证明了使用Tagged Pointer的对象的内存并不存储在堆中,也不需要mallocfree。在后续的源码中,也会看到但凡是Tagged Pointer的对象,很多都会直接return

到这里我们得出几个结论:

  1. 从64bit开始,iOS对NSNumber、NSDate、NSString等小对象会优先使用Tagged Pointer技术进行存储,即指针存储。当指针存储不下内容的时候,才会使用堆空间即动态分配内存;
  2. 在mac OS 64bit下,通过看最低有效位是否为1判断是否Tagged Pointer的对象,在iOS 64bit下,通过查看最高有效位是否为1来判断;

我们知道,所有对象都有其对应的isa指针,那么引入Tagged Pointer会对isa指针产生什么影响。

isa指针

isa的本质——isa_t联合体

在Objective-C语言中,类也是对象,且每个对象都包含一个isa指针,isa指针指向该对象所属的类。
在arm64架构之前,isa就是一个普通的指针,存储着Class或者Meta-Class对象的内存地址。从arm64开始,Runtime对isa进行了优化,变成了一个union(共用体或者联合体)结构,还使用位域来存储更多的信息。

在C语言中,结构体可以包含多个类型不同的成员,各个成员会占用不同的内存,互相之间没有影响。结构体占用的内存大于等于所有成员占用的内存的总和(成员之间可能会存在缝隙)。而联合体,是一种与结构体非常接近的数据结构,但是有所区别。共用体占用的内存等于最长的成员占用的内存,这就是说共用体的所有成员占用同一段内存,修改一个成员会影响其余所有成员。

objc_object这个结构体中定义了isa指针,这里我们只看arm64下的相关定义:

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
struct objc_object {
isa_t isa;
}

// isa_t的定义
union isa_t {
isa_t() { }
isa_t(uintptr_t value) : bits(value) { }

Class cls;
uintptr_t bits;

#if SUPPORT_PACKED_ISA
# 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)
};
# endif

// SUPPORT_PACKED_ISA
#endif
};
  • nonpointer 该变量占用1 bit内存空间,可以有两个值:0和1,分别代表不同的isa_t的类型:

  • has_assoc 该变量与对象的关联引用有关。

  • has_cxx_dtor 表示该对象是否有析构函数,如果有析构函数,则需要做析构逻辑;如果没有,则可以更快的释放对象。

  • shiftcls 在开启指针优化的情况下,用33bits存储类指针的值。在initIsa()中有newisa.shiftcls = (uintptr_t)cls >> 3;这样的代码,就是将类指针存在isa中。

  • magic 用于调试器判断当前对象是真的对象还是没有初始化的空间

  • weakly_referenced 标志对象是否被指向或者曾经指向一个 ARC 的弱变量,没有弱引用的对象可以更快释放。

  • deallocating 标志对象是否正在释放内存。

  • extra_rc 存储的是引用计数

  • has_sidetable_rc 当引用计数器过大的时候,那么引用计数会存储在一个叫SideTable的类的属性中。

  • ISA_MAGIC_MASK 通过掩码方式获取magic值。

  • SA_MASK 通过掩码方式获取isa的类指针值。

  • RC_ONERC_HALF 用于引用计数的相关计算。

nonpointer的0表示开启指针优化即普通的指针,访问objc_object的isa会直接返回cls变量,cls变量会指向对象所属的类的结构;1表示开启指针优化,不能直接访问objc_object的isa成员变量(此时的isa而是一个Tagged Pointer),isa中包含了类信息、对象的引用计数等信息。

extra_rc占了19位,可以存储的最大引用计数应该是$2^{19} - 1 + 1= 524288$(为什么要这么写是因为extra_rc保存的是值-1,而在获取引用计数的时候会+1),当超过它就需要SideTablesSideTables内包含一个RefcountMap,用来保存引用计数,根据对象地址取出其引用计数,类型是size_t

这里有个问题,为什么既要使用一个extra_rc又要使用SideTables

可能是因为历史问题,以前cpu是32位的,isa中能存储的引用计数就只有$2^7=128$。因此在arm64下,引用计数通常是存储在isa中的。

更具体的会在retain操作的时候讲到。

isa_t联合体里面的宏

SUPPORT_PACKED_ISA

表示平台是否支持在isa指针中插入除Class之外的信息。

  1. 如果支持就会将Class信息放入isa_t定义的struct内,并附上一些其他信息,例如上面的nonpointer等等;
  2. 如果不支持,那么不会使用isa_t内定义的struct,这时isa_t只使用cls(Class 指针)。

在iOS以及MacOSX设备上,SUPPORT_PACKED_ISA定义为1

SUPPORT_INDEXED_ISA

SUPPORT_INDEXED_ISA表示isa_t中存放的Class信息是Class的地址。在initIsa()中有:

1
2
#if SUPPORT_INDEXED_ISA
newisa.indexcls = (uintptr_t)cls->classArrayIndex();

iOS设备上SUPPRT_INDEXED_ISA是0

isa类型有关的宏

SUPPORT_NONPOINTER_ISA

用于标记是否支持优化的isa指针,其定义:

1
2
3
4
5
#if !SUPPORT_INDEXED_ISA  &&  !SUPPORT_PACKED_ISA
# define SUPPORT_NONPOINTER_ISA 0
#else
# define SUPPORT_NONPOINTER_ISA 1
#endif

那如何判断是否支持优化的isa指针?

  1. 已知iOS系统的SUPPORT_PACKED_ISA为1,SUPPORT_INDEXED_ISA为0,从上面的定义可以看出,iOS系统的SUPPORT_NONPOINTER_ISA为1;
  2. 在环境变量中设置OBJC_DISABLE_NONPOINTER_ISA

这里需要注意的是,即使是64位环境下,优化的isa指针并不是就一定会存储引用计数,毕竟用19bit iOS 系统)保存引用计数不一定够。另外这19位保存的是引用计数的值减一。

SideTable

在源码中我们经常会看到SideTable这个结构体。它的定义:

1
2
3
4
5
6
7
struct SideTable {
spinlock_t slock;
RefcountMap refcnts;
weak_table_t weak_table;

//省略其他代码
};

从上面可知,SideTable中有三个成员变量:

  1. slock用于保证原子操作的自旋锁;
  2. refcnts用于引用计数的hash表;
  3. weak_table用于weak引用的hash表。

这里我们主要看引用计数的哈希表。RefcountMap的定义:typedef objc::DenseMap<DisguisedPtr<objc_object>,size_t,true> RefcountMap;

可以看出SideTable用来保存引用计数具体是用DenseMap这个类(在llvm-DenseMap.h中)实现的。DenseMapDisguisedPtr<objc_object>keysize_tvalueDisguisedPtr类是对objc_object *指针及其一些操作进行的封装,其内容可以理解为对象的内存地址,值的类型为__darwin_size_t,在 darwin 内核一般等同于 unsigned long。其实这里保存的值也是等于引用计数减1。

引用计数的获取

通过retainCount可以获取到引用计数器,其定义:

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
- (NSUInteger)retainCount {
return ((id)self)->rootRetainCount();
}

inline uintptr_t objc_object::rootRetainCount() {
if (isTaggedPointer()) return (uintptr_t)this;

sidetable_lock();
// 加锁,用汇编指令ldxr来保证原子性
isa_t bits = LoadExclusive(&isa.bits);
// 释放锁,使用汇编指令clrex
ClearExclusive(&isa.bits);
if (bits.nonpointer) {
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();
}

//sidetable_retainCount()函数实现
uintptr_t objc_object::sidetable_retainCount() {
SideTable& table = SideTables()[this];

size_t refcnt_result = 1;

table.lock();
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;
}
table.unlock();
return refcnt_result;
}

从上面的代码可知,获取引用计数的时候分为三种情况:

  1. Tagged Pointer的话,直接返回isa本身;
  2. Tagged Pointer,且开启了指针优化,此时引用计数先从extra_rc中去取(这里将取出来的值进行了+1操作,所以在存的时候需要进行-1操作),接着判断是否有SideTable,如果有再加上存在SideTable中的计数;
  3. Tagged Pointer,没有开启了指针优化,使用sidetable_retainCount()函数返回。

手动操作对引用计数的影响

objc_retain()

1
2
3
4
5
6
7
8
#if __OBJC2__
__attribute__((aligned(16))) id objc_retain(id obj) {
if (!obj) return obj;
if (obj->isTaggedPointer()) return obj;
return obj->retain();
}
#else
id objc_retain(id obj) { return [obj retain]; }

首先判断是否是Tagged Pointer的对象,是就返回对象本身,否则通过对象的retain()返回。

1
2
3
4
5
6
7
8
9
inline id objc_object::retain() {
assert(!isTaggedPointer());
// hasCustomRR方法检查类(包括其父类)中是否含有默认的方法
if (fastpath(!ISA()->hasCustomRR())) {
return rootRetain();
}

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

首先判断是否是Tagged Pointer,这个函数并不希望处理的对象是Tagged Pointer;接着通过hasCustomRR函数检查类(包括其父类)中是否含有默认的方法,有则调用自定义的方法;如果没有,调用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
36
37
38
39
40
41
42
ALWAYS_INLINE id objc_object::rootRetain() {
return rootRetain(false, false);
}

//将源码精简后的逻辑
ALWAYS_INLINE id
objc_object::rootRetain(bool tryRetain, bool handleOverflow)
{
if (isTaggedPointer()) return (id)this;

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;
}

从上面的可以看到:

  • Tagged Pointer直接返回对象本身;
  • newisa.nonpointer == 0没有开启指针优化,直接使用SideTable来存储引用计数;
  • 开启指针优化,使用isa的extra_rc保存引用计数,当超出的时候,使用SideTable来存储额外的引用计数。

objc_release()

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
#if __OBJC2__
__attribute__((aligned(16)))
void
objc_release(id obj) {
if (!obj) return;
if (obj->isTaggedPointer()) return;
return obj->release();
}
#else
void objc_release(id obj) { [obj release]; }
#endif

//release()源码
inline void
objc_object::release()
{
assert(!isTaggedPointer());

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

((void(*)(objc_object *, SEL))objc_msgSend)(this, SEL_release);
}

这边的逻辑和objc_retain()的逻辑一致,所以直接看rootRelease()函数,与上面一样,下面的代码也是经过精简的。

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
ALWAYS_INLINE bool
objc_object::rootRelease(bool performDealloc, bool handleUnderflow) {
if (isTaggedPointer()) return false;

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. 没有开启指针优化,使用SideTable存储的引用计数-1;
  3. 开启指针优化,使用isa的extra_rc保存的引用计数-1,当carry==0表示需要从SideTable保存的引用计数也用完了或者说引用计数为0,所以执行最后一步;
  4. 最后调用dealloc,所以这也回答了之前的《OC内存管理–对象的生成与销毁》中dealloc什么时候被调用这个问题,在rootRelease(bool performDealloc, bool handleUnderflow)函数中如果判断出引用计数为0了,就要调用dealloc函数了。

总结

  1. 引用计数存在什么地方?

    • Tagged Pointer不需要引用计数,苹果会直接将对象的指针值作为引用计数返回;
    • 开启了指针优化(nonpointer == 1)的对象其引用计数优先存在isaextra_rc中,大于524288便存在SideTableRefcountMap或者说是DenseMap中;
    • 没有开启指针优化的对象直接存在SideTableRefcountMap或者说是DenseMap中。
  2. retain/release的实质

    • Tagged Pointer不参与retain/release
    • 找到引用计数存储区域,然后+1/-1,并根据是否开启指针优化,处理进位/借位的情况;
    • 当引用计数减为0时,调用dealloc函数。
  3. isa是什么

    1
    2
    3
    4
    5
    // ISA() assumes this is NOT a tagged pointer object
    Class ISA();

    // getIsa() allows this to be a tagged pointer object
    Class getIsa();
    • 首先要知道,isa指针已经不一定是类指针了,所以需要用ISA()获取类指针;
    • Tagged Pointer的对象没有isa指针,有的是isa_t的结构体;
    • 其他对象的isa指针还是类指针。
  4. 对象的值是什么

    • 如果是Tagged Pointer,对象的值就是指针;
    • 如果非Tagged Pointer, 对象的值是指针指向的内存区域中的值。

补充: 一道多线程安全的题目

以下代码运行结果

1
2
3
4
5
6
7
8
9
@property (nonatomic, strong) NSString *target;
//....

dispatch_queue_t queue = dispatch_queue_create("parallel", DISPATCH_QUEUE_CONCURRENT);
for (int i = 0; i < 1000000 ; i++) {
dispatch_async(queue, ^{
self.target = [NSString stringWithFormat:@"ksddkjalkjd%d",i];
});
}

答案:大概率地发生Crash。

Crash的原因:过度释放。

这道题看着虽然是多线程范围的,但是解题的最重要思路确是在引用计数上,更准确的来说是看对强引用的理解程度。关键知识点如下:

  1. 全局队列和自定义并行队列在异步执行的时候会根据任务系统决定开辟线程个数;
  2. target使用strong进行了修饰,Block是会截获对象的修饰符的;
  3. 即使使用_target效果也是一样,因为默认使用strong修饰符隐式修饰;
  4. strong的源代码如下:
1
2
3
4
5
6
7
8
9
objc_storeStrong(id *location, id obj) {
id prev = *location;
if (obj == prev) {
return;
}
objc_retain(obj);
*location = obj;
objc_release(prev);
}

假设这个并发队列创建了两个线程A和B,由于是异步的,可以同时执行。因此会出现这么一个场景,在线程A中,代码执行到了objc_retain(obj),但是在线程B中可能执行到了objc_release(prev),此时prev已经被释放了。那么当A在执行到objc_release(prev)就会过度释放,从而导致程序crash。

解决方法:

  1. 加个互斥锁
  2. 使用串行队列,使用串行队列的话,其实内部是靠DISPATCH_OBJ_BARRIER_BIT设置阻塞标志位
  3. 使用weak
  4. Tagged Pointer,如果说上面的self.target指向的是一个Tagged Pointer技术的NSString,那程序就没有问题。