在编写代码时,弱引用一般以下面两种形式出现:
- 使用
weak
关键字修饰属性时 - 使用
__weak
关键字修饰变量时
这里我们可以统一把第一种形式看作使用 __weak
关键字修饰成员变量。
__weak
修饰的变量有两大特点:
- 不会增加指向对象的引用计数 (规避循环引用)
- 指向对象释放后,变量会自动置 nil (规避野指针访问错误)
下文会从源码的视角分析 runtime 是如何实现弱引用自动置 nil 的。
实现简述
设置 __weak
修饰的变量时,runtime 会生成对应的 entry 结构放入 weak hash table 中,以赋值对象地址生成的 hash 值为 key,以包装 __weak
修饰的指针变量地址的 entry 为 value,当赋值对象释放时,runtime 会在目标对象的 dealloc 处理过程中,以对象地址(self)为 key 去 weak hash table 查找 entry ,置空 entry 指向的的所有对象指针。
实际上 entry 使用数组保存指针变量地址,当地址数量不大于 4 时,这个数组就是个普通的内置数组,在地址数量大于 4 时,这个数组就会扩充成一个 hash table。
实现模仿
首先,我们看下如下代码:
1 | @interface A : NSObject |
由于 __unsafe_unretained
修饰的变量会始终保留对像地址,所以在 obj 指向的对象释放后,访问 w1 会出现 EXC_BAD_ACCESS 错误,我们要做的就是模仿 __weak
的实现,在 obj 指向的对象释放之后,将 w1 置为 nil。
以下是根据实现简述编写的代码:
1 | // { 对象地址 : [ 对象指针地址1、 对象指针地址1] } |
考虑到 w1 变量的作用域可能会在指向对象释放前结束,我们还需要在作用域结束时,将保存的 w1 地址清除:
1 |
|
即使出了作用域,只要栈帧还在,并且 w1 变量所处的地址没被覆盖,那么通过 w1 的地址访问 w1 变量(即访问 obj 指向的对象)还是没有问题的,只不过既然 w1 对于外界不可见,就没有继续在 map 中维护其地址的必要了。
以上就是我所理解的弱引用置 nil 的粗略实现,接下来我们看看 runtime 是如何实现这个特性的。
objc 的实现
以下源码分析基于 runtime 750 版本
我们使用以下代码分析 runtime 是如何在 weak hash table 中创建及销毁 __weak
修饰变量信息的:
1 | int main(int argc, const char * argv[]) { |
初步分析
在设置 w1/w0 变量时,runtime 触发了以下调用栈:
1 | objc_initWeak / objc_storeWeak |
在将要走出 w1 变量的作用域时,runtime 触发了以下调用栈:
1 | objc_destroyWeak |
在 obj 释放时,runtime 触发了以下调用栈:
1 | objc_storeStrong |
我们顺着这几个函数调用栈,抽取关键信息进行分析。
创建关联信息
1 | id objc_initWeak(id *location, id newObj) |
objc_initWeak
只是简单地判空处理后,调用了 storeWeak
函数,并传入一些模版参数,这里的 location 就是 __weak
修饰的指针变量地址,newObj 为赋值对象的地址,objc_storeWeak
同理。
1 | static id storeWeak(id *location, objc_object *newObj) { |
这里的 SideTable 我们可以简单地把它视为保存对象引用计数和弱引用表的结构,对于一个对象来说这个结构实例是唯一的。一般来说,objc 2.0 的对象引用计数都会优先保存在 isa 的 extra_rc 位段中,只有超出了存储的限制才会将超出部分保存到对应的 SideTable 中,isa 使用 has_sidetable_rc 标记是否超出限制。
在设置新的关联前,如果 __weak
修饰的指针变量已经关联了其他对象,那么此函数会先解除旧关联,再设置新的。如果 newObjc 是 nil,那么只会进行解除关联以及指针置 nil 操作,objc_destroyWeak
就以这种方式调用 storeWeak
来执行销毁动作。
1 | id weak_register_no_lock(weak_table_t *weak_table, id referent_id, |
这里面涉及到了两个结构 weak_table_t
和 weak_entry_t
,其结构如下:
1 | struct weak_table_t { |
后面会频繁提及这两个结构。
回到 weak_register_no_lock
函数,由于是第一次设置 __weak
变量,没有现成的 entry,需要新建一个,所以走的是 else 新增逻辑分支,如果是多个 __weak
变量指向同个对象时,entry 是可以同时保存这几个变量的地址的,这时候就是走的 append_referrer
分支。
1 | static void weak_entry_insert(weak_table_t *weak_table, weak_entry_t *new_entry) |
可以看到,上方 weak_table
的 weak_entries
字段可视为哈希表,key 由对象地址生成,value 是记录 __weak
修饰变量地址的 entry 结构。 weak_entry_for_referent
函数从哈希表中获取 entry,和 weak_entry_insert
实现类似,这里不做赘述。
调用 weak_entry_insert
函数之后,一次弱引用记录的创建就算完成了,
销毁关联信息
1 | void objc_destroyWeak(id *location) |
objc_destroyWeak
传入了 nil ,用以清空 location 地址上的对象指针,并且由于没有非 nil 新值,storeWeak
只会删除不会新建关联信息。storeWeak
上一节已经分析过,这里直接看 weak_unregister_no_lock
函数。
1 | void weak_unregister_no_lock(weak_table_t *weak_table, id referent_id, |
这里将销毁的 __weak
变量地址从 entry 中删除。
指针变量置 nil
1 | void weak_clear_no_lock(weak_table_t *weak_table, id referent_id) |
刨去前面 dealloc
相关的调用函数,weak_clear_no_lock
只是根据释放对象的地址,查找关联的 entry ,遍历 entry 中的地址,置 nil 地址上的指针变量。
weak_entry_t 的两种形式
上面分析基本围绕着 weak_table_t
展开,实际上它只是第一层哈希表,其存储的 weak_entry_t
value 内部也可以实现为一个哈希表,只不过 weak_table_t
使用对象地址生成 hash 值,而 weak_entry_t
使用 __weak
修饰的指针变量地址生成 hash 值。
这里回到 weak_entry_t
的结构:
1 |
|
weak_entry_t
定义了一个 union ,其中 WEAK_INLINE_COUNT 宏为 4 ,也就是说在初始状态下,这个 union 的空间有 weak_referrer_t inline_referrers[4]
这么大,当 entry 保存指针变量地址的个数不大于 4 个时,我们就可以直接使用 inline_referrers
数组,这样写的话,访问更加快速便捷。
我们再看下关联的变量个数大于 4 的情况:
1 | int main(int argc, const char * argv[]) { |
当 entry 已经存在时,再关联指针变量则会走 append_referrer
函数,也就是上方的 w1 开始到 w4 都走的 append_referrer
。
1 | static void append_referrer(weak_entry_t *entry, objc_object **new_referrer) |
可以看到,w1-w3 会直接使用 inline_referrers
,一旦设置 w4,关联数据就大于 4 了,weak_entry_t
将不会使用内置数组,而是使用 grow_refs_and_insert
函数申请新的内存。
1 | __attribute__((noinline, used)) |
这里调用 append_referrer
时,由于已经设置了 out_of_line_ness
,out_of_line
函数将会返回 true,在数据再次溢出 hash table 之前,我们可以直接走插入流程。