本文基于可编译的objc4-750版本源码进行讨论,文章中旦有错误之处,希望大家不吝指正。
我们知道,weak引用的特性:
- 一个对象可以被一个或多个weak指针所引用,但不改变对象的引用计数。
- 一个对象被释放的时候,引用它的所有weak指针,需要设置为nil,避免造成野指针。
为了实现这样的能力,runtime需要在内存中维护所有对象与weak指针之间的引用关系(英文称“WeakPointer-Object Pair”,以下简称“weak引用对”或者“引用对”),并且对weak引用对进行操作有极高的性能追求。那么runtime中具体是如何实现的呢?
存储结构
试想,给你一个对象,要快速的找到所有引用他的weak指针,最简单粗暴的做法是在每一个对象结构体中都带一个weak指针数组,将所有weak指针存到数组中,只要拿到对象,就可以直接操作。如果是这样,我们今天都不用讨论了。显然这样是不行的,因为整个runtime的生命周期中,被weak引用的对象占总对象数是屈指可数的,如果每个对象结构体中都带一个数组(指针),那内存将大大地浪费。runtime中的做法是全局维护一个hash表,以对象地址作为key对的方式存储。这个全局的hash表是weak_table_t:
实际上,说runtime全局只有一个weak_table_t是不确切的,因为weak_table_t是SideTable中的成员结构,而全局并不是只有一个SideTable,关于SideTable的个数以及如何存储可以看这篇文章的介绍。这里我们先忽略它,将重点放在weak_table_t:
/**
* The global weak references table. Stores object ids as keys,and weak_entry_t structs as their values.
*/
struct weak_table_t {
weak_entry_t *weak_entries;
size_t num_entries; // 元素个数
uintptr_t mask; // mask=数组容量-1
uintptr_t max_hash_displacement; // 插入元素时发生冲突后最大位移量,主要用于校验
};
从结构体中可以看出,内部是用一个weak_entries数组来实际存储weak_entry_t类型的value,其中num_entries表示元素的总数。既然weak_entry_t是value,key又是对象地址,那么显然一个weak_entry_t就对应一个对象,而一个对象可以被多个weak指针引用,那么所有的weak指针应该是放在weak_entry_t里了:
struct weak_entry_t {
// DisguisedPtr<T> 实际上是T* 的封装,可以把它看做T* ,将T* 伪装一下是的避免被类似leak的内存检测工具识别。
DisguisedPtr<objc_object> referent; // 被引用对象
union {
// 当添加一个新的的weak引用者指针,会尝试先把引用者指针存储在inline数组里面,则再开辟新的数组空间,这时用referrers表示
struct {
weak_referrer_t *referrers; // typedef DisguisedPtr<objc_object *> weak_referrer_t; // 64bit * 1
uintptr_t out_of_line_ness : 2; // 用于标记是否是out_of_line,0b10表示out_of_line
// 如果out_of_line的话,数据存放在referrers数组中,否则的话,存放在inline_referrers这个内部小数组(长度只有4)
uintptr_t num_refs : PTR_MINUS_2; // 62bit
uintptr_t mask; // mask=referrers数组的容量-1
uintptr_t max_hash_displacement; // 64bit * 1
};
struct {
// inline_referrers[1]的低两位是out_of_line_ness标记位
weak_referrer_t inline_referrers[WEAK_INLINE_COUNT];// 64bit * 4
};
};
// ...
};
从weak_entry_t中我们可以看出,真正weak引用关系是存在该结构中的,该结构中既包含了被引用对象referent,所有的weak指针则存放在union联合体的结构中。
概括一下weak引用对的存储结构:
1、全局SideTable持有的weak_table_t,可以通过对象地址作为key找到。
2、weak_table_t内部存放weak_entry_t类型的数组,一个对象对应一个weak_entry_t。
3、weak_entry_t中实际存放着被引用的对象和引用它的所有weak指针。
寻址和扩容
weak_table_t
往weak_table中插入weak_entry的逻辑在weak_entry_insert(...)中:
static void weak_entry_insert(weak_table_t *weak_table, weak_entry_t *new_entry)
{
weak_entry_t *weak_entries = weak_table->weak_entries;
// ...
size_t begin = hash_pointer(new_entry->referent) & (weak_table->mask);
size_t index = begin;
size_t hash_displacement = 0; // 用于记录hash冲突时往后寻找位移大小
while (weak_entries[index].referent != nil) {
index = (index+1) & weak_table->mask;
if (index == begin) bad_weak_table(weak_entries);
hash_displacement++;
}
weak_entries[index] = *new_entry;
weak_table->num_entries++;
if (hash_displacement > weak_table->max_hash_displacement) { // 记录最大位移数
weak_table->max_hash_displacement = hash_displacement;
}
}
在这个方法中,先用对象地址(referent)求hash,再和mask按位与计算的得到起始索引,mask的值为数组长度-1,这样求索引的值必然在数组的范围内。
index = hash & (length-1)
比如: 某个对象的地址求得的hash为0x6a9f2be7,当前数组长度为16,mask为15(0b1111),则 0x6a9f2be7 & ob1111 = 0b0111(7),那么begin索引值为7。
那么,当在初始索引处已经被占用了,也就是发生了hash冲突,则index+1继续往后寻找,直到找到为止,这种处理hash冲突的方法叫做开放寻址法。
通过对象地址查找weak_entry的时候也是大同小异,具体逻辑在weak_entry_for_referent(...)中,这边不再赘述。
此外,weak_table_t的容量如何管理呢?既然它内部使用数组存储,那么数组的大小是固定的,当元素到一定数量时,自然要进行扩容。实际上,在每次调用weak_entry_insert(...)进行插入之前,会先调用weak_grow_maybe(...)方法判断是否需要扩容:
static void weak_grow_maybe(weak_table_t *weak_table)
{
size_t old_size = TABLE_SIZE(weak_table);
// 初始容量为64,当容量达到3/4时对数组进行扩容,每次扩容为原来的2倍.
if (weak_table->num_entries >= old_size * 3 / 4) {
weak_resize(weak_table, old_size ? old_size*2 : 64);
}
}
删除元素的时候也会判断是否释放多余的容量,这边也不再赘述。
weak_entry_t
在weak_entry_t的结构体中,我们也看到mask、max_hash_displacement的成员变量,那么是不是说明weak_entry_t中的寻址和处理hash冲突的逻辑跟上面所介绍的weak_table_t中的逻辑一样呢?
答案是,看情况。
在weak_entry_t结构的具体定义中,是有一个union联合体的:
struct weak_entry_t {
DisguisedPtr<objc_object> referent;
union {
struct { //结构体1
weak_referrer_t *referrers;
uintptr_t out_of_line_ness : 2;
// ...
};
struct { //结构体2
weak_referrer_t inline_referrers[4];
};
};
// ...
};
联合体是什么?它也叫共用体,本质是共用一段内存的。它其中的两个结构体可以看成这段内存的不同表现形式,在这边是用了out_of_line_ness段来区分,如果out_of_line_ness=0b10,这段内存可以看成是用结构体1表示,否则用结构体2表示。
我们来到append_referrer(...)方法中,这边是往weak_entry_t中插入weak指针(weak_referrer_t)的具体逻辑:
static void append_referrer(weak_entry_t *entry, objc_object **new_referrer)
{
if (! entry->out_of_line()) { // out_of_line_ness != REFERRERS_OUT_OF_LINE
// Try to insert inline.
for (size_t i = 0; i < WEAK_INLINE_COUNT; i++) { // WEAK_INLINE_COUNT=4
if (entry->inline_referrers[i] == nil) {
entry->inline_referrers[i] = new_referrer;
return;
}
}
// Couldn't insert inline. Allocate out of line.
weak_referrer_t *new_referrers = (weak_referrer_t *)
calloc(WEAK_INLINE_COUNT, sizeof(weak_referrer_t));
// ...
for (size_t i = 0; i < WEAK_INLINE_COUNT; i++) {
new_referrers[i] = entry->inline_referrers[i];
}
entry->referrers = new_referrers;
entry->num_refs = WEAK_INLINE_COUNT;
entry->out_of_line_ness = REFERRERS_OUT_OF_LINE; // 0b10
entry->mask = WEAK_INLINE_COUNT-1;
entry->max_hash_displacement = 0;
}
assert(entry->out_of_line());
if (entry->num_refs >= TABLE_SIZE(entry) * 3/4) { // 元素个数超过3/4后,开辟新的数组空间
return grow_refs_and_insert(entry, new_referrer);
}
size_t begin = w_hash_pointer(new_referrer) & (entry->mask);
// ...(查找和插入的逻辑跟上面一样,这边省略)
}
方法中,先判断了是否是out_of_line(),也就是上面提到的out_of_line_ness位,如果非REFERRERS_OUT_OF_LINE,weak指针是存在union的 结构体2 中inline_referrers[4]小数组中,当小数组存满了,则开辟由 结构体1 中referrers指向的新的数组空间,并将原来inline_referrers[4]数组中的元素拷贝到referrers数组。 将out_of_line_ness位标记为REFERRERS_OUT_OF_LINE,接下来的扩容和寻址逻辑就跟上面提到的一样了。
为什么weak_entry_t中寻址和扩容要和weak_table_t中有所不一样呢?分析,如果weak_entry_t中直接采用weak_table_t一样的寻址方式,也就是每次都对地址进行hash计算,hash计算的开销可能比遍历小数组还大。而一个对象对应的weak指针很多时候是不多的(少于4个),为了性能达到最优,必须考虑这种情况。
总结:
- weak引用对是存放在全局的
weak_table_t结构中,weak_table_t是一个采用开放寻址法的hash table。 - 一个被weak引用的对象对应一个
weak_entry_t结构,通过对象地址作为key可以找到对应的weak_entry_t。 weak_entry_t结构中存放着所有引用该对象的weak指针地址,它也是一个使用指针地址作为key的hash table,当weak指针个数<=4时,它们是存放在weak_entry_t中的固定数组,否则也是一个采用开放寻址法处理的数组。采用这样的设计是为了大部分情况下对表存取的性能达到最优。