互联网科技

探究引用计数的实现

作者:金沙国际官网    发布时间:2020-04-20 07:27     浏览次数 :68

[返回]
开启了Non-pointer

retain时。

id objc_object::rootRetain(bool tryRetain, bool handleOverflow) { ... //其实就是对isa的extra_rc变量进行+1,前面说到isa会存很多东西 addc(newisa.bits, 1, 0, &carry); ...}

release时。

bool objc_object::rootRelease(bool performDealloc, bool handleUnderflow) { ... //其实就是对isa的extra_rc变量进行-1 subc(newisa.bits, 1, 0, &carry); ...}

retainCount时。

uintptr_t objc_object::rootRetainCount() { ... //其实就是获取isa的extra_rc值再+1,alloc新建一个对象时bits.extra_rc为0并不是1,这个要注意。 uintptr_t rc = 1 + bits.extra_rc; ...}

如果对象开启了Non-pointer,那么引用计数是存在isa中的,引用计数超过255将附加SideTable辅助存储。更新:看网络上该系列文章时发现自己漏了一个细节,那就是extra_rc是有存储限制,经过测试为255,如果超过255将会附加SideTable辅助存储。详细解释看这里。

  • 自己生成的对象,自己持有(使用 allow/new/copy/mutableCopy 开头的方法生成并持有对象);

     id obj = [[NSObject alloc] init];
    
  • 非自己生成的对象,自己也能持有(发送 -retain 消息持有对象);

     NSMutableArray *array = [NSMutableArray array]; [array retain];
    
  • 不再需要自己持有的对象时,应该交出自己的对象所有权(发送 -release 消息释放对象所有权,或者发送 -autorelease 消息延迟释放);

     id obj = [[NSObject alloc] init]; [obj release];
    
  • 无法释放自己不持有的对象的所有权;

TaggedPointer

未开启Non-pointer isa

这个是最麻烦的,因为要用到SideTable,里面一大堆逻辑;我们拿上面的Person举例,请记住对象的地址。retain时。

id objc_object::rootRetain(bool tryRetain, bool handleOverflow) { ... sidetable_retain(); ...}id objc_object::sidetable_retain() { SideTable& table = SideTables()[this];}

在这里不得不讲清楚SideTable的内部实现了,如果不讲清楚则没办法继续看代码。

SideTable中有三个结构体。spinlock_t:锁,这个就不用说了,一个支持多线程环境运行的库肯定得考虑这个;weak_table_t:weak表就是这个,用来处理弱引用的,不过本文不讲;RefcountMap:引用表,引用计数就是这个存的,这个要好好的说明白。RefcountMap的定义:typedef objc::DenseMap<DisguisedPtr<objc_object>,size_t,true> RefcountMap;又臭又长,本来想把类展开出来的,但是发现类会非常的大,而且很难懂;所以我这里讲一下逻辑就可以了,你感兴趣可以深入看看。当我们第一次通过SideTables()[this]取得table时,这个table中refcnts内容是空的。(我们省略spinlock_t和weak_table_t):SideTable table = { ... RefcountMap refcnts = { BucketT *Buckets = NULL; unsigned NumEntries = 0; unsigned NumTombstones = 0; unsigned NumBuckets = 0; } ...}接下来程序会执行size_t &refcntStorage = table.refcnts[this];这句话是在干嘛呢?RefcountMap继承自DenseMapBase,DenseMapBase中重载了[]操作符,所以会进入[],下面的代码是我展开后的部分代码,方便理解。class DenseMapBase { ... //目的很明确,就是取得一个pair<DisguisedPtr<objc_object>, size_t> size_t &operator[](DisguisedPtr<objc_object> &&Key) { pair<DisguisedPtr<objc_object>, size_t> &reslut = FindAndConstruct; return reslut.second; } ...}这里要注意一个细节,因为我们传进来的是this,而这里是用DisguisedPtr<objc_object>来接收的。所以会触发DisguisedPtr的初始化方法,所以this被转成了下面的对象。class DisguisedPtr Key = { uintptr_t value = 18446638383154282704;}下面来看FindAndConstruct()做了什么。pair<DisguisedPtr<objc_object>, size_t>& FindAndConstruct(const DisguisedPtr<objc_object> &Key) { //先定义一个用于接收结果的pair对象,关于pair我们肯定很熟悉了,相当于字典的一个key-value对, //pair.first就是实例对象地址转换成的DisguisedPtr< objc_object >类,pair.second就是这个对象的引用计数数量。 pair<DisguisedPtr<objc_object>, size_t> *TheBucket = nil; //如果找到了直接返回,因为TheBucket会在LookupBucketFor中被赋值 if (LookupBucketFor(Key, TheBucket)) return *TheBucket; //没有找到,就插入一个 return *InsertIntoBucket(Key, ValueT(), TheBucket);}//看看能不能找到key对于的pair<DisguisedPtr<objc_object>, size_t>bool LookupBucketFor(const LookupKeyT &Val, const BucketT *&FoundBucket) const { const pair<DisguisedPtr<objc_object>, size_t> *BucketsPtr = getBuckets(); const unsigned NumBuckets = getNumBuckets(); ...... (代码还是不贴出来了,我相信你也不想看,总结起来就是从buckets()中找到该实例对象对应的pair<DisguisedPtr<objc_object>, size_t>)}总结:1:取table时我们知道了对象的哈希值是可能一样的,如果哈希值一样那么会得到相同的table;2:相同的table又会根据对象转换成的DisguisedPtr<objc_object>对象在buckets中去取pair<DisguisedPtr<objc_object>, size_t>对;如果没有就会插入一条;插入到buckets哪个位置呢?哈希算法如下。static inline uint32_t ptr_hash(uint64_t key) { key ^= key >> 4; key *= 0x8a970be7488fda55;//这个是随意写的吧,没发现啥特别的 key ^= __builtin_bswap64; return key;}为此我依然做了一个测试,我把ptr_hash的值都返回1,模拟哈希冲突。static inline uint32_t ptr_hash(uint64_t key) { return 1;}然后我们打印one和two的retainCount看是否正确。[one retain];NSLog(@"%d",[one retainCount]);//2 [two retain];NSLog(@"%d",[two retainCount]);//2经过测试依然是正确的,说明内部会解决哈希冲突,也说明了这个哈希算法并不能产生唯一的值。这也就解决了上面留下的问题,获取table的时候没有解决哈希冲突,而是在查找pair对的时候有解决哈希冲突,方法就是找到下一个可用的位置,这也是很常见的哈希冲突解决方法;另外一个方法是用链表存所有哈希值一样的value,不过系统在这里并没有用这种方法。3:这个buckets的大小是会动态改变的,这也是RefcountMap refcnts = { BucketT *Buckets = NULL; unsigned NumEntries = 0; unsigned NumTombstones = 0; unsigned NumBuckets = 0;}中后三个变量的作用;装逼点的说法就是让数组具有伸缩性,提前处理一些临界值情况。

所以我们可以把refcnts中的Buckets看成一个数组,根据对象地址产生的哈希值和哈希冲突算法肯定能在Buckets中找到其对应的pair;我们接着往下走。

id objc_object::sidetable_retain() { ... //取得该实例对象在该table中对应pair<DisguisedPtr<objc_object>, size_t>对中size_t的引用,默认值为0。 size_t &refcntStorage = table.refcnts[this]; //SIDE_TABLE_RC_PINNED的值在64位系统为1<<63,也就是pow if ((refcntStorage & SIDE_TABLE_RC_PINNED) == false) { //SIDE_TABLE_RC_ONE的值为4,为什么要以4为单位,我不知道,估计是控制最大引用计数的值吧。 refcntStorage += SIDE_TABLE_RC_ONE; } return this;}我们知道了refcntStorage值最大为pow-4,因为再加refcntStorage & SIDE_TABLE_RC_PINNED就为false了。而引用计数最大为 - 4) >> 2 + 1 = pow = 2305843009213693952,为什么要+1?后面会说。还是需要数据说话,不然有人不相信。unsigned long number = SIDE_TABLE_RC_PINNED;//powunsigned long first = (unsigned long)pow - 4;unsigned long second = (unsigned long)pow;unsigned long max = first >> 2;unsigned long max111 = (unsigned long)pow; po second & number9223372036854775808 po first & number0 po max2305843009213693951 po max1112305843009213693952

retain我们就说完了,其实release也是这样的逻辑。release时。

前面的逻辑一样,拿到实例对象对应的pair。uintptr_t objc_object::sidetable_release(bool performDealloc) { ... //迭代器,it指向的就是pair<DisguisedPtr<objc_object>, size_t> RefcountMap::iterator it = table.refcnts.find; ... //SIDE_TABLE_RC_ONE = 4 it->second -= SIDE_TABLE_RC_ONE; ...}

retainCount时。

uintptr_t objc_object::sidetable_retainCount() { SideTable& table = SideTables()[this]; //这就是上面为什么说要+1的原因 size_t refcnt_result = 1; //迭代器,it指向的就是pair<DisguisedPtr<objc_object>, size_t> RefcountMap::iterator it = table.refcnts.find; if (it != table.refcnts.end { //SIDE_TABLE_RC_SHIFT = 2 refcnt_result += it->second >> SIDE_TABLE_RC_SHIFT; } ...}

下面 objc-runtime 的代码来源于 RetVal 的 Github。感谢作者的修复。

isa 指针(NONPOINTER_ISA)

进入正题

下面我们就开始看看对象的引用计数到底存哪里了。我先把判断优先级写一下。

1:对象是否是Tagged Pointer对象;2:对象是否启用了Non-pointer;3:对象未启用Non-pointer。

满足1则不判断2,依次类推。

换做引用计数来理解,通过 +alloc/-init 等方法生成一个对象,这对对象被你所持有,它的引用计数(retain count)是 1。对它发送 -retain 消息,引用计数加一,发送 -release 消息则减一,当其引用计数为 0 时,对象所占的内存被系统回收。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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);
}
使用Non-pointer的isa
isa_t isa = { Class class = Person; uintptr_t bits = 8303516107940673; struct { uintptr_t nonpointer = 1; uintptr_t has_assoc = 0; uintptr_t has_cxx_dtor = 0; uintptr_t shiftcls = 536872040; uintptr_t magic = 59; uintptr_t weakly_referenced = 0; uintptr_t deallocating = 0; uintptr_t has_sidetable_rc = 0; uintptr_t extra_rc = 0; }}extra_rc就是存的引用计数,nonpointer = 1表示启用Non-pointer。

isa的赋值是在alloc方法调用时,内部会进入initIsa()方法,你可以进去看一看有啥不同之处。

objc_object::initIsa(Class cls, bool nonpointer, bool hasCxxDtor) { if (!nonpointer) { isa.cls = cls; } else { isa_t newisa; ......  ...... isa = newisa; }}

引用计数的操作

上面扯完了引用计数相关的数据结构,那么接下来分析 -retainCount,-retain,-release 在 objc-runtime 源码中的实现。有两点需要注意的:

  1. objc-object.h 文件中对于这些方法背后函数的实现有两套,通过条件编译的宏 SUPPORT_NONPOINTER_ISA 区分,我第一次看的时候就搞蒙了;
  2. 这些方法上面都有 // Replaced by ObjectAlloc 这样的一行注释,应该是说这些方法被 Core Foundation 的实现给替换了,所以下面的分析可能与实际的逻辑不符。

下面的分析以 SUPPORT_NONPOINTER_ISA 为真的代码为例子。

-retainCount 的实现最终落到下面这个函数上:

inline uintptr_t objc_object::rootRetainCount(){ assert; 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();}

在调用 objc_object::rootRetainCount 时,如果当前对象使用的是 tagged pointer,那么直接返回自身的指针值。因为考究存在于栈上的变量的引用计数几乎没有什么意义,它的生命周期由栈来管理。接着,如果对象使用了 non-pointer isa,并且没有使用 SideTable 辅助计数,那么返回对象实例化后的计数值 1 加上额外被 retain 的次数 extra_rc(objc_object::sidetable_getExtraRC_nolock 这个函数实现就不贴了,同下面的差不多)。

对于使用纯粹的 isa 指针的对象,会调到下面这个函数,从 SideTable 中获得计数表,通过 this 指针获得迭代器并访问引用计数值:

uintptr_tobjc_object::sidetable_retainCount(){ SideTable& table = SideTables()[this]; size_t refcnt_result = 1; table.lock(); RefcountMap::iterator it = table.refcnts.find; 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;}

理解上面获取引用计数的函数实现之后,对于 retain 和 release 的实现就不难理解了。但由于 id objc_object::rootRetain(bool, bool)bool objc_object::rootRelease(bool, bool) 的实现都比较长,贴在这里有凑字数的嫌疑,而且使用了很多 goto 和递归,阅读起来也不太方便。

所以下面仅对一些关键的逻辑进行分析:

  • id objc_object::rootRetain(bool, bool) 中,如果对象是 tagged pointer object,那么直接返回该对象;对于普通的对象,如果其 isa 指针不用于优化存储,那么通过 goto unindexed; 跳到 unindexed 标签所标记的代码块,对 SideTable 的计数表进行操作;否则进入 do...while() 循环里面,通过下面的代码对 bits.extra 操作:

     newisa.bits = addc(newisa.bits, RC_ONE, 0, &carry); // extra_rc++
    

一旦溢出,对象启用 SideTable 辅助计数,extra_rc 的值为最大值的一半,而将另一半拷贝到对应的 SideTable 中的计数表中。

 // 每次溢出,transcribeToSideTable 为真 if (transcribeToSideTable) { sidetable_addExtraRC_nolock;}
  • bool objc_object::rootRelease(bool, bool) 中,对于 tagged pointer object 还是没有任何操作,直接返回。对于 goto unindexed; 跳转的那一块代码,调用 sidetable_release() 函数操作计数表。而在

     newisa.bits = subc(newisa.bits, RC_ONE, 0, &carry); // extra_rc--
    

之后,如果 extra_rc 出现下溢,那么要跳转到 underflow 那一块代码进行操作,从对象的辅助计数表中把原先加到里面的数“要”回来:

// Try to remove some retain counts from the side table. size_t borrowed = sidetable_subExtraRC_nolock;

如果“要”回来的数字大于零,那么将设置 extra_rc 并返回:

// Side table retain count decreased.// Try to add them to the inline count.newisa.extra_rc = borrowed - 1; // redo the original decrement too

否则直接往下执行,向对象发送 -dealloc 消息:

if (performDealloc) { (objc_object *, SEL))objc_msgSend)(this, SEL_dealloc);}

Advanced Memory Management Programming Guide

Objective-C 引用计数原理

  • retain 和 release
不使用Non-pointer的isa
isa_t isa = { Class class = Person; uintptr_t bits = 4294976320; struct { uintptr_t nonpointer = 0; uintptr_t has_assoc = 0; uintptr_t has_cxx_dtor = 0; uintptr_t shiftcls = 536872040; uintptr_t magic = 0; uintptr_t weakly_referenced = 0; uintptr_t deallocating = 0; uintptr_t has_sidetable_rc = 0; uintptr_t extra_rc = 0; }}其实可以简化为isa_t isa = { Class class = Person;}因为源码中显示不使用Non-pointer则只对isa的class赋值了,其他的都是默认值,而且除了class其他成员也不会在源码中被使用到。

整个内存管理模型都是围绕对象拥有权(object ownership)工作的:如果某个对象一直被其它对象所拥有,那么它就会存在,反之则以。遵循以下规则以保证对对象拥有权管理的正确性:

图片 1

Non-pointer isa

我们一直认为实例对象的isa都指向类对象,甚至还看到这样的源码。

typedef struct objc_object *idstruct objc_object { Class _Nonnull isa;}

其实这是之前版本的代码了,现在版本的代码早就变了。

struct objc_object {private: isa_t isa; ...}

所以实例对象的isa都指向类对象这样的说法不对。现在实例对象的isa是一个isa_t联合体,里面存了很多其他的东西,相信你也猜到了引用计数也在其中;如果该实例对象启用了Non-pointer,那么会对isa的其他成员赋值,否则只会对cls赋值。

union isa_t { Class cls; ... (还有很多其他的成员,包括引用计数数量)}

对象是否不启用Non-pointer目前有这么几个判断条件,这些都可以在runtime源码objc-runtime-new.m中找到逻辑。

1:包含swift代码;2:sdk版本低于10.11;3:runtime读取image时发现这个image包含__objc_rawisa段;4:开发者自己添加了OBJC_DISABLE_NONPOINTER_ISA=YES到环境变量中;5:某些不能使用Non-pointer的类,GCD等;6:父类关闭。

我们自己新建一个Person类,通过OBJC_DISABLE_NONPOINTER_ISA=YES/NO来看看isa结构体的具体内容,设置方法上面有截图。

设置了OBJC_DISABLE_NONPOINTER_ISA不一定就使用Non-pointer了,因为上面说到了还有其他条件会关闭Non-pointer。所以我们自己创建一个Person类继承自NSObject,这样只通过在环境变量中设置OBJC_DISABLE_NONPOINTER_ISA就可以控制Person对象是否启用Non-pointer,因为我们排除了除4之外的其他条件。

MRR 即为 “manual retain-release”,人为地插入 retain, release 等语句进行内存管理。

获取引用计数

Tagged Pointer对象

retain时。

id objc_object::rootRetain(bool tryRetain, bool handleOverflow) { if (isTaggedPointer return this; ...}

release时。

bool objc_object::rootRelease(bool performDealloc, bool handleUnderflow) { if (isTaggedPointer return false; ...}

retainCount时。

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

由此可见对于Tagged Pointer对象,并没有任何的引用计数操作,引用计数数量也只是单纯的返回自己地址罢了。

引用计数的存储

要知道引用计数是如何存储与操作,除了知道与计数相关的数据结构之外,还要知道 isa 指针的存储优化(non-pointer isa)和 tagged pointer 这两项技术,这些知识在下文中对 -retainCount 等实现的理解有帮助:

isa 指针通常用来指向对象所属的类,然而在 64 位的环境下,isa 还能存储一些额外的信息,毕竟 64 个比特仅仅存储一个类的地址确实有些浪费。那么,先瞄一下 isabits 的各个指针变量(以x86_64平台的为例)

 // 变量意义来源于:http://www.sealiesoftware.com/blog/archive/2013/09/24/objc_explain_Non-pointer_isa.html // 其意义可能已经有些改变,这里列出来仅供参考。 struct { uintptr_t indexed : 1; // 0 表示纯粹的 isa 指针,1 表示 non-pointer isa uintptr_t has_assoc : 1; // 是否有 associated object,没有的话 dealloc 会更快 uintptr_t has_cxx_dtor : 1; // 是否有 C++/ARC 的析构函数,没有的话 dealloc 会更快 uintptr_t shiftcls : 44; // 指向类的指针 uintptr_t magic : 6; // 0x02 用于在调试时区分未初始化的垃圾数据和已经初始化的对象 uintptr_t weakly_referenced : 1; // 是否被 weak 变量引用过,没有的话 dealloc 会更快 uintptr_t deallocating : 1; // 是否正在 deallocating uintptr_t has_sidetable_rc : 1; // 引用计数值是否太大,以至于无法存在 isa 中,需要 SideTable 辅助存储 uintptr_t extra_rc : 8; /* 额外的引用计数值。对象实例化时的本身的引用计数值为 1,而该值为 0。 向该对象发送 retain 消息后,extra_rc 增加 1。当 extra_rc 太大时,则需要 SideTable 辅助计数。*/ #define RC_ONE (1ULL<<56) // bits + RC_ONE 等于 extra_rc + 1 #define RC_HALF (1ULL<<7) };

同样的,tagged pointer 也是 64 位环境下一种利用指针优化存储技术,用来存储一些小对象(实际上只是栈上的一段数据,可能算不上是一个 Objective-C 对象),减少 malloc/free 在堆上的开销。在 objc_internal.h 中能看到以下的类型支持 tagged pointer:

 OBJC_TAG_NSAtom = 0, OBJC_TAG_1 = 1, OBJC_TAG_NSString = 2, OBJC_TAG_NSNumber = 3, OBJC_TAG_NSIndexPath = 4, OBJC_TAG_NSManagedObjectID = 5, OBJC_TAG_NSDate = 6, OBJC_TAG_7 = 7

对于一个 tagged pointer,其内存布局如下:

MSB 60 bit 3 bit 1 bit LSB
< payload tag index,即上面所列出来的类型 1 表示 tagged pointer 对象,0 表示普通对象 >

你可以写这么一段代码去验证对象是否为 tagged pointer 对象,以及检查它的类型:

 NSNumber *obj = @1; uintptr_t ptr = 0xF; uintptr_t result = ((uintptr_t)obj & ptr); NSLog(@"obj's pointer: %p", obj); NSLog(@"isTaggedPointer: %lu", result & 0x1); NSLog(@"TaggedPointerType: %lu", (result >> 1 & 0x7));

有人会试 NSString *obj = @"Hello!";,想看看它是不是 tagged pointer。答案是否定的。str 指向的是 TEXT 段的一个常量指针,合理的实验方式是 NSString *obj = [NSString stringWithFormat:@"Hello!"];

上面的讨论中,我们引出了一个 SideTable 这样的东西。当一个对象的引用计数很大时(extra_rc 超出所能表示的范围),需要它辅助记录对象的引用计数。此时实际的计数值:retainCount = 1 + extra_rc + sideTable.refcnts[obj] 中的值。在 NSObject.mm 中的它,看起来大概是这样的:

 typedef objc::DenseMap<DisguisedPtr<objc_object>,size_t,true> RefcountMap; struct SideTable { spinlock_t slock; // 自旋锁,保证对 sideTable 操作的原子性 RefcountMap refcnts; // 存储引用计数的哈希表 weak_table_t weak_table; // weak 表,这个放到 ARC 再讨论 ... }

SideTable 将自旋锁、引用计数表和一个 weak 表封装到了一起。当需要根据对象读取 SideTable 时,会从一个名为 SideTableBuf 的静态数组中找到相应的 SideTable:

 // 出于某些原因以下面这种方式分配 4096 个字节,即为 64 个 sideTable 的大小 alignas(sizeof(StripedMap<SideTable>)) static uint8_t SideTableBuf[sizeof(StripedMap<SideTable>)]; // StripedMap 重载了 [] 运算符,具体实现可以查看源码,这里不再赘叙 SideTable& table = SideTables()[this];

你可以理解 SideTableBuf 有 64 个格子,每个格子里面都有个 SideTable。每个对象指针可以通过计算映射到其中的一个格子中,然后再从格子中读取 refcnts 去找到自己的额外的引用计数。

值得注意的是存储引用计数的哈希表 RefcountMap refcnts,键是将对象指针包裹了一层的 DisguisedPtr,值是对象额外的引用计数值再左移两位,所以我们读取这个值的时候要再右移两位。

本想贴上一堆 Runtime 中关于自动释放池的源码然后说上一大堆,然后发现了太阳神的这篇黑幕背后的Autorelease把我想说的都说了,把我不知道的也说了,简直太屌了。

总结

这篇文章主要是讲了引用计数存在哪里,这只是庞大的引用计数管理中一个小的细节;引用计数管理还有其他的问题,比如。

1:系统怎么存weak变量,什么时候把weak变量置为nil的?2:autorelease、autoreleasepool以及RunLoop怎么合作的?3:ARC相对于MRC多出了什么内容?......

后面有时间我会写成文章的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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();
}

预备知识

要说清楚这个问题,我们需要先来了解下面的三个知识点。调试环境如下。

macOS:10.13.4;XCode:9.4;调试设备:My Mac。

sidetable_retainCount() 方法的逻辑就是先从 SideTable 的静态方法获取当前实例对应的 SideTable 对象,其 refcnts 属性就是之前说的存储引用计数的散列表,这里将其类型简写为 RefcountMap:

前言

最近偶尔出去面试了解一下现在iOS行情和面试会问的问题。其中有这样的一个问题被问到很多次:引用计数原理。回去查资料发现当时回答的很糟糕,于是就在这里单独写一篇文章记录下来。这篇文章只讲一个问题:引用计数的数量存哪里的,文末提到的其他问题后面会单独再写。

苹果使用一个全局的 weak 表来保存所有的 weak 引用。并将对象作为键,weak_entry_t 作为值。weak_entry_t 中保存了所有指向该对象的 weak 指针。

后记

这篇文章我用了两天的时间完成,自己仔细读了三次,本人是一枚菜鸟,如果你在阅读过程中发现不严谨或者错误的地方还请指出来;感激不尽。

之前说引用计数表是个散列表,这里简要说下散列的方法。有个专门处理键的 DenseMapInfo 结构体,它针对 DisguisedPtr 做了些优化匹配键值速度的方法:

Tagged Pointer

这个玩意的详细解释在这里,简单的说64位系统下,对于值小的对象指针本身已经存了值的内容了,而不用去指向一个地址再去取这个地址所存对象的值;相信你也知道了,如果是Tagged Pointer的话就少了创建对象的操作。

我们也可以在WWDC2013的《Session 404 Advanced in Objective-C》视频中,看到苹果对于Tagged Pointer特点的介绍:1:Tagged Pointer专门用来存储小的对象,例如NSNumber和NSDate2:Tagged Pointer指针的值不再是地址了,而是真正的值。所以,实际上它不再是一个对象了,它只是一个披着对象皮的普通变量而已。所以,它的内存并不存储在堆中,也不需要malloc和free。3:在内存读取上有着3倍的效率,创建时比以前快106倍。

图片 2图片 3

我们来测试一下。

NSLog(@"%p",@.intValue));//0x127NSLog(@"%p",@.intValue));//0x227由此可知int类型的tag为27,因为去掉27后0x1 = 1,0x2 = 2,正好是值。NSLog(@"%p",@.doubleValue));//0x157NSLog(@"%p",@.doubleValue));//0x257由此可知double类型的tag为57,因为去掉27后0x1 = 1,0x2 = 2,正好是值。明显0x127、0x257不是一个地址,所以@也不是一个对象,只是一个普通变量。

既然是Tagged Pointer那肯定得有一个tag,经过测试发现值类型不一样所具有的tag也会相对不一样。

为什么说相对,因为测试发现unsigned long和long long具有相同的tag值37。当然其他类型也有一样的。

什么时候NSNumber对象Tagged Pointer失效呢?那就是当值和tag加起来占用的字节数要超过地址长度时会失效:

为什么说要超过,而不是超过,这个我也比较纠结,具体的看看下面的例子。

这里针对double类型来举个例子,其他类型的结果可能稍有不同,因为上面说到tag有不同的值,所占用二进制位长度会不一样。

int 17:10001,5位;long long 37:100101,6位;double 57:111001,6位。...

这样64减去已占用的tag位,剩下的位来表示值,所能表示的范围也不一样。

double pow(double, double)返回的是double类型的值。NSLog(@"%p",@(pow - 3));//0x7ffffffffffffc5757是double类型的tag,0x7ffffffffffffc57去掉tag剩下的是0x7ffffffffffffc = pow - 3 = 36028797018963964;二进制表示为0...01...100。关于这里为什么要-3这就是我比较纠结的原因,因为二进制表示后面还有2个0啊,还可以多表示3啊;系统这么做肯定有自己的考虑,也许是我理解错了,希望你来指正。NSLog(@"%p",@(pow - 2));//0x6030002c50c0这个单纯就是一个地址了,没有57这个tag了,里面并没有存值的内容,所以Tagged Pointer失效了。从这个例子可以知道tag占用8位,64 - 55 = 9,9 - 1 = 8,因为第一位是来做符号位表示正负数的;上面我们测试出来57占用6个二进制位,为什么这里值最长占用56二进制长度呢,我也不知道。关于Tagged Pointer是否启用,你也可以通过下面的语句来打印,这个语句是runtime源码中的。NSNumber *number = @(pow - 3);NSLog(@"%d",((uintptr_t)number & 1UL) == 1UL);//truenumber = @(pow - 2);NSLog(@"%d",((uintptr_t)number & 1UL) == 1UL);//false

目前我所知的系统中可以启用Tagged Pointer的类对象有:NSDate、NSNumber、NSString,上面我们只举例了NSNumber,你可以自己下来试试另外的。

当然了你可以在环境变量中设置OBJC_DISABLE_TAGGED_POINTERS=YES强制不启用Tagged Pointer,环境变量我们可以添加很多东西的,具体的你可以看看runtime源码的objc-env.h文件。图片 4不启用Tagged Pointer这样runtime就会做相应的处理了。

不启用后上面的例子就会得到这样的结果,也就表示关闭成功了。

NSLog(@"%p",@(pow - 3));//0x6030002ccbc0NSLog(@"%p",@(pow - 2));//0x6030002ccc50NSNumber *number = @(pow - 3);NSLog(@"%d",((uintptr_t)number & 1UL) == 1UL);//falsenumber = @(pow - 2);NSLog(@"%d",((uintptr_t)number & 1UL) == 1UL);//false
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;
};

SideTable

散列表,这是一个比较重要的数据结构,相信你也猜到了这个和对象引用计数有关;如果该对象不是Tagged Pointer且关闭了Non-pointer,那该对象的引用计数就使用SideTable来存。我们先来看一下SideTable结构体定义,至于怎么被使用的且听我慢慢道来。

struct SideTable { //锁 spinlock_t slock; //强引用相关 RefcountMap refcnts; //弱引用相关 weak_table_t weak_table; ...}

启动应用后,我们第一次看到SideTable其实是在runtime读取image的时候。

void map_images_nolock(unsigned mhCount, const char* const mhPaths[], const struct mach_header *const mhdrs[]) { ... static bool firstTime = YES; if (firstTime) { AutoreleasePoolPage::init(); SideTableInit(); } ... }static void SideTableInit() { new (SideTableBuf)StripedMap<SideTable>();}

map_images_nolock会多次调用,因为ImageLoader一批加载很多个image到内存,然后通知runtime去读取这一批image,没错这时候runtime开始从image中处理类了;SideTableInit()方法只会执行一次。SideTableInit内部用到了SideTableBuf,SideTableBuf的定义如下。

alignas(StripedMap<SideTable>) static uint8_t SideTableBuf[sizeof(StripedMap<SideTable>)];sizeof(StripedMap<SideTable>) = 4096;alignas(StripedMap<SideTable>)是字节对齐的意思,表示让数组中每一个元素的起始位置对齐到4096的倍数,也把数组中每一个元素都变成了4096大小,能理解吧。所以这句话就简化为static uint8_t SideTableBuf[4096],也就是定义了一个4096大小类型为uint8_t的数组,每一个元素大小为4096,名字为SideTableBuf;现在来理解SideTableInit()中的new (SideTableBuf)StripedMap<SideTable>()。你会发现这句话没有任何意思,你注释后一样可以正常运行。因为上面那句话已经初始化SideTableBuf了,怎么说?看下面。在SideTableBuf定义上方有这样的一段注释。We cannot use a C++ static initializer to initialize SideTables becauselibc calls us before our C++ initializers run. We also don't want a global pointer to this struct because of the extra indirection.Do it the hard way.我来翻译一下:我们不能用C++静态初始化方法去初始化SideTables,因为C++初始化方法运行之前libc就会调用我们;我们同样不想用一个全局的指针去指向SideTables,因为需要额外的代价。但是没办法我们只能这样。看不懂没关系,下面就是答案。什么是C++ static initializer呢,我们依然可以在runtime源码中找到答案。在objc-os.mm中有这样的代码。size_t count;Initializer *inits = getLibobjcInitializers(&_mh_dylib_header, &count);for (size_t i = 0; i < count; i++) { inits[i]();}是的,这个就是在调用C++的initializer了,这个操作在map_images_nolock之前执行,也就是这时候还没有执行SideTableInit()。我们打印出其中一个方法名。......libobjc.A.dylib`defineLockOrder() at objc-os.mm:674......然后我们去defineLockOrder()方法中打个断点,跟踪一波。__attribute__((constructor)) static void defineLockOrder() { ...... SideTableLocksPrecedeLock(&crashlog_lock); ......}void SideTableLocksPrecedeLock(const void *newlock) { SideTables().precedeLock;}然后会进入这个方法。static StripedMap<SideTable>& SideTables() { return *reinterpret_cast<StripedMap<SideTable>*>(SideTableBuf);}你会发现这里已经在使用SideTableBuf了,说明SideTableBuf肯定提前被赋值了。而我们刚才说了SideTableInit()方法调用是C++的initializer调用之后,这也就是注释说的内容。白话文翻译一下:通过SideTableInit()来初始化SideTable是不对的,因为在SideTableInit()之前会先执行C++的initializer,而在那个时候就已经用到SideTable了,所以我们才用静态全局变量来初始化SideTable,文件被加载就会初始化。

你会在runtime源码中经常看到这样的代码,其实刚才说到:C++的initializer调用阶段也用到了。

SideTable &table = SideTables()[this];static StripedMap<SideTable>& SideTables() { return *reinterpret_cast<StripedMap<SideTable>*>(SideTableBuf);}

所以我们很有必要理解这句话什么意思。StripedMap是一个模版类,熟悉C++的应该非常熟悉这个,来看看StripedMap<SideTable>会生成怎样的一个类。

//简化版本,宏啥的都替换了class StripedMap { //存SideTable的结构体 struct PaddedT { SideTable value; }; PaddedT array[64]; //取得p的哈希值,p就是实例对象的地址 static unsigned int indexForPointer(const void *p) { uintptr_t addr = reinterpret_cast<uintptr_t>; return ((addr >> 4) ^ (addr >> 9)) % 64; }public: T& operator[] (const void *p) { return array[indexForPointer].value; } const T& operator[] (const void *p) const { return const_cast<StripedMap< SideTable >>[p]; } ...}

这样一来就很清晰了,StripedMap里面有一个PaddedT数组,StripedMap重载了[]符号,根据参数的哈希值取PaddedT数组的内容,数组里存的就是SideTable。现在来理解reinterpret_cast什么意思。

reinterpret_cast:转换一个指针为其它类型的指针等,我们没必要去深究,这样理解就够了。

所以

SideTable &table = SideTables()[this];static StripedMap<SideTable>& SideTables() { return *reinterpret_cast<StripedMap<SideTable>*>(SideTableBuf);}意思就是:将SideTableBuf转换为StripedMap<SideTable>*类型并返回,也就是把SideTableBuf当成StripedMap使用,这也是为什么要写alignas(StripedMap<SideTable>)的原因,这样SideTableBuf数组每一个元素都正好对应一个StripedMap<SideTable>对象。

这里要特别注意了,会有哈希冲突吗?

我们创建两个不同的类Person和Car,打印一下通过indexForPointer得到的哈希值。哈希值计算公式:((addr >> 4) ^ (addr >> 9)) % 64;addr就是实例对象的地址。这个公式岁随便写的吧,看不出啥端倪。Person *one = [[Person alloc] init];NSLog(@"%p",one);//0x60200000bf30 105690555268912indexForPointer(105690555268912) = 44;Car *two = [[Car alloc] init];NSLog(@"%p",two);//0x6030002c9710 105759277618960indexForPointer(105759277618960) = 58;计算出来的哈希值确实是不一样的,我们可以手动更改哈希算法把哈希值都设置为1,看看程序是否能正常运行。也就是更改这个方法。static unsigned int indexForPointer(const void *p) { return 1;}然后我们打印one和two的retainCount看是否正确。[one retain];NSLog(@"%d",[one retainCount]);//2 [two retain];NSLog(@"%d",[two retainCount]);//2看来都没问题,那么系统是怎么解决哈希冲突并成功的进行存取值的呢?我们下面讲。
1
2
3
spinlock_t slock;//保证原子操作的自选锁
RefcountMap refcnts;//保存引用计数的散列表
weak_table_t weak_table;//保存 weak 引用的全局散列表

可以看出值的第一个 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 优化了下,但其引用计数可能不准确。

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
}

修改引用计数

当然这里的哈希算法会根据是否为 64 位平台来进行优化,算法具体细节就不深究了,我总觉得苹果在这里的 hardcode 是随便写的:

看到这里知道为什么在存储引用计数时总是真正的引用计数值减一了吧。因为 release 本来是要将引用计数减一,所以存储引用计数时先预留了个“一”,在减一之前先看看存储的引用计数值是否为 0 (it->second < SIDE_TABLE_DEALLOCATING),如果是,那就将对象标记为“正在析构”(it->second |= SIDE_TABLE_DEALLOCATING),并发送 dealloc 消息,返回 YES;否则就将引用计数减一(it->second -= SIDE_TABLE_RC_ONE)。这样做避免了负数的产生。

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

这样的实现跟获取引用计数类似,先是看是否支持 TaggedPointer(毕竟数据存在栈指针而不是堆中,栈的管理本来就是自动的),否则去操作 SideTable 中的 refcnts 属性,这与获取引用计数策略类似。sidetable_retain() 将 引用计数加一后返回对象,sidetable_release() 返回是否要执行 dealloc 方法:

1
static SideTable *tableForPointer(const void *p)