一 weak引用
当一个对象被 weak 引用时,Runtime 需要把这个 weak 指针记录到一个全局的数据结构中,以便将来对象销毁时能够找到它并置为 nil。整个过程涉及编译器、Runtime 函数和复杂的数据结构。下面我们详细拆解这个过程。
1. 编译器对 weak 变量的处理
假设我们有如下代码:
NSObject *obj0 = [NSObject new];
__weak id obj1 = obj0;
在 ARC 环境下,编译器会将 __weak 变量的赋值操作编译为对 Runtime 函数的调用。具体来说:
- 对于
__weak变量的初始化(首次赋值),编译器会调用objc_initWeak。 - 对于
__weak变量的重赋值(即已经存在一个 weak 变量,再将其指向另一个对象),编译器会调用objc_storeWeak。
这两者最终都会调用核心函数 objc_storeWeak(id *location, id newObj),其中:
location是 weak 指针的地址(例如&obj1)。newObj是要指向的对象(即obj0)。
所以,我们以 objc_storeWeak 为主线,讲解存储过程。
2. 进入 Runtime:objc_storeWeak 的主要流程
objc_storeWeak 的核心逻辑可以简化为以下步骤:
- 获取要指向的对象
newObj。 - 如果有旧值(即这个 weak 指针之前已经指向过某个对象),需要先从 weak 表中移除该指针。
- 如果
newObj不为nil,则将当前 weak 指针注册到newObj的 weak 表中。 - 返回
newObj。
下面重点说明注册过程,即如何将 weak 指针存储到表中。
3. 存储 weak 指针的核心步骤
3.1 获取 SideTable
Runtime 维护一个全局的 SideTables 哈希表(实际上是一个 StripedMap),它以对象的内存地址为 key,映射到一个 SideTable 结构体。这个 SideTable 包含了引用计数和 weak 表等。
为了操作 obj0 的 weak 表,需要先通过 obj0 的地址找到对应的 SideTable:
SideTable *table = &SideTables[obj0];
这里使用 obj0 的地址进行哈希计算,得到一个索引,从而取出对应的 SideTable。
为什么需要 SideTable ? 主要为了锁分离,提高并发性能。不同的对象可能映射到不同的 SideTable,操作不同对象的 weak 引用时可以并行执行。
3.2 获取 weak_table_t
每个 SideTable 内部包含一个 weak_table_t 结构,它是一个独立的哈希表,专门管理所有指向该 SideTable 所管辖对象的 weak 引用。
weak_table_t &weak_table = table->weak_table;
weak_table_t 的定义大致如下:
struct weak_table_t {
weak_entry_t *weak_entries; // 哈希数组,元素是 weak_entry_t
size_t num_entries; // 当前条目数
uintptr_t mask; // 掩码,用于哈希计算
uintptr_t max_hash_displacement; // 最大偏移量
};
3.3 在 weak_table_t 中查找或创建 weak_entry_t
接下来,使用 对象地址 obj0 作为 key,在 weak_table_t 中查找对应的 weak_entry_t。
weak_entry_t 是一个容器,它负责存储所有指向同一个对象的 weak 指针地址(即 location)。
- 如果找到已有的
weak_entry_t,说明已经有一些 weak 指针指向obj0,那么直接将当前 weak 指针地址添加到这个weak_entry_t中。 - 如果没找到,说明这是第一个指向
obj0的 weak 指针,需要创建一个新的weak_entry_t,并将它插入到weak_table_t中。
3.4 weak_entry_t 的结构:如何存储多个 weak 指针
weak_entry_t 的设计目标是高效地存储多个指向同一个对象的 weak 指针。它的简化结构如下:
struct weak_entry_t {
DisguisedPtr<objc_object> referent; // 指向对象,即 obj0
union {
struct {
weak_referrer_t *referrers; // 动态数组
uintptr_t out_of_line : 1; // 标志位,表示使用动态数组
// ... 其他字段
};
struct {
weak_referrer_t inline_referrers[4]; // 静态数组,容量为4
};
};
};
这里有一个优化:当一个对象只有少量(<=4)weak 引用时,使用静态数组 inline_referrers,避免额外堆分配。当超过4个时,会转换为动态数组 referrers,可以动态扩容。
weak_referrer_t 本质上就是 id *,即 weak 指针的地址(例如 &obj1)。存储的是指针的地址,这样当对象销毁时,Runtime 可以找到这个地址并将其内容置为 nil。
3.5 添加 weak 指针到 weak_entry_t
在找到或创建了 weak_entry_t 之后,调用 weak_entry_append 或类似函数,将当前 weak 指针的地址(location)添加到 weak_entry_t 的数组中。
添加时,会根据当前是静态数组还是动态数组,采取不同的插入逻辑。如果是静态数组且未满,直接放入;如果静态数组已满(即已有4个),则触发扩容,将静态数组内容迁移到新分配的动态数组,再加入新元素。
3.6 线程安全
整个过程中,对 SideTable 的操作都在其持有的自旋锁(spinlock_t)保护下进行,确保多个线程同时操作 weak 引用时的安全性。
4. 一个完整的例子
让我们用代码模拟这个过程:
NSObject *obj0 = [NSObject new];
__weak id obj1 = obj0;
__weak id obj2 = obj0;
- 编译器为
obj1的赋值生成类似objc_storeWeak(&obj1, obj0)的代码。 - 进入
objc_storeWeak:- 获取
obj0的SideTable,加锁。 - 在
weak_table_t中以obj0为 key 查找weak_entry_t,未找到,创建一个新的weak_entry_t,其中referent指向obj0,静态数组inline_referrers为空。 - 将
&obj1添加到weak_entry_t的数组中。 - 将
weak_entry_t插入weak_table_t。 - 解锁。
- 获取
- 编译器为
obj2的赋值生成objc_storeWeak(&obj2, obj0)。 - 再次进入
objc_storeWeak:- 获取
obj0的SideTable,加锁。 - 在
weak_table_t中以obj0为 key 查找weak_entry_t,这次找到了。 - 将
&obj2添加到该weak_entry_t的数组中(此时数组内已有&obj1和&obj2)。 - 解锁。
- 获取
此时,obj0 的 weak 表中就记录了这两个 weak 指针的地址。
5. 为什么存储的是 weak 指针的地址,而不是指针本身?
当对象 obj0 销毁时,Runtime 会遍历 weak_entry_t 中的数组,对于数组中的每一项(即每个 weak_referrer_t,它是一个 id * 类型),执行 *referrer = nil,将 weak 变量本身置为 nil。如果存储的是指针的值(即 obj1 本身),那么 Runtime 只能知道这个 weak 指针指向了 obj0,却无法修改这个 weak 变量(因为不知道它的内存位置)。存储地址使得 Runtime 能够直接修改该变量的内容,从而实现自动置 nil。
6. 小结
对象被 weak 引用后,存储到 weak 表中的过程可以概括为:
- 编译器将 weak 赋值转换为
objc_storeWeak调用。 - Runtime 通过对象地址找到对应的
SideTable(分离锁)。 - 在
SideTable的weak_table_t中以对象地址为 key 查找或创建weak_entry_t。 weak_entry_t是一个容器(静态或动态数组),存储所有指向该对象的 weak 指针的地址。- 将当前 weak 指针地址添加到该容器中。
这种设计保证了:
- 高效查找:通过两层哈希表,快速定位到对象对应的 weak 容器。
- 并发友好:通过
SideTable分离锁,不同对象的 weak 操作可以并行。 - 内存优化:使用小对象优化(inline array),减少大多数情况下的堆分配。
- 自动置 nil:存储指针地址,使得对象销毁时可以直接修改 weak 变量的内容。
二 释放weak引用
当一个对象的引用计数降为 0 时,系统会回收该对象。在这个过程中,Runtime 必须完成两件与 weak 相关的重要工作:
- 将所有指向该对象的
weak指针置为nil,防止产生悬垂指针。 - 清理该对象在
weak表中对应的条目(weak_entry_t),避免表膨胀。
下面我们从“引用计数归零”开始,逐步剖析整个流程,重点讲解 weak 的处理机制。
1. 引用计数归零的入口
在 ARC 下,当对象最后一次被释放时,会调用 objc_release。objc_release 内部会执行:
if (--newRetainCount == 0) {
// 引用计数变为 0,准备销毁
((id)obj)->dealloc();
}
因此,引用计数归零的最终结果就是调用对象的 dealloc 方法。
2. dealloc 的核心调用链
对象的 dealloc 方法(通常由编译器自动生成)最终会调用 Runtime 函数 objc_destructInstance,该函数负责真正的析构工作。objc_destructInstance 的执行顺序大致如下:
- 如果有 C++ 析构函数,先调用。
- 如果有关联对象,则移除关联对象。
- 调用
clearDeallocating,这是处理weak和引用计数表的关键步骤。
3. clearDeallocating 的作用
objc_object::clearDeallocating 函数的简化逻辑如下:
void objc_object::clearDeallocating() {
// 获取 SideTable
SideTable *table = SideTable::tableForPointer(this);
// 加锁,防止并发操作
table->lock();
// 处理 weak 引用:将指向当前对象的所有 weak 指针置 nil,并移除 weak_entry_t
weak_clear_no_lock(table, this);
// 处理引用计数表:清除该对象在 RefcountMap 中的条目
table->refcnts.erase(this);
table->unlock();
}
可见,weak 的处理先于引用计数表的清理。这是因为 weak_clear_no_lock 需要读取 weak_table_t,而该表在对象销毁后便不再需要,所以先处理 weak 再清理 refcnts 是合理的。
4. weak_clear_no_lock 详解:将 weak 指针置 nil 并清理 entry
weak_clear_no_lock 是真正执行 weak 清理的函数。它的实现思路是:
- 根据对象地址(
this)找到对应的weak_table_t。 - 在
weak_table_t中查找该对象对应的weak_entry_t。 - 如果找到了,就遍历
weak_entry_t中的所有 weak 指针地址,将每个指针的内容置为nil。 - 从
weak_table_t中移除这个weak_entry_t(释放其占用的内存)。
下面我们拆解每一步。
4.1 获取 SideTable 和 weak_table_t
SideTable *table = &SideTables[this];
weak_table_t *weak_table = &table->weak_table;
这里 this 就是待释放对象的地址。通过对象地址取模得到对应的 SideTable(分离锁),然后取出其中的 weak_table_t。
4.2 在 weak_table_t 中查找 weak_entry_t
以对象地址为 key,在 weak_table_t 的哈希表(weak_entries 数组)中查找对应的 weak_entry_t。如果找不到,说明没有 weak 指针指向该对象,直接返回。否则,进入下一步。
4.3 遍历 weak_entry_t,将 weak 指针置 nil
weak_entry_t 内部存储着所有指向该对象的 weak 指针的地址(即 weak_referrer_t,类型是 id *)。weak_clear_no_lock 会遍历这个容器(无论是静态数组还是动态数组),对每个 referrer 执行:
*referrer = nil;
这一步直接修改了 weak 变量的内容,使其变为 nil。由于 weak 变量本身是 __weak 修饰的,它们的存储位置可能是栈上的局部变量,也可能是堆上的实例变量,但都是有效的内存地址,因此可以直接赋值。
4.4 从 weak_table_t 中移除 weak_entry_t
遍历并置 nil 完成后,weak_entry_t 已经没有任何作用了。需要将其从 weak_table_t 的哈希表中删除,并释放 weak_entry_t 占用的内存(如果是动态数组,也要释放)。
这一步涉及哈希表的删除操作,具体会:
- 将
weak_entries数组中对应的槽位标记为空。 - 减少
weak_table_t的num_entries计数。 - 如果
weak_entry_t使用了动态数组(即out_of_line标志为 1),则释放referrers指向的堆内存。
5. 引用计数表的清理
在 weak_clear_no_lock 之后,clearDeallocating 还会调用:
table->refcnts.erase(this);
refcnts 是一个 DenseMap(或类似结构),存储了该对象的额外引用计数信息(例如 weak 引用计数、deallocating 标志等)。由于对象即将被销毁,这些信息也不再需要,因此从表中删除。
6. 为什么 weak 处理必须加锁?
在整个过程中,SideTable 的锁一直持有,直到 clearDeallocating 结束。这是因为可能有多个线程同时操作同一个对象的 weak 引用(例如一个线程正在释放对象,另一个线程正在对这个对象取 weak 值),锁保证了操作的原子性,避免出现数据竞争。
7. 完整流程图示
对象引用计数 → 0
↓
调用 dealloc
↓
objc_destructInstance
↓
clearDeallocating
↓
获取 SideTable,加锁
↓
weak_clear_no_lock
├── 根据对象地址查找 weak_table_t
├── 在 weak_entries 中找到 weak_entry_t
├── 遍历 referrers 数组
│ └── 将每个 weak 指针内容置为 nil
└── 从 weak_entries 中移除 weak_entry_t,释放内存
↓
从 refcnts 中擦除该对象的条目
↓
解锁 SideTable
↓
对象内存被释放(free)
8. 总结
当对象引用计数归零时,Runtime 通过以下步骤处理 weak:
- 定位 SideTable:通过对象地址找到对应的
SideTable,加锁保证线程安全。 - 查找 weak_entry_t:在
weak_table_t的哈希表中找到该对象对应的weak_entry_t。 - 遍历并置 nil:遍历
weak_entry_t中存储的所有 weak 指针地址,将每个指针的值设为nil。由于存储的是指针的地址,所以可以直接修改 weak 变量的内容。 - 清理 entry:从
weak_table_t中删除该weak_entry_t,释放相关内存。 - 清理引用计数表:从
refcnts中删除该对象的条目。 - 解锁:完成 weak 和引用计数表的清理后,解锁
SideTable。
这一过程确保了在对象被彻底销毁前,所有指向它的 weak 指针都被安全地置为 nil,从而避免程序出现野指针崩溃。同时,通过 SideTable 和锁的分离设计,保证了多线程环境下的性能和正确性。