Runtime系列文章
Runtime笔记(一)—— isa的深入体会(苹果对isa的优化)
Runtime笔记(二)—— Class结构的深入分析
Runtime笔记(三)—— OC Class的方法缓存cache_t
Runtime笔记(四)—— 刨根问底消息机制
Runtime笔记(五)—— super的本质
[Runtime笔记(六)—— Runtime的应用…待续]-()
[Runtime笔记(七)—— Runtime的API…待续]-()
Runtime笔记(八)—— 记一道变态的runtime面试题
☕️☕️本文篇幅比较长,创作的目的并不是为了在简书上刷赞和阅读量,而是为了自己日后温习知识所用。如果有幸被你发现这篇文章,并且引起了你的阅读兴趣,请休息充分,静下心来,精力充足地开始阅读,希望这篇文章能对你有所帮助。如发现任何有误之处,肯请留言纠正,谢谢。☕️☕️
承接上一篇的内容,我们回过头去看Class
的定义
struct objc_class : objc_object {
// Class ISA;
Class superclass;
cache_t cache; // formerly cache pointer and vtable 方法缓存
class_data_bits_t bits; // class_rw_t * plus custom rr/alloc flags 用于获取具体的类信息
};
这里面还有一个cache_t cache
没有解读过,一起来看一看这个东西。看名字很好理解,就是缓存的意思,缓存什么呢?——缓存方法。
它的底层是通过散列表(哈希表)
的数据结构来实现的,用于缓存曾经调用过的方法,可以提高方法的查找速度。
首先,回顾一下正常情况下方法调用的流程。假设我们调用一个实例方法[bj XXXX];
obj
->isa
->obj
的Class
对象 ->method_array_t methods
-> 对该表进行遍历查找,找到就调用,没找到继续往下走obj
->superclass
->obj
的父类 ->isa
->method_array_t methods
-> 对父类的方法列表进行遍历查找,找到就调用,没找到就重复本步骤- 找到就调用,没找到重复流程
- 找到就调用,没找到重复流程
- 找到就调用,没找到重复流程
- 直到
NSObject
->isa
->NSObject
的Class
对象 -> method_array_t methods …
如果XXXX
方法在程序内会被频繁的调用,那么这种逐层便利查找的方式肯定是效率低下的,因此苹果设计了cache_t cache
,当XXXX
第一次被调用的时候,会按照常规流程查找,找到之后,就会被加入到cache_t cache
中,当再次被调用的时候,系统就会直接现到cache_t cache
来查找,找到就直接调用,这样便大大提升了查找的效率。
刚才介绍了cache_t cache
是通过散列表
来实现的,下面就来着重分析一下,方法是如何被缓存的。散列/哈希表,想必大部分iOS开发者至少应该听过,而我们常用的NSDictionary
其实就是一种散列表数据结构。来看一下cache_t cache
的定义
struct cache_t {
struct bucket_t *_buckets;
mask_t _mask;
mask_t _occupied;
}
struct bucket_t *_buckets;
—— 用来缓存方法的散列/哈希表mask_t _mask;
—— 这个值 = 散列表长度 - 1mask_t _occupied;
—— 表示已经缓存的方法的数量
上面介绍的_buckets
散列表里面的存储单元是bucket_t
,来看看它包含了方法的什么信息
struct bucket_t {
private:
cache_key_t _key;
IMP _imp;
}
cache_key_t _key;
—— 这个key实际上就是方法的SEL,也就是方法名IMP _imp;
—— 这个就是方法对应的函数的内存地址
想一想我们平时是怎么使用NSDictionary的,通过一堆Key-Value
键值对来进行存储的,NSDictionary的底层就是散列表,这个刚才说过。方法缓存的时候,key
就是上面的cache_key_t _key;
,value就是上面的bucket_t
结构体对象。
但是散列表的运作原理到底如何呢,这个属于数据结构问题,这里简要介绍一下。首先散列表本质上就是一个数组
在往散列表里面添加成员的时候,首先需要借助key
计算出一个index
,然后再将元素插入散列表的index
位置
那么从散列表里面取值就显而易见了,根据一个key,计算出index,然后到散列表对应位置将值取出
这里的查询方法的时候(也就是取值操作),时间复杂度为O(1), 对比我们一开始从方法列表的遍历查询所对应的时间复杂度为O(n),因此通过缓存方法,可以极大的提高方法查询的效率,从而提高了方法调用机制的效率。
根据key计算出index值的这个算法称作散列算法,这个算法可以由你自己设计,总之目的就是尽可能减少不同的key得出相同index的情况出现,这种情况被称作哈希碰撞,同时还要保证得出的index值在合理的范围。index越大,意味着对应的散列表的长度越长,这是需要占用实际物理空间的,而我们的内存是有限的。散列表是一种通过牺牲一定空间,来换取时间效率的设计思想。
我们通过key计算出的index大小是随机的,无顺序的,因此在方法缓存的过程中,插入的顺序也是无顺序的
而且可以预见的是,散列表里面再实际使用中会有很多位置是空着的,比如散列表长度为16
,最终值存储了10
个方法,散列表长度为64
,最终可能只会存储40
个方法,有一部分空间终究是要被浪费的。但是却提高查找的效率。这既是所谓的空间换时间。
再介绍一下苹果这里所采用的散列算法,其实很简单,如下
index = @selector(XXXX) & mask
根据&运算的特点,可以得知最终index <= mask
,而mask
= 散列表长度 - 1
,也就是说 0 <= index <= 散列表长度 - 1
,这实际上覆盖了散列表的索引范围。而刚刚我们还提到过一个问题——哈希碰撞,也就是不同的key
得到相同的index
,该怎么处理呢?我们看一下源码,在objc源码里面搜索cache_t
,可以发现一个跟查找相关的方法
bucket_t * cache_t::find(cache_key_t k, id receiver) //根据key值 k 进行查找
{
assert(k != 0);
bucket_t *b = buckets();
mask_t m = mask();
mask_t begin = cache_hash(k, m); //通过cache_hash函数【begin = k & m】计算出key值 k 对应的 index值 begin,用来记录查询起始索引
mask_t i = begin; // begin 赋值给 i,用于切换索引
do {
if (b[i].key() == 0 || b[i].key() == k) {
//用这个i从散列表取值,如果取出来的bucket_t的 key = k,则查询成功,返回该bucket_t,
//如果key = 0,说明在索引i的位置上还没有缓存过方法,同样需要返回该bucket_t,用于中止缓存查询。
return &b[i];
}
} while ((i = cache_next(i, m)) != begin);
// 这一步其实相当于 i = i-1,回到上面do循环里面,相当于查找散列表上一个单元格里面的元素,再次进行key值 k的比较,
//当i=0时,也就i指向散列表最首个元素索引的时候重新将mask赋值给i,使其指向散列表最后一个元素,重新开始反向遍历散列表,
//其实就相当于绕圈,把散列表头尾连起来,不就是一个圈嘛,从begin值开始,递减索引值,当走过一圈之后,必然会重新回到begin值,
//如果此时还没有找到key对应的bucket_t,或者是空的bucket_t,则循环结束,说明查找失败,调用bad_cache方法。
// hack
Class cls = (Class)((uintptr_t)this - offsetof(objc_class, cache));
cache_t::bad_cache(receiver, (SEL)k, cls);
}
*********************************** cache_hash(k, m);
static inline mask_t cache_hash(cache_key_t key, mask_t mask)
{