iOS 中的引用计数
iOS 中的引用计数
最近面试,遇到引用计数的问题
并且由引用计数引出来的关联问题,在这说明一下
1、引用计数是什么
通常情况下,某一块地址有多少个指针指向了它,那么这个多少就是引用计数的值。是iOS 使用自动引用计数来管理内存。
2、引用计数的存储
总的来说,引用计数的存储位置可以分为三种情况,其目标是在保证正确性的前提下,最大限度地优化性能和内存使用
引用计数(retain count)的存储位置取决于对象所处的状态,主要有以下三种方式:
**1. 存储在对象的 isa指针的额外比特位中(优化情况)
存储在 isa指针中(非指针型 isa/ Tagged Pointer)
这是苹果进行的最重要的优化之一,目的是为了在多数常见情况下,不产生额外的存储开销。
a) 非指针型 isa
在 64 位系统后,一个指针地址是 64 位(8字节),但实际寻址并不需要全部 64 位。苹果利用了这些多余的比特位来存储信息,包括引用计数。
原理:对象的 isa指针不再直接指向类对象的内存地址,而是一个包含了类对象地址和对象状态信息的位域。
存储内容:isa结构中的 extra_rc字段(例如 19 个比特位)用于存储额外的引用计数。
当一个对象的引用计数为 1 时(即刚创建时),extra_rc的值为 0。
当有新的强引用持有该对象时,retain操作会尝试先给 extra_rc加 1。
只要 extra_rc没有溢出(即引用计数不太大),所有的引用计数操作都直接在这个 isa指针内完成,速度极快,且没有额外的内存访问。
b) Tagged Pointer(特殊情况)
对于某些小对象(如短字符串 NSString、小数字 NSNumber等),苹果使用了 Tagged Pointer 技术。此时,对象的值直接存储在其指针值中,它根本不是堆上的一个真正对象。
特点:对于 Tagged Pointer,不存在引用计数的概念。retain和 release操作都是空操作,因为它的“内存管理”就是简单的指针赋值和销毁,效率极高。
2. 存储在对象的 Side Table中(溢出情况)
当存储在 isa中的引用计数不够用时,系统会使用 Side Table。
何时触发:当对象的引用计数持续增加,导致 isa中的 extra_rc字段被塞满(溢出)时。
工作原理:
一半一半策略:当 extra_rc快满时,retain操作会将 extra_rc的大约一半值转移到一个全局的 SideTables中。
SideTables是一个哈希表,根据对象的地址可以找到对应的 Side Table。
每个 Side Table中有一个 RefcountMap(引用计数表),它以对象地址为 key,存储其额外的引用计数。
此时,isa中的 extra_rc会保留剩余的一半计数值,并设置一个标志位 has_sidetable_rc为 1,表示此对象有部分引用计数存储在 Side Table中。
操作:后续的 retain/release操作会先尝试修改 isa.extra_rc,如果不够,再去操作 Side Table中的值。
为什么这样设计?
这是一种缓存思想。将最常用的、较小的引用计数放在访问速度最快的 isa指针中(相当于 L1 缓存),将不常用的、较大的计数部分放在访问稍慢的 Side Table中(相当于内存)。这保证了在绝大多数情况下(对象的引用数不多)性能最优。
3.两者结合使用(现代运行时的主流方式)
对于一个普通的 Objective-C 对象,其引用计数的存储是分级和混合的:
创建时:引用计数为 1,存储在 isa.extra_rc中(值为 0,表示实际计数是 extra_rc + 1)。
频繁引用时:retain操作优先增加 isa.extra_rc。
计数溢出时:将 isa.extra_rc的一部分转移到 Side Table中,isa只保留一部分。后续操作会同时检查两者。
释放时:release操作先减少 isa.extra_rc,如果它为 0 且 Side Table中有值,则再从 Side Table中借一些计数填回 isa.extra_rc。当所有计数归零时,对象被销毁。
3.为什么对象的isa.extra_rc 中会溢出,该怎么理解这种溢出
假设 isa.extra_rc字段的比特位数为 8 位。这意味着它能存储的最大无符号整数值是 2^8 - 1 = 255。
根据苹果的优化策略,当 extra_rc快满时(比如达到 255 的一半,即 127 左右),系统会进行“分半”处理,而不是等到完全溢出(255)才处理,以防止溢出错误。
场景:一个被大量强引用的单例对象或管理器对象
假设我们有一个 NetworkManager的单例对象,它在 App 启动时被创建。然后,很多个网络请求模块(比如 200 个 RequestHandler对象)都需要强引用这个管理器来发送请求。
第一步:对象创建
操作:NetworkManager *manager = [[NetworkManager alloc] init];
引用计数:1
存储方式:因为是初始状态,引用计数为 1。在优化实现中,isa.extra_rc的实际值被设为 0,因为真正的引用计数是 isa.extra_rc + 1。这样设计可以多存一个计数。
isa.extra_rc= 0
isa.has_sidetable_rc= 0 (false,表示未使用 Side Table)
第二步:前 127 次 retain(假设没有 release)
操作:200 个 RequestHandler对象开始创建,并强引用 manager。我们执行了 127 次 retain操作。
引用计数变化:从 1 增加到 1 + 127 = 128
存储方式:所有的 retain操作都只是简单地增加 isa.extra_rc的值。
isa.extra_rc= 127 (因为实际计数是 127 + 1 = 128)
isa.has_sidetable_rc= 0
此时状态:引用计数完全存储在 isa指针中,速度极快。
第三步:第 128 次 retain- 触发溢出处理
这是最关键的一步。当系统发现 extra_rc的值已经比较大(比如达到了阈值 127,即 255 的一半),为了给后续的 retain留出空间,它会主动进行“分半”处理,将一部分计数转移到 Side Table。
操作:第 128 个 RequestHandler强引用 manager,触发第 128 次 retain。
处理流程:
a. 准备转移:系统决定将 isa.extra_rc中的大约一半(比如 128 的一半,64)转移出去。
b. 操作 Side Table:
在全局的 SideTables中,根据 manager对象的内存地址找到对应的 Side Table和它的 RefcountMap。
在 RefcountMap中为 manager创建一个条目,并将其引用计数值设置为 64。
c. 更新 isa:
将 isa.extra_rc的值更新为 128 - 64 - 1 = 63。(解释:原来的 128 次计数,减去移出去的 64,再减去对象本身占用的 1,剩下 63 存在 extra_rc中)。
将 isa.has_sidetable_rc标志位设置为 1,告诉运行时:“这个对象的部分引用计数在 Side Table 里,以后操作要注意。”
最终存储状态(第 128 次 retain 后):
总引用计数 = 1 (对象本身) + isa.extra_rc(63) + Side Table(64) = 128。结果正确。
isa.extra_rc= 63
isa.has_sidetable_rc= 1
Side Table RefcountMap中 key(manager)对应的 value= 64
第四步:后续的 retain操作(第 129 次到第 200 次)
现在对象处于混合存储模式。
操作:继续创建 RequestHandler,执行第 129 次到第 200 次 retain(共 72 次)。
处理流程:每次 retain,系统会优先尝试增加 isa.extra_rc。
假设 isa.extra_rc从 63 开始增加,它最多能增加到 255。所以这 72 次 retain可以完全由 isa.extra_rc吸收。
最终存储状态(第 200 次 retain 后):
isa.extra_rc= 63 + 72 = 135
isa.has_sidetable_rc= 1
Side Table中的值保持不变,仍然是 64
总引用计数 = 1 + 135 + 64 = 200。结果正确。
相反的过程:release
当 RequestHandler们开始释放时:
前 135 次 release会先减少 isa.extra_rc,从 135 减到 0。这个过程很快。
当 isa.extra_rc减为 0,但 has_sidetable_rc为 1 时,系统知道 Side Table 里还有计数。
随后的 release操作会从 Side Table 中“借回”一部分计数到 isa.extra_rc中,然后再减少它。例如,系统可能会从 Side Table 的 64 中转移 50 到 isa.extra_rc,然后开始减少这 50。
如此循环,直到 Side Table 和 isa.extra_rc中的计数都归零,对象被正确销毁。
