探索底层原理,积累从点滴做起。大家好,我是Mars。
往期回顾
iOS底层原理探索—OC对象的本质
iOS底层原理探索—class的本质
iOS底层原理探索—KVO的本质
iOS底层原理探索— KVC的本质
iOS底层原理探索— Category的本质(一)
iOS底层原理探索— Category的本质(二)
iOS底层原理探索— 关联对象的本质
iOS底层原理探索— block的本质(一)
iOS底层原理探索— block的本质(二)
iOS底层原理探索— Runtime之isa的本质
今天继续带领大家探索iOS之Runtime
的本质。
前言
OC是一门动态性比较强的编程语言,它的动态性是基于Runtime
的API
。Runtime
在我们的实际开发中占据着重要的地位,在面试过程中也经常遇到Runtime
相关的面试题,我们在之前几期的探索分析时也经常会到Runtime
的底层源码中查看相关实现。Runtime
对于iOS
开发者的重要性不言而喻,想要学习和掌握Runtime
的相关技术,就要从Runtime
底层的一些常用数据结构入手。掌握了它的底层结构,我们学习起来也能达到事半功倍的效果。今天研究class
的底层结构。
Class
我们在iOS底层原理探索—class的本质一文中简单介绍了class
的底层结构,今天我们继续深入研究一下class
底层结构的具体内容。
首先我们通过源码回顾一下class
的底层结构:
通过源码可以看出,
bits
经过
FAST_DATA_MASK
按位与运算之后,可以得到
class_rw_t
,我们在之前的文章中讲到
class_rw_t
中存储着
方法列表
、
属性列表
以及
协议列表
,那么我们来看一下
class_rw_t
的源码:
class_rw_t
在
class_rw_t
结构体源码中,
class_ro_t
是一个只读的结构体,里面包含类的初始内容。方法列表数组
method_array_t
、属性列表数组
property_array_t
、协议列表数组
protocol_array_t
这三个数组是二维数组,里面分别存储着
method_list_t
数组、
property_list_t
数组、
protocol_list_t
数组,这三个数组里面又分别存储了
method_t
结构体、
property_t
结构体、
protocol_t
结构体。
我们以
method_array_t
示意图为例总结以上内容:
class_ro_t
我们在iOS底层原理探索—class的本质中提到过class_ro_t
中也存储着方法列表、属性列表和协议列表,另外还有成员变量列表。
那么我们来看class_ro_t
源码:
通过源码,
class_ro_t
内部直接存储的是
method_list_t
、
protocol_list_t
、
property_list_t
类型的一维数组,数组里面分别存放的是类的初始信息,三个数组里面又分别存储了
method_t
、
protocol_t
、
property_t
结构体。注意
class_ro_t
是只读的,不允许修改里面的内容。
经过上面通过解读源码,class_rw_t
结构体可读可写,那么里面存储的三个方法列表数组method_array_t
、属性列表数组property_array_t
、协议列表数组protocol_array_t
二维数组可以动态的添加方法、属性、协议。例如我们创建分类的时候,当系统加载类、分类的时候,就是通过class_rw_t
将分类的信息添加到本类里面。
其实一开始类的方法,属性,成员变量属性协议等等都是存放在class_ro_t中的,当程序运行的时候,需要将分类中的列表跟类初始的列表合并在一起的时,就会将class_ro_t中的列表和分类中的列表合并起来存放在class_rw_t中,也就是说class_rw_t中有部分列表是从class_ro_t里面拿出来的。并且最终和分类的方法合并。可以通过源码提现这里一点。
而class_ro_t
中存储的是类的方法、属性、协议以及成员变量等内容,当程序运行时,要将分类的信息跟类初始的信息整合在一起,就会把class_ro_t
中的信息和分类中的信息合并起来存放在class_rw_t
中。
这一点我们可以从类初试化的源码中可以证明,我们在源码中找到realizeClass
,这个方法在类首次初试化的时候回调用,方法内会为类分配读写数据,并且返回类的真实结构。下面我们截取部分代码分析:
通过源码分析可知,类的初始信息本来其实是存储在
class_ro_t
中的,
class_ro_t
本来是指向
cls->data()
的。在运行过程中创建了
class_rw_t
,并将
cls->data
指向
class_rw_t
,同时将初始信息
class_ro_t
赋值给
class_rw_t
中的
class_ro_t
。最后在通过
setData(rw)
设置
data
。那么此时类中的信息就是
class_rw_t
,之后再去检查是否有分类,同时将分类的方法,属性,协议列表整合存储在
class_rw_t
中。
至此我们分析完了class_rw_t
的整体结构内容,知道了class_rw_t
内部存储了类和分类的信息,那么class_rw_t
是如何保存方法呢?我们继续研究
class_rw_t中是如何存储方法的
经过上文分析,我们知道我们知道class_rw_t
中的二维数组method_array_t
中通过数组method_list_t
存储着method_t
。那么method_t
究竟是什么呢?
method_t
method_t
是对方法、函数的封装,每一个方法对象就是一个method_t
。
可以看到
method_t
结构体中可以看到三个成员变量,我们分别解释一下这三个成员变量。
1、SEL
SEL
代表方法\函数名,一般叫做选择器
,底层结构跟char *
类似。可以把SEL
看做是方法名字符串。
SEL
可以通过@selector()
和sel_registerName()
获得:
SEL sel1 = @selector(test);
SEL sel2 = sel_registerName("test");
也可以通过sel_getName()
和NSStringFromSelector()
将SEL
转成字符串:
char *string = sel_getName(sel1);
NSString *string2 = NSStringFromSelector(sel2);
需要注意的是,不同类中相同名字的方法,所对应的方法选择器是相同的。SEL
仅仅代表方法的名字,并且不同类中相同的方法名的SEL
是全局唯一的。
2、types
types
包含了函数返回值,参数编码的字符串。通过字符串拼接的方式将返回值和参数拼接成一个字符串,来代表函数返回值及参数。
我们可以通过测试代码查看types
具体内容:
上图中可以看出
types
的值为
v16@0:8
,这个值代表什么呢?苹果为了能够清晰的使用字符串表示方法及其返回值,制定了一系列对应规则
@encode
指令,可以将具体的类型表示成字符串编码,下图简单展示,具体可查看 官方文档:
经过与上图对照,我们可以得出
types
的值
v16@0:8
分别代表什么:
- (void)test {
}
v 16 @ 0 : 8
void id SEL
// 16表示参数的占用空间大小,id后面跟的0表示从0位开始存储,id占8位空间。
// SEL后面的8表示从第8位开始存储,SEL同样占8位空间
我们知道任何方法都默认有两个参数的,id
类型的self
和SEL
类型的_cmd
,而上述通过对types
的分析也验证了这个说法。
下面我们为test
方法添加参数后再次查看types
的值:
同样通过对应上表,查看
types
的值具体含义:
- (int)testWithAge:(int)age Height:(float)height
{
return 0;
}
i 24 @ 0 : 8 i 16 f 20
int id SEL int float
// 参数的总占用空间为 8 + 8 + 4 + 4 = 24
// id 从第0位开始占据8位空间
// SEL 从第8位开始占据8位空间
// int 从第16位开始占据4位空间
// float 从第20位开始占据4位空间
3、IMP
IMP代表函数的具体实现,存储的内容是函数地址。也就是说当找到imp的时候就可以找到函数实现,进而对函数进行调用。
至此,我们弄清楚了class_rw_t
结构体和其内部的class_ro_t
结构体的内容和作用,在类的底层结构objc_class
结构体中还有cache_t
,那它的具体结构和作用又是什么呢?下面继续分析cache_t
cache_t
通过上面分析class_rw_t
结构体我们弄清楚了方法是如何存储在类对象中的,如果我们调用类中的方法时,每次都要去类内部的方法列表中去找的话,似乎不太现实。那么苹果就提供了方法缓存
这一技术,来提高方法调用效率。利用cache_t
来缓存调用过的方法,当再次调用的时候,直接从方法缓存列表中查找取出。如果没有找到,再从类的方法列表里面查找,找到后完成调用并且放在方法缓存中,方便后续调用。这样就调高了查找效率。
那么cache_t
是如何进行缓存的呢?我们从源码入手进行分析。首先先来到cache_t
的底层结构查看:
cache_t
结构体中保存了一个散列表
bucket_t
,并且记录着该散列表的长度以及已经缓存了多少方法。我们来到
bucket_t
内部:
bucket_t
中通过
key--value
的形式存储着一个
_key
和
_imp
,其中
key
就是
方法选择器SEL
,而
value
则是函数实现的内存地址
_imp
。
bucket_t
bucket_t
我们称之为散列表。
散列表(Hash table,也叫哈希表),是根据关键码值(Key
value
)直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
散列表的工作过程
苹果通过以下几个函数来完成在散列表中快速并且准确的找到对应的key以及函数实现:
1、cache_fill 及 cache_fill_nolock 函数
上述代码已经逐步做了注释,不做过多解释。当然源码中也有官方的注释,大家也可以看一下。
源码中
reallocate
函数负责分配散列表空间,下面来分析
reallocate
函数。
2、reallocate 函数
参数
newCapacity
就是
INIT_CACHE_SIZE
,
INIT_CACHE_SIZE
是个枚举值,也就是4。因此散列表最初创建的空间就是4个。
3、expand ()函数
当散列表的空间被占用超过3/4的时候,散列表会调用expand ()
函数进行扩展,我们来看一下expand ()
函数内散列表如何进行扩展的。
可以看到散列表进行扩容时会将容量增至之前的2倍。
现在弄清楚了如何创建散列表来缓存方法,那么散列表中如何快速的通过key
找到相应的bucket
呢?下面我们找到find
函数:
4、find函数
在
find
函数中通过函数
cache_hash (k, m)
用来通过
key
找到方法在散列表中存储的下标,此处的
key
就是
方法选择器SEL
。
cache_hash (k, m)
函数内部仅仅是进行了
key & mask
,将
方法选择器SEL
按位与运算,得到下标,即得到
方法选择器SEL
存储的位置。
在最开始我们分析
cache_t
结构体中讲到,
_mask
是散列表的长度减一。那么
cache_hash (k, m)
函数中
mask
参数的值是散列表的长度减一,那么任何数通过与
_mask
进行按位与运算之后获得的值都会小于等于
_mask
,因此不会出现数组溢出的情况。
我们看到find
函数中有一个do-while
循环,就是在散列表中循环查找方法选择器SEL
,如果找到的方法选择器SEL
跟想要查找的方法的选择器SEL
匹配了就会返回对应下标。当查找失败后,就会进入cache_next
函数中,修改查找下标,再进行一次循环查找。
总结
分析至此我们可以得出总结:
当第一次使用方法时,消息机制通过isa
找到方法之后,会对方法将方法选择器SEL
作为key
,函数实现地址IMP
作为value
缓存在cache
的_buckets
中。当第一次存储的时候,会创建具有4个空间的散列表,并将_mask
的值置为散列表的长度减一,之后通过SEL & mask
计算出方法存储的下标值,并将方法存储在散列表中。
当散列表中存储的方法占据散列表长度超过3/4的时候,散列表会进行扩容操作,将创建一个新的散列表并且空间扩容至原来空间的两倍,并重置_mask
的值,最后释放旧的散列表,此时再有方法要进行缓存的话,就需要重新通过SEL & mask
计算出下标值之后在按照下标进行存储了。
如果一个类中方法很多,其中很可能会出现多个方法的SEL & mask
得到的值为同一个下标值,那么会调用cache_next
函数往下标值-1位去进行存储,如果下标值-1位空间中有存储方法并且SEL
和要存储的SEL
不同,那么再到前面一位进行比较,直到找到一位空间没有存储方法或者SEL
和要存储的SEL
相同为止,如果找到下标0的位置就会到下标为_mask
的空间也就是最大空间处进行比较。
当要查找方法时,并不需要遍历散列表,同样通过SEL & mask
计算出下标值,直接去下标值的空间取值即可。同上,如果下标值中存储的SEL
与要查找的SEL
不相同,就去前面一位查找。这样虽然占用了少量空间,但是大大节省了时间,也就是说其实苹果在方法缓存技术上是用空间换时间。
下面为大家用示意图简单概括一下整体过程:
当调用
test2
方法后,需要把
test2
方法也缓存入散列表。首先跟
_mask
按位与运算得到5,那么就去散列表中查看下标5的位置,发现已经缓存了
test1
方法,并且
test1
方法和
test2
方法两个方法选择器不同,那么就会将下标-1,到下标4的位置查看,发现此处是
null
,没有缓存方法,即
_buckets[i].key() == 0
,那么就把
test2
方法缓存在下标4的位置。
而在散列表中查找是否缓存了某个方法时,只需要将方法选择器SEL
跟_mask
按位与运算,得到下标直接去_buckets
中对应下标获取即可。这样效率就得到了很大提升。
更多技术知识请关注公众号
iOS进阶