iOS 内存深度解析:ARC 到底是怎么管理对象生命周期的

iOS 内存深度解析:ARC 到底是怎么管理对象生命周期的

很多人对 ARC 的理解停留在三句话:

"ARC 会自动 retain / release;引用计数归零就释放;有循环引用就内存泄漏。"

这三句没错,但只是用户层心智模型

真正要搞懂 iOS 内存,得往下看三层:

  1. 编译期:Clang 在什么地方插 retain / release
  2. 运行期:objc_retain / objc_release 怎么改引用计数
  3. 底层存储:isa、extra_rc、SideTable、weak table 如何协作

一、先纠偏:ARC 不是 GC,也不是"自动内存管理黑盒"

ARC(Automatic Reference Counting)本质是:

引用计数机制不变,retain / release 由编译器和 runtime 替你插。

它和 Java / Go 的垃圾回收(GC)完全不同:

维度 ARC GC
释放时机 引用计数归 0 立即释放 后台扫描,延迟回收
是否停顿 无 STW 可能有 stop-the-world
循环引用 不能自动解决 可达性分析可回收
成本位置 每次引用变更都有小成本 周期性大成本

所以 ARC 的核心优势是:确定性销毁

核心代价是:循环引用要人来处理


二、对象生命周期的全景图

一个 Objective-C 对象的一生大概是:

objectivec 复制代码
alloc/init
  ↓
被强引用持有(retain / storeStrong)
  ↓
被传递、被赋值、被返回
  ↓
强引用逐个消失(release / 变量出作用域)
  ↓
strong count = 0
  ↓
调用 dealloc
  ↓
清理 weak 引用、关联对象、C++ 析构
  ↓
内存归还给堆分配器(malloc / free)

ARC 做的事,就是在"被持有"和"不再被持有"的边界上,自动插入正确的引用计数操作


三、编译期:Clang 到底插了什么

你写:

ini 复制代码
{
    id obj = [NSObject new];
    id other = obj;
}

Clang 在 IR / 机器码层面大致会变成:

less 复制代码
id obj = objc_msgSend(NSObject.class, @selector(new));
// new 已经返回 +1 对象

objc_storeStrong(&other, obj);
// other = obj:先 retain 新值,再 release 旧值

objc_storeStrong(&obj, nil);   // 作用域结束
objc_storeStrong(&other, nil); // 作用域结束

关键运行时函数不是 [obj retain],而是:

  • objc_retain
  • objc_release
  • objc_storeStrong
  • objc_retainAutoreleasedReturnValue
  • objc_autoreleaseReturnValue
  • objc_initWeak / objc_destroyWeak

ARC 不是"发消息",而是"调 runtime C 函数"。

原因:更快,也更容易被优化器识别。

1. 不同修饰符,插不同的代码

修饰符 行为
__strong(默认) 赋值 retain,离开作用域 release
__weak 不 retain;对象死时自动置 nil
__unsafe_unretained 不 retain;对象死了变成野指针
__autoreleasing 隐式进 autorelease pool

例如:

ini 复制代码
__weak id w = obj;

编译器会生成:

scss 复制代码
objc_initWeak(&w, obj);
// 作用域结束:
objc_destroyWeak(&w);

四、运行期:retain / release 改的到底是什么

1. Tagged Pointer:根本不进堆

像小 NSNumber、短 NSString:

python 复制代码
@1
@"abc"

可能是 Tagged Pointer ------值直接编码在指针里,没有堆对象,没有引用计数,objc_retain 直接返回。

kotlin 复制代码
if (obj->isTaggedPointer()) return obj;

所以:

Tagged Pointer 不受 ARC 管理,也不是"对象生命周期"问题。


2. Non-pointer isa:引用计数藏在指针里

64 位时代,isa 不只是"指向 Class 的指针",而是位域:

arduino 复制代码
isa_t:
nonpointer         1
has_assoc          1
has_cxx_dtor       1
shiftcls          33   // 真正的类指针
magic              6
weakly_referenced  1
deallocating       1
has_sidetable_rc   1
extra_rc          19   // 引用计数 - 1

也就是说:

  • 普通对象:引用计数直接放在 isa 的 extra_rc 里
  • extra_rc 能存:2^19 - 1 = 524287 个强引用
  • 大多数 retain / release 只是一次原子 CAS 改 isa
arduino 复制代码
// retain 简化逻辑
if (isa.extra_rc < MAX) {
    isa.extra_rc += 1;   // 原子操作
} else {
    // 溢出,搬一半到 SideTable
}

这就是为什么 ARC 在 iOS 上够快:

99% 的 retain/release 不改全局表、不拿大锁。


3. SideTable:isa 装不下时的兜底仓库

当发生以下情况:

  • 引用计数超过 extra_rc 上限
  • 对象被 __weak 引用过
  • 有关联对象 / 需要弱引用表

runtime 会用 SideTable

全局有 64 张 SideTable,按对象地址哈希分桶,降低锁竞争:

arduino 复制代码
struct SideTable {
    spinlock_t slock;
    RefcountMap refcnts;     // 对象地址 -> 额外引用计数
    weak_table_t weak_table;  // 对象地址 -> 弱引用列表
};

所以"引用计数存在哪里"的标准答案是:

先存 isa.extra_rc,溢出或需要弱引用时,存 SideTable。


五、release 到 0 之后发生了什么

这是很多人没讲透的一步。

scss 复制代码
objc_release(obj)

流程大概是:

  1. extra_rc -= 1

  2. 如果还有余量 → 结束

  3. 如果 extra_rc 不够 → 从 SideTable 借/减

  4. 如果总强引用数 = 0:

    • 标记 deallocating = 1
    • 调用 dealloc
  5. dealloc 里做:

    • 调用 C++ 析构
    • 清理关联对象
    • 清理 weak 引用
    • 调用 object_dispose
  6. 内存归还分配器

1. weak 引用为什么不会野指针

对象 dealloc 时,runtime 会:

objectivec 复制代码
查 SideTable.weak_table
→ 找到所有指向该对象的 weak 指针
→ 全部置 nil
→ 再释放对象

所以:

ini 复制代码
__weak id w = obj;
// obj 释放后
w == nil; // 安全

而:

ini 复制代码
__unsafe_unretained id u = obj;
// obj 释放后
u; // 野指针,访问即 crash

六、autorelease:不是"晚一点释放",而是"交给池子托管"

1. MRC 时代

ini 复制代码
- (NSString *)name {
    NSString *s = [[NSString alloc] initWithFormat:@"%@"];
    return [s autorelease];
}

对象先进入当前 autorelease pool,runloop 一轮结束后才 release。

2. ARC 时代

你写:

objectivec 复制代码
- (NSString *)name {
    return [NSString stringWithFormat:@"hi"];
}

编译器生成:

kotlin 复制代码
return objc_autoreleaseReturnValue(retval);

调用方:

ini 复制代码
id x = objc_retainAutoreleasedReturnValue([obj name]);

这里有一个关键优化:

如果调用方马上 retain,就不进 autorelease pool,直接"移交所有权"。

这是 ARC 性能比"傻瓜式 autorelease"好的核心原因。

3. @autoreleasepool 什么时候真有用?

主线程 RunLoop 每一轮都有池子。

但下面场景要自己加:

ini 复制代码
for (int i = 0; i < 100000; i++) {
    @autoreleasepool {
        NSString *s = [NSString stringWithFormat:@"%d", i];
        // 临时对象本轮就释放
    }
}

否则几十万临时对象撑到 RunLoop 结束,峰值内存爆炸。


七、属性、变量、Block:生命周期最容易翻车的地方

1. 属性默认是 strong

objectivec 复制代码
@property (nonatomic) UIView *view;

等于:

objectivec 复制代码
__strong UIView *view;

2. Block 捕获 self 是强引用

ini 复制代码
self.block = ^{
    [self doSomething];
};

形成:

lua 复制代码
self → block → self

解决:

ini 复制代码
__weak typeof(self) weakSelf = self;
self.block = ^{
    __strong typeof(weakSelf) strongSelf = weakSelf;
    [strongSelf doSomething];
};

这里 strongSelf 的作用是:

在 block 执行期间"临时保活",避免执行到一半 self 没了。


八、Swift 的 ARC 和 ObjC 有什么区别

Swift 也是 ARC,但更彻底:

  • 没有 retain/release/autorelease
  • 没有 __weak 指针类型,只有 weak var
  • unowned
  • 编译器对值类型/引用类型划分更清晰

Swift 对象头大概:

csharp 复制代码
isa
strong ref count
unowned ref count
weak ref count / flags

但思想一样:

strong count = 0 → deinit → 清 weak → 释放


九、一张图总结 ARC 的生命周期决策

objectivec 复制代码
新对象创建
   ↓
有几个 __strong 指向它?
   ├─ >0 → 活着
   └─ =0 → dealloc
          ↓
       weak 引用置 nil
       关联对象清理
       C++ / Swift 资源清理
          ↓
       内存回收

ARC 的真相只有一句:

它不决定"对象该不该活",它只是严格记账:谁强引用你,你就多活一会儿;没人强引用你,你就立刻死。


十、面试/工程里最该记住的 10 句话

  1. ARC 不是 GC,是"编译期插 retain/release + runtime 管计数"。
  2. 引用计数先放 isa.extra_rc,不够才
相关推荐
焦玉全1 小时前
Redis Lua 脚本实战:从入门到精通(Java 版)
后端
知识的搬运工旺仔1 小时前
唯一索引与 NULL 值:PostgreSQL 主键约束与 NULLS NOT DISTINCT
数据库·后端·sql·postgresql
大勇前进1 小时前
从 OC 到 Swift,老 iOS 开发者踩过的 10 个语法大坑
后端
我的xiaodoujiao2 小时前
Django 基础知识详细图文教程 9-Django 模板引擎 2
开发语言·数据库·后端·django
烈风逍遥2 小时前
第五篇:通用 LLM 流式对话:前后端联接的完整实现
前端·后端·架构
Ticnix2 小时前
别再手动上线了:一条命令带备份、健康检查和自动回滚
后端·python·ci/cd
ArkPppp2 小时前
如何从零上线一个耐造的Redis缓存系统——最直接最不绕弯子的方式
后端·ai编程
Kyrie_kk2 小时前
Java--ProcessBuilder操作系统进程
java·后端
Ticnix2 小时前
迁移脚本能跑通,不代表你回滚得回来
后端·python