# iOS weak 原理详解

一、weak 是什么

weak 是 Objective-C / Swift 中的一种弱引用修饰符。它的核心行为只有两条:

  1. 不增加引用计数:持有对象但不影响对象的生命周期
  2. 对象释放时自动置 nil:不会产生野指针

这个"自动置 nil"是 weak 最关键的特性,也是它和 __unsafe_unretained(不置 nil,会变野指针)的根本区别。


二、为什么需要 weak

循环引用问题

两个对象互相强引用,谁都释放不了:

css 复制代码
A 强引用 B(B 的引用计数 +1)
B 强引用 A(A 的引用计数 +1)

想释放 A → 但 B 还在引用 A → A 释放不了
想释放 B → 但 A 还在引用 B → B 释放不了
→ 内存泄漏

把其中一方改成 weak 就打破了循环:

css 复制代码
A 强引用 B(B 的引用计数 +1)
B 弱引用 A(A 的引用计数不变)

外部释放 A → A 的引用计数归零 → A 被销毁 → A 释放对 B 的强引用 → B 也被销毁

常见场景:delegate、block 捕获 self、父子视图关系。


三、weak 的底层实现:SideTable

这是 weak 原理的核心。Runtime 维护了一套 SideTable 数据结构来管理 weak 引用。

3.1 整体架构

diff 复制代码
全局有一个 SideTable 数组(StripedMap<SideTable>)
包含 64 个 SideTable(根据对象地址哈希分配到不同的表,减少锁竞争)

每个 SideTable 包含三样东西:
+---------------------------+
|  spinlock_t 自旋锁         |   用于多线程安全
+---------------------------+
|  RefcountMap 引用计数表     |   存储对象的引用计数(非 isa 优化时)
+---------------------------+
|  weak_table_t 弱引用表     |   存储所有 weak 指针的信息
+---------------------------+

3.2 weak_table_t 的结构

arduino 复制代码
weak_table_t
+-----------------------------------+
|  weak_entry_t *weak_entries       |   哈希数组
|  size_t num_entries               |   当前条目数
|  uintptr_t mask                   |   哈希掩码(数组大小 - 1)
|  uintptr_t max_hash_displacement  |   最大哈希冲突偏移
+-----------------------------------+

每个 weak_entry_t 对应一个被弱引用的对象:
weak_entry_t
+-----------------------------------+
|  referent(被弱引用的对象地址)      |   Key:对象是谁
|  referrers(弱引用指针的地址数组)   |   Value:谁在弱引用它
+-----------------------------------+

用人话说就是:Runtime 维护了一张大表,Key 是对象地址,Value 是所有指向这个对象的 weak 指针的地址列表

类比理解:

想象一个"粉丝登记簿"。每个明星(对象)有一页,上面记着所有粉丝(weak 指针)的联系方式。明星退役(对象释放)时,工作人员翻到那一页,逐个通知粉丝"他退了"(置 nil),然后撕掉这一页。


四、weak 的完整生命周期

4.1 创建 weak 引用时发生了什么

当你写 __weak id weakObj = obj; 时,Runtime 调用 objc_initWeak,完整流程:

yaml 复制代码
objc_initWeak(&weakObj, obj)
    |
    v
storeWeak(&weakObj, obj)
    |
    v
1. 根据 obj 的地址,哈希计算找到对应的 SideTable
    |
    v
2. 加锁(SideTable 的 spinlock)
    |
    v
3. 在 weak_table 中查找 obj 对应的 weak_entry_t
   - 如果不存在:创建一个新的 weak_entry_t,插入 weak_table
   - 如果已存在:直接使用
    |
    v
4. 将 &weakObj(weak 指针的地址)添加到 weak_entry_t 的 referrers 数组中
    |
    v
5. 解锁
    |
    v
6. 返回 obj(weakObj 现在指向 obj,但不增加引用计数)

4.2 读取 weak 引用时发生了什么

当你使用 weakObj 时(比如 [weakObj doSomething]),Runtime 调用 objc_loadWeakRetained

yaml 复制代码
objc_loadWeakRetained(&weakObj)
    |
    v
1. 读取 weakObj 当前指向的对象
    |
    v
2. 如果对象正在被释放(deallocating)→ 返回 nil
    |
    v
3. 如果对象还活着 → 对它做一次 retain(引用计数 +1)
    |
    v
4. 返回对象(调用方用完后会 release)

为什么读取时要 retain? 防止你拿到对象后、使用之前的瞬间,对象被其他线程释放。retain 一下确保对象在使用期间不会消失。

这也是为什么常见的模式是:

objc 复制代码
__weak typeof(self) weakSelf = self;
[obj doSomething:^{
    __strong typeof(weakSelf) strongSelf = weakSelf;  // 读取时 retain
    if (!strongSelf) return;                           // 如果已释放就退出
    [strongSelf doWork];                               // 安全使用
}];

__strong typeof(weakSelf) strongSelf = weakSelf 这一步就触发了 retain,保证 block 执行期间 self 不会消失。

4.3 对象释放时发生了什么(最关键的部分)

当一个被弱引用的对象引用计数归零时,dealloc 过程中会清理所有 weak 引用。

完整流程:

yaml 复制代码
对象引用计数归零
    |
    v
objc_object::rootDealloc()
    |
    v
object_dispose()
    |
    v
objc_destructInstance(obj)
    |
    v
clearDeallocating(obj)
    |
    v
clearDeallocating_slow(obj)
    |
    v
1. 根据 obj 地址找到对应的 SideTable
    |
    v
2. 加锁
    |
    v
3. 在 weak_table 中查找 obj 对应的 weak_entry_t
    |
    v
4. 遍历 weak_entry_t 的 referrers 数组
   对每个 weak 指针地址:*referrer = nil  (置 nil!)
    |
    v
5. 从 weak_table 中删除这个 weak_entry_t
    |
    v
6. 解锁
    |
    v
7. 释放对象内存(free)

第 4 步就是 weak 自动置 nil 的核心:Runtime 遍历所有指向这个对象的 weak 指针,把它们全部设为 nil。

4.4 weak 引用被覆盖或销毁时

当 weak 指针指向新对象或超出作用域时,Runtime 调用 objc_destroyWeak

yaml 复制代码
objc_destroyWeak(&weakObj)
    |
    v
storeWeak(&weakObj, nil)
    |
    v
1. 找到旧对象的 SideTable
    |
    v
2. 从旧对象的 weak_entry_t 的 referrers 中移除 &weakObj
    |
    v
3. 如果 referrers 为空了,删除这个 weak_entry_t

五、SideTable 的哈希设计

5.1 为什么用 64 个 SideTable

如果只有一个全局的 SideTable,所有线程操作 weak 引用时都要抢同一把锁,性能极差。

64 个 SideTable 通过对象地址哈希分散到不同的表上,不同表用不同的锁,大幅减少了锁竞争。

css 复制代码
对象 A(地址 0x1000)→ 哈希 → SideTable[3]  → 锁3
对象 B(地址 0x2000)→ 哈希 → SideTable[17] → 锁17
对象 C(地址 0x3000)→ 哈希 → SideTable[3]  → 锁3(和A竞争,但概率低)

5.2 weak_entry_t 内部的优化

weak_entry_t 内部存储 referrers(弱引用指针数组)有两种模式:

  • 内联模式(inline):当弱引用数量不超过 4 个时,直接用一个固定大小的数组存储(WEAK_INLINE_COUNT = 4),避免堆内存分配
  • 动态模式(outline):超过 4 个时,切换为动态分配的哈希数组

大多数对象的 weak 引用数量不超过 4 个(通常就一两个 delegate),所以内联模式覆盖了大部分场景,性能更好。


六、weak 和 isa 的关系

6.1 isa 中的 weakly_referenced 位

现代 Objective-C 使用"优化的 isa"(Non-pointer isa),把引用计数和一些标志位直接存在 isa 指针里:

scss 复制代码
isa 指针(64位):
| 1bit  | 1bit        | 1bit            | 33bit    | ...   |
| nonptr| has_assoc   | has_cxx_dtor    | shiftcls | ...   |
|       |             |                 |          |       |
|       |             |                 |          | weakly_referenced (1bit)

weakly_referenced 位标记这个对象是否有 weak 引用。dealloc 时,如果这个位是 0,就跳过 weak 清理流程,加速释放。

scss 复制代码
dealloc 快速路径:
  if (!nonpointer) → 慢路径
  if (weakly_referenced) → 需要清理 weak 表 → 慢路径
  if (has_assoc) → 需要清理关联对象 → 慢路径
  if (has_cxx_dtor) → 需要调用 C++ 析构 → 慢路径
  否则 → 直接 free,最快

所以一个没有 weak 引用、没有关联对象、没有 C++ 析构的纯 OC 对象,释放速度是最快的。


七、weak 的性能开销

weak 不是"免费的",它有实实在在的性能开销:

操作 开销
创建 weak 引用 哈希查找 SideTable + 加锁 + 插入 weak_entry
读取 weak 引用 读取 + retain + autorelease(或 release)
对象释放时 哈希查找 + 加锁 + 遍历所有 weak 指针置 nil + 删除条目

对比 __unsafe_unretained:创建和读取都只是普通的指针赋值和读取,几乎零开销。代价是对象释放后变成野指针。

实际影响:在绝大多数场景下,weak 的开销完全可以忽略。但在极端高频的场景下(比如每秒创建销毁上万个弱引用对象),可以考虑用 __unsafe_unretained 配合手动管理来优化。


八、weak 和 autoreleasepool 的关系

在 MRC 和早期 ARC 实现中,读取 weak 变量会自动将对象注册到 autoreleasepool:

ini 复制代码
id obj = objc_loadWeak(&weakObj);
// 等价于:
id obj = objc_loadWeakRetained(&weakObj);
objc_autorelease(obj);

这意味着在一个循环里频繁读取 weak 变量,会导致 autoreleasepool 膨胀:

objc 复制代码
for (int i = 0; i < 100000; i++) {
    NSLog(@"%@", weakObj);  // 每次读取都往 pool 里加一个
}
// pool 里积累了 10 万个对象,直到 pool drain 才释放

解决方案:在循环外用 strong 变量接住,循环里用 strong 变量。

现代 ARC(编译器优化后)在很多场景下已经不走 autorelease 了,但理解这个机制仍然重要。


九、Tagged Pointer 和 weak

Tagged Pointer 是苹果对小对象(短 NSString、小 NSNumber 等)的优化:把值直接编码在指针里,不是真正的堆对象。

对 Tagged Pointer 做 weak 引用时:

  • 因为它不是真正的对象,没有引用计数,不会被"释放"
  • Runtime 检测到是 Tagged Pointer 后,不会走 SideTable 的注册/清理流程
  • weak 指针直接存储 Tagged Pointer 的值,永远不会被置 nil

十、Swift 的 weak 和 Objective-C 的区别

维度 Objective-C weak Swift weak
类型 可以是任意 OC 对象 必须是 Optional 类型
类限制 只能用于 class 类型(AnyObject)
底层机制 SideTable Swift 有自己的实现,但原理类似
unowned 没有直接等价物 有 unowned(类似 unsafe_unretained,但 debug 模式有检查)

Swift 的 unowned vs weak

  • weak:可选类型,对象释放后变 nil,有 SideTable 开销
  • unowned:非可选类型,假设对象一定还活着。释放后访问会在 debug 模式 crash(比野指针更安全)。性能比 weak 好(不走 SideTable)

十一、常见面试问题

Q1:weak 是怎么实现自动置 nil 的?

Runtime 维护了一个全局的 SideTable 结构,其中的 weak_table 以对象地址为 Key,以所有指向该对象的 weak 指针地址数组为 Value。对象 dealloc 时,Runtime 从表中找到所有 weak 指针,逐个置 nil,然后删除表项。

Q2:weak 和 assign 有什么区别?

assign 只是简单的指针赋值,对象释放后指针变成野指针(指向已释放的内存)。weak 会在对象释放时自动置 nil,安全。assign 用于基本类型(int、CGFloat 等),weak 用于对象类型。

Q3:为什么 weak 比 strong 慢?

strong 只是引用计数的原子操作(+1/-1)。weak 需要额外的哈希查找、加锁、SideTable 操作。读取时还需要 retain 保证线程安全。但在绝大多数场景下差异可忽略。

Q4:一个对象可以有多少个 weak 引用?

理论上没有限制。weak_entry_t 内部先用 4 个内联槽位,超过后切换为动态哈希数组,可以按需增长。

Q5:weak 对象在什么线程被置 nil?

在触发 dealloc 的那个线程。谁释放了最后一个 strong 引用,就在谁的线程上走 dealloc 流程,进而清理 weak 表。


十二、一句话总结

weak 的本质就是 Runtime 维护了一张"对象 -> 弱指针列表"的全局哈希表(SideTable 中的 weak_table)。创建 weak 引用时注册,读取时 retain 保安全,对象释放时遍历置 nil 后删除条目。代价是哈希查找和加锁的开销,换来的是零野指针的安全性。

相关推荐
小码哥_常7 小时前
解锁Android开发封装密码,打造高效代码城堡
前端
在西安放羊的牛油果7 小时前
我把 2000 行下单代码,重构成了一套交易前端架构
前端·设计模式·架构
im_AMBER7 小时前
今日开发反思:编辑器大纲跳转与数据持久化实践
前端·架构
Qinana7 小时前
从数据包旅程到首屏渲染:深入理解 TCP/IP 如何决定你的 Web 性能
前端·tcp/ip·浏览器
橙子的AI笔记7 小时前
旧版 LangChain 已死:新版竟以LangGraph为底座封装
前端·langchain
Wect7 小时前
LeetCode 17. 电话号码的字母组合:回溯算法入门实战
前端·算法·typescript
SuperEugene7 小时前
Vue3 中后台实战:VXE-Table 从基础表格到复杂业务表格全攻略 | Vue生态精选篇
前端·javascript·vue.js
SuperEugene7 小时前
Vue3 中后台实战:Element + VXE Table 搜索表格分页完整方案 | Vue生态精选篇
前端·javascript·vue.js
欧哥讼7 小时前
当我问AI如何熟练掌握表单验证时
前端