一、weak 是什么
weak 是 Objective-C / Swift 中的一种弱引用修饰符。它的核心行为只有两条:
- 不增加引用计数:持有对象但不影响对象的生命周期
- 对象释放时自动置 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 后删除条目。代价是哈希查找和加锁的开销,换来的是零野指针的安全性。