13-4.【OC】【性能】ARC 插入 retain / release 会带来什么开销?

3 阅读3分钟

ARC(自动引用计数)虽然在代码层面免去了手动管理内存的痛苦,但在底层二进制层面,它并非免费。ARC 的开销主要体现在 CPU 指令执行、原子操作锁竞争、以及编译器优化抑制这三个方面。

以下是 ARC 插入 retain / release 带来的具体开销拆解:


1. 指令跳转与函数调用开销

在 ARC 下,简单的赋值操作 objectA = objectB 不再是单一的内存拷贝,而是会被编译器展开为一系列函数调用:

  • 指令增加:编译器会插入 objc_retainobjc_release。这意味着 CPU 必须执行跳转指令进入运行时库,开辟新的栈帧,执行逻辑后再返回。
  • 寄存器压力:为了调用这些函数,CPU 需要频繁保存和恢复寄存器(如 x0-x7),这增加了 CPU 的调度负担。

2. 原子操作(Atomic Operations)的成本

这是 ARC 最核心的性能消耗点。

  • 引用计数存储:对象的引用计数通常存储在 isa 指针的位域中或外部的 SideTable 哈希表中。
  • 硬件锁:为了保证多线程安全,retainrelease 必须是原子性的。底层使用汇编指令(如 ARM 架构下的 ldxr / stxr 独占访问对)来确保计数加减的同步。
  • 缓存一致性开销:当一个对象在多个 CPU 核心间传递时,频繁的原子操作会强制触发 L1/L2 缓存同步,导致 CPU 流水线停顿。

3. 编译器优化的抑制

ARC 的存在限制了编译器的某些优化手段:

  • 无法内联:由于 objc_retain/release 是在外部动态库(libobjc)中定义的,编译器通常无法将这些内存管理逻辑内联(Inline)到你的函数内部。
  • 代码膨胀:在复杂的逻辑分支中,编译器为了保证每一条路径都能正确释放内存,会插入大量的清理代码(Clean-up code),这增加了二进制文件的体积(Code Size),进而影响指令缓存(I-Cache)的命中率。

4. 特殊场景:Autoreleasepool 的开销

当 ARC 无法确定对象的生命周期时(例如 OC 方法返回一个对象),它会退而求其次使用 autorelease

  • 入队成本:对象被放入 AutoreleasePoolPage 中,涉及指针移动和分页管理。
  • 延迟释放:对象直到 Runloop 结束才释放,这导致内存占用峰值升高,增加了内存系统的压力。

5. ARC 的进化:运行时优化 (Runtime Optimization)

苹果为了减小这些开销,引入了一些黑科技:

  • ObjC 优化返回路径:利用 objc_retainAutoreleasedReturnValue。如果调用方立即执行 retain,运行时会通过检查调用栈的返回地址,发现这并直接将对象传递,跳过整个 autorelease 流程。
  • Tagged Pointers:对于小型的 NSNumberNSDate,引用计数直接不存在,对象值直接存在指针里,完全消除了 ARC 开销。

6. 性能对比:手动 vs 自动

维度手动管理 (MRC)自动管理 (ARC)
开发效率低(易出错、内存泄漏)
运行时开销极低(可精确控制,减少不必要的 retain)中等(存在冗余的 retain/release 匹配)
指令效率高(编译器可更好地预测逻辑)略低(被插入的调用打断)

💡 优化建议:如何减少 ARC 开销?

  1. 使用 __unsafe_unretained:在确定生命周期安全(且对性能要求极高)的局部变量中,可以使用它跳过引用计数。
  2. 避免在紧密循环中产生临时对象:通过复用对象或使用结构体(Struct)来规避。
  3. 使用 static inline 函数:减少方法调用层次,间接减少 ARC 的插入点。