iOS复习必看!weak关键字底层原理(Deepseek&豆包)回答整理

13 阅读11分钟

一 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 的核心逻辑可以简化为以下步骤:

  1. 获取要指向的对象 newObj
  2. 如果有旧值(即这个 weak 指针之前已经指向过某个对象),需要先从 weak 表中移除该指针。
  3. 如果 newObj 不为 nil,则将当前 weak 指针注册到 newObj 的 weak 表中。
  4. 返回 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;
  1. 编译器为 obj1 的赋值生成类似 objc_storeWeak(&obj1, obj0) 的代码。
  2. 进入 objc_storeWeak
    • 获取 obj0SideTable,加锁。
    • 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
    • 解锁。
  3. 编译器为 obj2 的赋值生成 objc_storeWeak(&obj2, obj0)
  4. 再次进入 objc_storeWeak
    • 获取 obj0SideTable,加锁。
    • 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 表中的过程可以概括为:

  1. 编译器将 weak 赋值转换为 objc_storeWeak 调用。
  2. Runtime 通过对象地址找到对应的 SideTable(分离锁)。
  3. SideTableweak_table_t 中以对象地址为 key 查找或创建 weak_entry_t
  4. weak_entry_t 是一个容器(静态或动态数组),存储所有指向该对象的 weak 指针的地址。
  5. 将当前 weak 指针地址添加到该容器中。

这种设计保证了:

  • 高效查找:通过两层哈希表,快速定位到对象对应的 weak 容器。
  • 并发友好:通过 SideTable 分离锁,不同对象的 weak 操作可以并行。
  • 内存优化:使用小对象优化(inline array),减少大多数情况下的堆分配。
  • 自动置 nil:存储指针地址,使得对象销毁时可以直接修改 weak 变量的内容。

二 释放weak引用

当一个对象的引用计数降为 0 时,系统会回收该对象。在这个过程中,Runtime 必须完成两件与 weak 相关的重要工作:

  1. 将所有指向该对象的 weak 指针置为 nil,防止产生悬垂指针。
  2. 清理该对象在 weak 表中对应的条目(weak_entry_t),避免表膨胀。

下面我们从“引用计数归零”开始,逐步剖析整个流程,重点讲解 weak 的处理机制。


1. 引用计数归零的入口

在 ARC 下,当对象最后一次被释放时,会调用 objc_releaseobjc_release 内部会执行:

if (--newRetainCount == 0) {
    // 引用计数变为 0,准备销毁
    ((id)obj)->dealloc();
}

因此,引用计数归零的最终结果就是调用对象的 dealloc 方法。


2. dealloc 的核心调用链

对象的 dealloc 方法(通常由编译器自动生成)最终会调用 Runtime 函数 objc_destructInstance,该函数负责真正的析构工作。objc_destructInstance 的执行顺序大致如下:

  1. 如果有 C++ 析构函数,先调用。
  2. 如果有关联对象,则移除关联对象。
  3. 调用 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 清理的函数。它的实现思路是:

  1. 根据对象地址(this)找到对应的 weak_table_t
  2. weak_table_t 中查找该对象对应的 weak_entry_t
  3. 如果找到了,就遍历 weak_entry_t 中的所有 weak 指针地址,将每个指针的内容置为 nil
  4. 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_tnum_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

  1. 定位 SideTable:通过对象地址找到对应的 SideTable,加锁保证线程安全。
  2. 查找 weak_entry_t:在 weak_table_t 的哈希表中找到该对象对应的 weak_entry_t
  3. 遍历并置 nil:遍历 weak_entry_t 中存储的所有 weak 指针地址,将每个指针的值设为 nil。由于存储的是指针的地址,所以可以直接修改 weak 变量的内容。
  4. 清理 entry:从 weak_table_t 中删除该 weak_entry_t,释放相关内存。
  5. 清理引用计数表:从 refcnts 中删除该对象的条目。
  6. 解锁:完成 weak 和引用计数表的清理后,解锁 SideTable

这一过程确保了在对象被彻底销毁前,所有指向它的 weak 指针都被安全地置为 nil,从而避免程序出现野指针崩溃。同时,通过 SideTable 和锁的分离设计,保证了多线程环境下的性能和正确性。