iOS底层原理探索— Runtime之class的本质

探索底层原理,积累从点滴做起。大家好,我是Mars。

往期回顾

iOS底层原理探索—OC对象的本质
iOS底层原理探索—class的本质
iOS底层原理探索—KVO的本质
iOS底层原理探索— KVC的本质
iOS底层原理探索— Category的本质(一)
iOS底层原理探索— Category的本质(二)
iOS底层原理探索— 关联对象的本质
iOS底层原理探索— block的本质(一)
iOS底层原理探索— block的本质(二)
iOS底层原理探索— Runtime之isa的本质

今天继续带领大家探索iOS之Runtime的本质。

前言

OC是一门动态性比较强的编程语言,它的动态性是基于RuntimeAPIRuntime在我们的实际开发中占据着重要的地位,在面试过程中也经常遇到Runtime相关的面试题,我们在之前几期的探索分析时也经常会到Runtime的底层源码中查看相关实现。Runtime对于iOS开发者的重要性不言而喻,想要学习和掌握Runtime的相关技术,就要从Runtime底层的一些常用数据结构入手。掌握了它的底层结构,我们学习起来也能达到事半功倍的效果。今天研究class的底层结构。

Class

我们在iOS底层原理探索—class的本质一文中简单介绍了class的底层结构,今天我们继续深入研究一下class底层结构的具体内容。

首先我们通过源码回顾一下class的底层结构:

iOS底层原理探索— Runtime之class的本质_第1张图片
objc_class底层结构.png

bits按位与操作.png

通过源码可以看出, bits经过 FAST_DATA_MASK按位与运算之后,可以得到 class_rw_t,我们在之前的文章中讲到 class_rw_t中存储着 方法列表属性列表以及 协议列表,那么我们来看一下 class_rw_t的源码:

class_rw_t

iOS底层原理探索— Runtime之class的本质_第2张图片
class_rw_t部分源码.png

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结构体。
iOS底层原理探索— Runtime之class的本质_第3张图片
三个二维数组源码.png

我们以 method_array_t示意图为例总结以上内容:
iOS底层原理探索— Runtime之class的本质_第4张图片
method_array_t结构示意图.png

class_ro_t

我们在iOS底层原理探索—class的本质中提到过class_ro_t中也存储着方法列表、属性列表和协议列表,另外还有成员变量列表。

那么我们来看class_ro_t源码:

iOS底层原理探索— Runtime之class的本质_第5张图片
class_ro_t源码.png

通过源码, class_ro_t内部直接存储的是 method_list_tprotocol_list_tproperty_list_t类型的一维数组,数组里面分别存放的是类的初始信息,三个数组里面又分别存储了 method_tprotocol_tproperty_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,这个方法在类首次初试化的时候回调用,方法内会为类分配读写数据,并且返回类的真实结构。下面我们截取部分代码分析:

iOS底层原理探索— Runtime之class的本质_第6张图片
realizeClass方法.png

通过源码分析可知,类的初始信息本来其实是存储在 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

iOS底层原理探索— Runtime之class的本质_第7张图片
method_t结构体.png

可以看到 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具体内容:

iOS底层原理探索— Runtime之class的本质_第8张图片
types.png

上图中可以看出 types的值为 v16@0:8,这个值代表什么呢?苹果为了能够清晰的使用字符串表示方法及其返回值,制定了一系列对应规则 @encode指令,可以将具体的类型表示成字符串编码,下图简单展示,具体可查看 官方文档:
iOS底层原理探索— Runtime之class的本质_第9张图片
encode.png

经过与上图对照,我们可以得出 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类型的selfSEL类型的_cmd,而上述通过对types的分析也验证了这个说法。

下面我们为test方法添加参数后再次查看types的值:

iOS底层原理探索— Runtime之class的本质_第10张图片
带参数的types的值.png

同样通过对应上表,查看 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的底层结构查看:

iOS底层原理探索— Runtime之class的本质_第11张图片
cache_t底层结构.png

cache_t结构体中保存了一个散列表 bucket_t,并且记录着该散列表的长度以及已经缓存了多少方法。我们来到 bucket_t内部:
iOS底层原理探索— Runtime之class的本质_第12张图片
bucket_t底层结构.png

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 函数

iOS底层原理探索— Runtime之class的本质_第13张图片
cache_fill函数.png

iOS底层原理探索— Runtime之class的本质_第14张图片
cache_fill_nolock函数.png

上述代码已经逐步做了注释,不做过多解释。当然源码中也有官方的注释,大家也可以看一下。
源码中 reallocate函数负责分配散列表空间,下面来分析 reallocate函数。

2、reallocate 函数

iOS底层原理探索— Runtime之class的本质_第15张图片
reallocate函数.png

参数 newCapacity就是 INIT_CACHE_SIZEINIT_CACHE_SIZE是个枚举值,也就是4。因此散列表最初创建的空间就是4个。
iOS底层原理探索— Runtime之class的本质_第16张图片
INIT_CACHE_SIZE.png

3、expand ()函数

当散列表的空间被占用超过3/4的时候,散列表会调用expand ()函数进行扩展,我们来看一下expand ()函数内散列表如何进行扩展的。

iOS底层原理探索— Runtime之class的本质_第17张图片
expand函数.png

可以看到散列表进行扩容时会将容量增至之前的2倍。

现在弄清楚了如何创建散列表来缓存方法,那么散列表中如何快速的通过key找到相应的bucket呢?下面我们找到find函数:

4、find函数

iOS底层原理探索— Runtime之class的本质_第18张图片
find函数.png

find函数中通过函数 cache_hash (k, m)用来通过 key找到方法在散列表中存储的下标,此处的 key就是 方法选择器SELcache_hash (k, m)函数内部仅仅是进行了 key & mask,将 方法选择器SEL按位与运算,得到下标,即得到 方法选择器SEL存储的位置。
cache_hash (k, m)函数.png

在最开始我们分析 cache_t结构体中讲到, _mask是散列表的长度减一。那么 cache_hash (k, m)函数中 mask参数的值是散列表的长度减一,那么任何数通过与 _mask进行按位与运算之后获得的值都会小于等于 _mask,因此不会出现数组溢出的情况。

我们看到find函数中有一个do-while循环,就是在散列表中循环查找方法选择器SEL,如果找到的方法选择器SEL跟想要查找的方法的选择器SEL匹配了就会返回对应下标。当查找失败后,就会进入cache_next函数中,修改查找下标,再进行一次循环查找。

cache_next函数.png

总结

分析至此我们可以得出总结:

当第一次使用方法时,消息机制通过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不相同,就去前面一位查找。这样虽然占用了少量空间,但是大大节省了时间,也就是说其实苹果在方法缓存技术上是用空间换时间。

下面为大家用示意图简单概括一下整体过程:

iOS底层原理探索— Runtime之class的本质_第19张图片
散列表缓存方法1.png

当调用 test2方法后,需要把 test2方法也缓存入散列表。首先跟 _mask按位与运算得到5,那么就去散列表中查看下标5的位置,发现已经缓存了 test1方法,并且 test1方法和 test2方法两个方法选择器不同,那么就会将下标-1,到下标4的位置查看,发现此处是 null,没有缓存方法,即 _buckets[i].key() == 0,那么就把 test2方法缓存在下标4的位置。
iOS底层原理探索— Runtime之class的本质_第20张图片
散列表缓存方法 2.png

而在散列表中查找是否缓存了某个方法时,只需要将方法选择器SEL_mask按位与运算,得到下标直接去_buckets中对应下标获取即可。这样效率就得到了很大提升。

更多技术知识请关注公众号
iOS进阶


iOS底层原理探索— Runtime之class的本质_第21张图片
iOS进阶.jpg

你可能感兴趣的:(iOS底层原理探索— Runtime之class的本质)