Objective-C 引用计数原理
- 本文所使用的源码为 objc4-647 和 CF-1153.18
引用计数如何存储
-
如果有些对象支持使用 TaggedPointer:
苹果会直接将对象的指针值作为引用计数返回。 -
如果另外一些对象不支持使用TaggedPointer:
如果当前设备是 64 位环境并且使用 Objective-C 2.0,那么会使用对象的 isa 指针 的 一部分空间 (bits.extra_rc)来存储它的引用计数;
否则 Runtime 会使用一张 散列表 (SideTables())来管理引用计数。 -
其实还有一种情况会改变引用计数的存储策略,那就是是否使用垃圾回收(用UseGC属性判断),但这种早已弃用的东西就不要管了,而且初始化垃圾回收机制的 void gc_init(BOOL wantsGC) 方法一直被传入 NO。
TaggedPointer
判断当前对象是否在使用 TaggedPointer 是看标志位是否为 1 :
/* objc-config.h */
// Define SUPPORT_TAGGED_POINTERS=1 to enable tagged pointer objects
// Be sure to edit tagged pointer SPI in objc-internal.h as well.
#if !(__OBJC2__ && __LP64__)
# define SUPPORT_TAGGED_POINTERS 0
#else
# define SUPPORT_TAGGED_POINTERS 1
#endif
// Define SUPPORT_MSB_TAGGED_POINTERS to use the MSB
// as the tagged pointer marker instead of the LSB.
// Be sure to edit tagged pointer SPI in objc-internal.h as well.
// 不支持TaggedPointer,或者不是ios也就是iphone
//(是osx也就是mac平台),SUPPORT_MSB_TAGGED_POINTERS 为 0
#if !SUPPORT_TAGGED_POINTERS || !TARGET_OS_IPHONE
# define SUPPORT_MSB_TAGGED_POINTERS 0
#else
# define SUPPORT_MSB_TAGGED_POINTERS 1
#endif
#if TARGET_OS_OSX && __x86_64__
// 64-bit Mac - tag bit is LSB
// macOS下,始终用最小位作为标志位
# define TAG_MASK 1UL
#else
// Everything else - tag bit is MSB
// 剩下的只有ARM64了。ARM64只用到64位中的48位,高位全是0,此时mask就要反过来。
// 这种情况下,理论上能够支持的Tagged Pointer的类就多了很多。
# define TAG_MASK (1UL<<63)
#endif
inline bool
objc_object::isTaggedPointer()
{
#if SUPPORT_TAGGED_POINTERS
return ((uintptr_t)this & TAG_MASK);
#else
return false;
#endif
}
OC中的[NSObject alloc]最终调用的是C标准库中的malloc,它所返回的地址通是16的整数。
16在二进制下最后4位都是0,用最后一位是否为1来识别Tagged Pointer非常合理。
剩下的3位,可以用于区分不同类型的Tagged Pointer,比如NSTaggedPointerNumber、NSTaggedPointerDate等等。在objc4-723中,有
#if __has_feature(objc_fixed_enum) || __cplusplus >= 201103L
enum objc_tag_index_t : uint16_t
#else
typedef uint16_t objc_tag_index_t;
enum
#endif
{
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_RESERVED_7 = 7,
OBJC_TAG_First60BitPayload = 0,
OBJC_TAG_Last60BitPayload = 6,
OBJC_TAG_First52BitPayload = 8,
OBJC_TAG_Last52BitPayload = 263,
OBJC_TAG_RESERVED_264 = 264
};
源码中可以看到:
0 ~ 7 分别表示类型。而另外 OBJC_TAG_First60BitPayload 等定义分别表示前 60 位或者 52 位为负载内容,还是后 60 位或者后 52 位为负载内容
创建Command LineTool应用
Command LineTool应用 Command LineTool应用运行结果
创建iOS应用
ios应用 ios应用运行结果
上面两个结果可以看出
在iOS真机上判断是否TaggedPointer可以直接看指针的最高一个比特位是否为1。(因为十六进制a转换为二进制为1010,前面的1表示使用TaggedPointer,而后面的010就是2,表示NSString类型)
mac应用判断是否TaggedPointer可以直接看指针的最低一个比特位是否为1。(因为十六进制5转换成二进制为0101,最后一位表示使用TaggedPointer,而前面的010就是2,表示NSString类型)
这里只列出了NSString采用这种技术的结果。
对于以前的@""符号创建的字符串还是常量字符串,这个没有改变,但是采用stringWithFormat 等NSString方法的创建字符串对象则有了区别。
在苹果的64位OC实现中,若对象指针的二进制第一位是1,则该指针为Tagged Pointer。
例如0xa000000000000311其中a的2进制为1010,第一位1表示这是Tagged Pointer,010表示这是一个NSTaggedPointerString类;这个地址最后一位表示字符串的数目,这里是0001表示有1位字符串;其中真正用来存储的位数只有中间的14位16进制。这个地址本身其实就存储了字符串的值,可以说是存储在&strS内存中值,只是伪装成了地址,它不需要存储在数据区,也不需要申请堆空间。
NSTaggedPointerString的存储有三种编码方式:ASCII码,六位编码,五位编码。
ASCII码
我们发现NSTaggedPointerString存储内容除去第一位和最后一位,其实只有中间的14位16进制字符,再看ascll码由8位二进制组成,所以这里(144) / 8=7,用8位的ascll码的话最多可以存储7个字符。字符串数目0~7之间
[NSString stringWithFormat:@"1"]输出的地址 0xa000000000000311,其中31的2进制是0011 0001,在ascll码表里查找发现正是对应着“1”;
[NSString stringWithUTF8String:"abcdabc"] 这里输出的地址是0xa636261646362617 可以发现在使用ascll编码时,字符串对应的编码是从右向左存储的
六位编码:
NSTaggedPointerString 采用六位二进制编码,(144)/6=9.333…,可以看出最多存储9位字符。字符数目8~9
五位编码:
采用五位二进制编码,(14*4)/5 = 11.2,可以看出这种编码最多存储11位字符。字符数目在10~11
NSTaggedPointerString 存储编码中的六位和五位编码都是根据通常代码中字母使用频率来排序的,但并不是一成不变的,apple会持续更新并统计字母使用频率,系统每次升级都可能不一样,当前第一位是字母e,之后是i,l,o,t…;这两种编码是从左向右的;根据编码位数我们显然也能推测出并不是所有字符都可以进行ascll或者六位五位编码的,当出现这样不能编码的时候,系统也就不会使用NSTaggedPointerString类。
isa 指针(NONPOINTER_ISA)
用 64 bit 存储一个内存地址显然是种浪费,毕竟很少有那么大内存的设备。于是可以优化存储方案,用一部分额外空间存储其他内容。isa 指针第一位为 1 即表示使用优化的 isa 指针,这里列出不同架构下的 64 位环境中 isa 指针结构:
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
// 变量意义来源于: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)
};
# 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
};
优化后的isa值针
SUPPORT_NONPOINTER_ISA 用于标记是否支持优化的 isa 指针,其字面含义意思是 isa 的内容不再是类的指针了,而是包含了更多信息,比如引用计数,析构状态,被其他 weak 变量引用情况。判断方法也是根据设备类型:(SUPPORT_NONPOINTER_ISA 定义)
// 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 的类的属性中,后面会详细讲。
canAllocNonpointer and SUPPORT_NONPOINTER_ISA
两个都带nonpointer,
SUPPORT_NONPOINTER_ISA是来标识当前平台是否支持优化的isa,但即使平台支持,具体到某一个类却是不一定的,要具体的去验证类的元类的信息.不过可以放心大多系统的类的isa都是支持优化的,我们自定义的类的isa也是支持优化的.
canAllocNonpointer则是具体标记某个类是否支持优化的isa.
在阅读源码时还有会各种带nonpointer字样的针对优化isa的标记,除SUPPORT_NONPOINTER_ISA外,全是针对某个类而言的.
散列表
散列表来存储引用计数具体是用 DenseMap 类来实现,这个类中包含好多映射实例到其引用计数的键值对,并支持用 DenseMapIterator 迭代器快速查找遍历这些键值对。接着说键值对的格式:键的类型为 DisguisedPtr<objc_object>,DisguisedPtr 类是对 objc_object * 指针及其一些操作进行的封装,目的就是为了让它给人看起来不会有内存泄露的样子(真是心机裱),其内容可以理解为对象的内存地址;值的类型为 __darwin_size_t,在 darwin 内核一般等同于 unsigned long。其实这里保存的值也是等于引用计数减一。使用散列表保存引用计数的设计很好,即使出现故障导致对象的内存块损坏,只要引用计数表没有被破坏,依然可以顺藤摸瓜找到内存块的位置。
之前说引用计数表是个散列表,这里简要说下散列的方法。有个专门处理键的 DenseMapInfo 结构体,它针对 DisguisedPtr 做了些优化匹配键值速度的方法:
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 是随便写的:
#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() 方法:
- (NSUInteger)retainCount {
return ((id)self)->rootRetainCount();
}
在 ARC 时代除了使用 Core Foundation 库的 CFGetRetainCount() 方法,也可以使用 Runtime 的 _objc_rootRetainCount(id obj) 方法来获取引用计数,此时需要引入头文件。这个函数也是调用 objc_object 的 rootRetainCount() 方法:
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() 方法:
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:
typedef objc::DenseMap RefcountMap;
然后在引用计数表中用迭代器查找当前实例对应的键值对,获取引用计数值,并在此基础上 +1 并将结果返回。这也就是为什么之前说引用计数表存储的值为实际引用计数减一。
需要注意的是为什么这里把键值对的值做了向右移位操作(it->second >> SIDE_TABLE_RC_SHIFT):
#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 的下面两个方法:
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 方法:
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 告诉编译器此处需要如何修改引用计数:
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 这样的内存管理工具所替代:
// 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:
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,那么后续的调用顺序依次是为:
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 之类。。。