前言
OC是一种动态语言,其动态性是由Runtime API来支撑的,Runtime API提供的接口都是C语言的 ,源码由C、C++、汇编语言编写,想深入学习Runtime,需要先了解它底层的一些数据结构,例如isa指针
一、isa指针
- 每一个继承自NSObject的对象都有一个
isa指针,通过isa指针我们可以拿到类/元类的内存地址
- 每一个继承自NSObject的对象都有一个
- 在
arm64架构之前,isa就是一个普通的指针,直接指向类对象或者元类对象,isa直接存储着类对象、元类对象的内存地址
- 在
- 从
arm64架构开始,对isa指针做了优化,变成了一个union共用体,使用了位域来存储更多的信息,isa指针内部结构如下所示,类对象/元类对象的地址存储在shiftcls位,shiftcls位占了33位,由于是共用体,所以需要对isa进行一次&ISA_MASK的位运算,才能将类对象的地址取出来 (为何进行一次按位与&的运算就能取出来shiftcls位呢???,别着急,下面会有讲到)
优化后的isa.png
- 从
- 所谓共用体,就是指多个成员共用同一段内存,跟结构体对比一下,就容易理解了:结构体的各个成员会占用不同的内存,互相之间没有影响;而共用体的所有成员占用同一段内存,修改一个成员会影响其余所有成员
- 为何要进行一次
&ISA_MASK的位运算才能将类对象的内存地址拿出来呢?看看下面的计算过程就明白了,按位与&的规则是:相同位的两个数字都为1,则为1;若有一个不为1,则为0
- 为何要进行一次
想把中间四位取出来,应该怎么取呢?
1010 0101
& 0011 1100
----------------------
0010 0100
只需要进行一次 &00111100 位运算,就可以将中间四位取出来了
这个方法用与取isa的shiftcls位的原理是一样的,只需要 isa & ISA_MASK就可以将shiftcls位的33位给取出来了
-
isa指针占8个字节,一共有64位,每一位都有其特殊含义,如下图所示:
image.png
-
二、Class的结构
- 我们知道
isa指针是指向类或者元类的,而类和元类的底层数据结构就是objc_class结构体,objc_class的内部结构如下所示:
objc_class结构体
- 我们知道
-
class_rw_t里面的methods、properties、protocols是二维数组,是可读可写的,包含了类的初始内容、分类的内容
class_rw_t结构体
-
-
class_ro_t里面的baseMethodList、baseProtocols、ivars、baseProperties是一维数组,是只读的,包含了类的初始内容,如下图所示:
class_ro_t结构体
-
- 上述的方法列表中,都用到了
method_t结构体,method_t结构体是对方法的封装,其内存布局如下所示,其中IMP代表函数的具体实现;SEL代表方法名,一般叫做选择器,底层结构跟char *类似,不同类中相同名字的方法,所对应的方法选择器是相同的,可以通过@selector和sel_registerName()获得;types包含了函数返回值、参数编码的字符串,iOS提供了一个叫做@encode的指令,可以将具体类型表示成字符串编码
method_t结构体.png
OC类型编码.png
- 上述的方法列表中,都用到了
三、方法缓存
-
Class内部结构中有个方法缓存cache_t,用散列表来缓存曾经调用过的方法,提高了方法的查找速度 ,cache_t的内部结构如下所示,其中,_buckets是bucket_t结构体的数组,bucket_t是用来存放方法的SEL内存地址和IMP;_mask的大小是数组大小 - 1;_occupied是当前已缓存的方法数,即数组中已使用了多少位置
cache_t结构体.png
-
- 散列表,也叫哈希表,利用了数组支持下标随机访问的特性,通过散列函数把元素的键值key映射为数组的下标,然后把数据存储在下标对应的位置。按照键值key查找数据时,只需要用同样的散列函数,就可以把key转化为数组下标,进而从数组下标的位置取到数据,时间复杂度为O(1),如下图所示:

-
方法缓存cache_t就是用散列表来缓存曾经调用过的方法的,使用的散列函数是@selector(方法名) & _mask的位运算,其中@selector(方法名)是方法选择器,_mask是散列表长度 - 1,将两者进行一次按位与的位运算,是为了快速算出来下标的同时,保证下标不越界
-
-
- 方法缓存到 散列表 的整个存储流程是这样的:
-
(1). 当某个方法被调用时,就会先看方法缓存
cache_t的buckets中有没有此方法:缓存中有此方法的话,就直接取出来地址然后调用,不再走方法查找流程;
缓存中没有的话,就会走方法查找流程,找到方法的
IMP,调用此方法的同时,将方法地址缓存下来;
-
(2). 方法缓存的时候,会先看
cache_t中的buckets有没有初始化:如果
cache_t中的buckets已经初始化了,就会通过@selector(方法名) & _mask的位运算,计算出数组下标,然后将Key和IMP包装成bucket_t结构体,插入到buckets数组的对应的下标的位置;如果
cache_t中的buckets没有初始化,就会给cache_t中的buckets分配大小为4的数组,并设置_mask为3,然后通过@selector(方法名) & _mask的位运算,计算出数组下标再插入
-
(3). 插入到
buckets数组的对应的下标的位置的时候,会看此位置有没有被占用:如果下标对应的数组位置是空的,就直接将包装好的
bucket_t结构体插入进去如果下标对应的数组位置有值了,就将
数组下标 - 1,看看这个新位置是不是空的,如果是空的就插入进去;如果不是空的,就继续将数组下标 - 1,然后比较插入,直到数组下标 < 0,这个时候就将数组下标设置为_mask,继续整个插入过程 (_mask上面说了是数组的长度 - 1,所以不会有越界的风险)
(4). 如果
buckets数组满了,就会进行扩容,扩容为原来大小的2倍 ,并且会将原来缓存的方法清空
-
- 在方法缓存的 散列表中 查找某个方法的流程是这样的:
(1). 调用某个对象的方法时,会向这个对象发送一个
SEL消息,假设这个方法是:@selector(test)(2). Runtime会去
objc_class结构体的cache方法缓存中找,会拿@selector(test)作为Key进行一次散列函数计算,散列函数是@selector(方法名) & _mask的位运算,经过散列函数计算出数组的下标,假设此时算出来的下标 == 2,如下图所示-
(3).就会
buckets数组的下标为2的位置取出来Key,与@selector(test)进行比较:如果Key相同,说明找对了,就会拿这个Key的IMP去调用;
如果Key不相同,就将
下标 - 1,继续寻找相同的Key,直到数组下标 < 0,这个时候就将数组下标设置为_mask,继续整个查找过程 (_mask上面说了是数组的长度 - 1,所以不会有越界的风险)

- 方法缓存的散列表,是通过开放寻址法来解决散列冲突的,所谓散列冲突,就是key不同的时候,散列值hash(key)却意外的相同了,方法缓存的散列冲突就是指,两个不同的Key,经过
散列函数hash(key):@selector(方法名) & _mask算出来了同一个数组下标,这时候就出现了散列冲突,就将数组下标 - 1,依次往后查找。利用散列表缓存方法,虽然会浪费一些存储空间,但是却大大提升了方法查找速度,这也是空间换时间设计思想的具体应用
- 方法缓存的散列表,是通过开放寻址法来解决散列冲突的,所谓散列冲突,就是key不同的时候,散列值hash(key)却意外的相同了,方法缓存的散列冲突就是指,两个不同的Key,经过
四、OC消息发送
OC中的方法调用,其实底层转换成了C语言objc_msgSend函数的调用,objc_msgSend的执行分为三大阶段:消息发送、动态方法解析、消息转发
- 消息发送

- 动态方法解析


- 消息转发


五、面试题
-
- 讲一下OC的消息机制
答 :OC的方法调用其实都转成了objc_msgSend函数的调用,给
receiver方法调用者发送了一条@selector(方法名)消息,objc_msgSend函数底层有三大阶段:消息发送、动态方法解析、消息转发 -
- OC的消息转发流程是怎么样的?
先用调用
forwardingTargetForSelecotor:获取另一个消息接受者,如果获取到了就给这个新的消息接受者,发送消息;如果获取不到新的消息接受者,就进入调用
methodSignatureForSelector:获取方法签名,如果获取到了方法签名,就调用forwardInvocation:方法,在这个方法中可以自定义任何逻辑如果拿不到方法签名,就调用
doesNotRecognizaSelector:方法,抛出异常
-
- RunTime有哪些具体应用?
利用关联对象给分类增加属性
遍历类的所有成员变量,实现字典转模型、自动归档解档
交换方法实现
利用消息转发机制,避免方法找不到而产生崩溃







