引用计数的存储策略
- 有些对象如果支持使用
Tagged Pointer
,苹果会直接将其指针值作为引用计数返回; - 如果当前设备是
64
位环境并且使用Objective-C 2.0
,那么“一些”对象会使用其isa
指针的一部分空间来存储它的引用计数; - 否则
Runtime
会使用一张散列表来管理引用计数。
Tagged Pointer
从64bit开始,iOS引入了Tagged Pointer
技术,用来优化NSNumber
、NSDate
、NSString
等小对象的内存。
我们知道在没有使用Tagged Pointer
之前,NSNumber
、NSDate
、NSString
等对象需要动态分配内存,维护引用计数等。对象的指针存的是一个指向堆空间的地址值。以NSNumber
为例,我们在创建NSObject对象的时候,至少需要16个字节的内存大小(这个与OC内存对齐所使用的系数有关),另外还要用其他的内存空间来存NSNumber
所对应的值,看上去这无疑在增加内存的开销。
在使用了Tagged Pointer
之后,它的指针不再是地址了,而是真正的值,准确的说是Tag+Data。所以,实际上它不再是一个对象了,它只是一个披着对象皮的普通变量而已。所以,它的内存并不存储在堆中,也不需要malloc
和free
。
在内存的读取上使用了Tagged Pointer
技术的对象也会比之前快很多。
下面的代码用来反映在64位系统下Tagged Pointer
的应用:
1 | int main(int argc, char * argv[]) { |
上面的打印结果中,除了numberLarger
这个变量之外,其他变量都使用Tagged Pointer
技术。那如何知道对象是否是Tagged Pointer
的对象呢?接着看一下源码中的定义:
1 | inline bool objc_object::isTaggedPointer() { |
_OBJC_TAG_MASK
是一个依赖于环境的宏,其相关定义如下:
1 | #if (TARGET_OS_OSX || TARGET_OS_IOSMAC) && __x86_64__ |
如果是mac OS 64bit的环境下,_OBJC_TAG_MASK
是1UL
,如果是iOS 64bit的环境下,_OBJC_TAG_MASK
是1UL<<63
(1UL
就是1)。
所以判断是否Tagged Pointer
的对象需要分环境考虑。如果是mac 64bit的环境下,则查看其指针最低有效位是否为1,如果是iOS 64bit的环境下,则使用1UL<<63
,即1往左偏移63即0x1000000000000000
,即查看最高有效位是否为1。
上述实验是在iphone手机下运行的结果。我们将上面地址(16进制)的第一位转成二进制,分别是1001
、1001
、1001
、1001
、0110
,做$
运算后,除了numberLarger
对应的变量以外,其他的都是0x1000000000000000
。另外我们在创建对象的时候,只要是从堆空间里创建的对象,它的最低有效位一定是0
。这还是因为OC对象的内存对齐使用的系数为16,所以内存地址一定是16的倍数。但是使用了Tagged Pointer
技术的对象,它的最低有效位就不一定是0
了。从这里也证明了使用Tagged Pointer
的对象的内存并不存储在堆中,也不需要malloc
和free
。在后续的源码中,也会看到但凡是Tagged Pointer
的对象,很多都会直接return
。
到这里我们得出几个结论:
- 从64bit开始,iOS对NSNumber、NSDate、NSString等小对象会优先使用Tagged Pointer技术进行存储,即指针存储。当指针存储不下内容的时候,才会使用堆空间即动态分配内存;
- 在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 | struct objc_object { |
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_ONE和RC_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),当超过它就需要SideTables
。SideTables
内包含一个RefcountMap
,用来保存引用计数,根据对象地址取出其引用计数,类型是size_t
。
这里有个问题,为什么既要使用一个extra_rc
又要使用SideTables
?
可能是因为历史问题,以前cpu是32
位的,isa
中能存储的引用计数就只有$2^7=128$。因此在arm64
下,引用计数通常是存储在isa
中的。
更具体的会在retain操作的时候讲到。
isa_t联合体里面的宏
SUPPORT_PACKED_ISA
表示平台是否支持在isa
指针中插入除Class
之外的信息。
- 如果支持就会将
Class
信息放入isa_t
定义的struct内,并附上一些其他信息,例如上面的nonpointer
等等; - 如果不支持,那么不会使用
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 | #if SUPPORT_INDEXED_ISA |
iOS设备上SUPPRT_INDEXED_ISA是0。
isa类型有关的宏
SUPPORT_NONPOINTER_ISA
用于标记是否支持优化的isa
指针,其定义:
1 | #if !SUPPORT_INDEXED_ISA && !SUPPORT_PACKED_ISA |
那如何判断是否支持优化的isa指针?
- 已知iOS系统的
SUPPORT_PACKED_ISA
为1,SUPPORT_INDEXED_ISA
为0,从上面的定义可以看出,iOS系统的SUPPORT_NONPOINTER_ISA
为1; - 在环境变量中设置
OBJC_DISABLE_NONPOINTER_ISA
。
这里需要注意的是,即使是64位环境下,优化的isa
指针并不是就一定会存储引用计数,毕竟用19bit iOS 系统)保存引用计数不一定够。另外这19位保存的是引用计数的值减一。
SideTable
在源码中我们经常会看到SideTable
这个结构体。它的定义:
1 | struct SideTable { |
从上面可知,SideTable
中有三个成员变量:
slock
用于保证原子操作的自旋锁;refcnts
用于引用计数的hash
表;weak_table
用于weak引用的hash
表。
这里我们主要看引用计数的哈希表。RefcountMap
的定义:typedef objc::DenseMap<DisguisedPtr<objc_object>,size_t,true> RefcountMap;
可以看出SideTable
用来保存引用计数具体是用DenseMap
这个类(在llvm-DenseMap.h
中)实现的。DenseMap
以DisguisedPtr<objc_object>
为key
,size_t
为value
,DisguisedPtr
类是对objc_object *
指针及其一些操作进行的封装,其内容可以理解为对象的内存地址,值的类型为__darwin_size_t
,在 darwin 内核一般等同于 unsigned long
。其实这里保存的值也是等于引用计数减1。
引用计数的获取
通过retainCount
可以获取到引用计数器,其定义:
1 | - (NSUInteger)retainCount { |
从上面的代码可知,获取引用计数的时候分为三种情况:
Tagged Pointer
的话,直接返回isa本身;- 非
Tagged Pointer
,且开启了指针优化,此时引用计数先从extra_rc
中去取(这里将取出来的值进行了+1操作,所以在存的时候需要进行-1操作),接着判断是否有SideTable
,如果有再加上存在SideTable
中的计数; - 非
Tagged Pointer
,没有开启了指针优化,使用sidetable_retainCount()
函数返回。
手动操作对引用计数的影响
objc_retain()
1 | #if __OBJC2__ |
首先判断是否是Tagged Pointer
的对象,是就返回对象本身,否则通过对象的retain()
返回。
1 | inline id objc_object::retain() { |
首先判断是否是Tagged Pointer
,这个函数并不希望处理的对象是Tagged Pointer
;接着通过hasCustomRR
函数检查类(包括其父类)中是否含有默认的方法,有则调用自定义的方法;如果没有,调用rootRetain()
函数。
1 | ALWAYS_INLINE id objc_object::rootRetain() { |
从上面的可以看到:
Tagged Pointer
直接返回对象本身;newisa.nonpointer == 0
没有开启指针优化,直接使用SideTable
来存储引用计数;- 开启指针优化,使用isa的
extra_rc
保存引用计数,当超出的时候,使用SideTable
来存储额外的引用计数。
objc_release()
1 | #if __OBJC2__ |
这边的逻辑和objc_retain()
的逻辑一致,所以直接看rootRelease()
函数,与上面一样,下面的代码也是经过精简的。
1 | ALWAYS_INLINE bool |
从上面可以看到:
- 判断是否是
Tagged Pointer
的对象,是就直接返回; - 没有开启指针优化,使用
SideTable
存储的引用计数-1; - 开启指针优化,使用isa的
extra_rc
保存的引用计数-1,当carry==0
表示需要从SideTable
保存的引用计数也用完了或者说引用计数为0,所以执行最后一步; - 最后调用
dealloc
,所以这也回答了之前的《OC内存管理–对象的生成与销毁》中dealloc
什么时候被调用这个问题,在rootRelease(bool performDealloc, bool handleUnderflow)
函数中如果判断出引用计数为0了,就要调用dealloc
函数了。
总结
引用计数存在什么地方?
Tagged Pointer
不需要引用计数,苹果会直接将对象的指针值作为引用计数返回;- 开启了指针优化(
nonpointer == 1
)的对象其引用计数优先存在isa
的extra_rc
中,大于524288
便存在SideTable
的RefcountMap
或者说是DenseMap
中; - 没有开启指针优化的对象直接存在
SideTable
的RefcountMap
或者说是DenseMap
中。
retain/release的实质
Tagged Pointer
不参与retain
/release
;- 找到引用计数存储区域,然后+1/-1,并根据是否开启指针优化,处理进位/借位的情况;
- 当引用计数减为0时,调用
dealloc
函数。
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指针还是类指针。
- 首先要知道,isa指针已经不一定是类指针了,所以需要用
对象的值是什么
- 如果是
Tagged Pointer
,对象的值就是指针; - 如果非
Tagged Pointer
, 对象的值是指针指向的内存区域中的值。
- 如果是
补充: 一道多线程安全的题目
以下代码运行结果
1 | @property (nonatomic, strong) NSString *target; |
答案:大概率地发生Crash。
Crash的原因:过度释放。
这道题看着虽然是多线程范围的,但是解题的最重要思路确是在引用计数上,更准确的来说是看对强引用的理解程度。关键知识点如下:
- 全局队列和自定义并行队列在异步执行的时候会根据任务系统决定开辟线程个数;
target
使用strong
进行了修饰,Block是会截获对象的修饰符的;- 即使使用
_target
效果也是一样,因为默认使用strong
修饰符隐式修饰; strong
的源代码如下:
1 | objc_storeStrong(id *location, id obj) { |
假设这个并发队列创建了两个线程A和B,由于是异步的,可以同时执行。因此会出现这么一个场景,在线程A中,代码执行到了objc_retain(obj)
,但是在线程B中可能执行到了objc_release(prev)
,此时prev
已经被释放了。那么当A在执行到objc_release(prev)
就会过度释放,从而导致程序crash。
解决方法:
- 加个互斥锁
- 使用串行队列,使用串行队列的话,其实内部是靠
DISPATCH_OBJ_BARRIER_BIT
设置阻塞标志位 - 使用weak
- Tagged Pointer,如果说上面的
self.target
指向的是一个Tagged Pointer
技术的NSString
,那程序就没有问题。