前言
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_MASK的位运算
才能将类对象的内存地址拿出来呢?看看下面的计算过程就明白了,按位与&
的规则是:相同位的两个数字都为1,则为1;若有一个不为1,则为0
- 为何要进行一次
想把中间四位取出来,应该怎么取呢?
1010 0101
& 0011 1100
----------------------
0010 0100
只需要进行一次 &00111100 位运算,就可以将中间四位取出来了
这个方法用与取isa的shiftcls位的原理是一样的,只需要 isa & ISA_MASK就可以将shiftcls位的33位给取出来了
-
isa指针
占8个字节,一共有64位,每一位都有其特殊含义,如下图所示:
-
二、Class的结构
- 我们知道
isa指针
是指向类或者元类的,而类和元类的底层数据结构就是objc_class
结构体,objc_class
的内部结构如下所示:
- 我们知道
-
class_rw_t
里面的methods、properties、protocols
是二维数组,是可读可写的,包含了类的初始内容、分类的内容
-
-
class_ro_t
里面的baseMethodList、baseProtocols、ivars、baseProperties
是一维数组,是只读的,包含了类的初始内容,如下图所示:
-
- 上述的方法列表中,都用到了
method_t
结构体,method_t
结构体是对方法的封装,其内存布局如下所示,其中IMP
代表函数的具体实现;SEL
代表方法名,一般叫做选择器,底层结构跟char *类似,不同类中相同名字的方法,所对应的方法选择器是相同的,可以通过@selector和sel_registerName()
获得;types
包含了函数返回值、参数编码的字符串,iOS提供了一个叫做@encode
的指令,可以将具体类型表示成字符串编码
- 上述的方法列表中,都用到了
三、方法缓存
-
Class
内部结构中有个方法缓存cache_t
,用散列表来缓存曾经调用过的方法,提高了方法的查找速度 ,cache_t
的内部结构如下所示,其中,_buckets
是bucket_t
结构体的数组,bucket_t
是用来存放方法的SEL
内存地址和IMP
;_mask
的大小是数组大小 - 1;_occupied
是当前已缓存的方法数,即数组中已使用了多少位置
-
- 散列表,也叫哈希表,利用了数组支持下标随机访问的特性,通过散列函数把元素的键值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有哪些具体应用?
利用关联对象给分类增加属性
遍历类的所有成员变量,实现字典转模型、自动归档解档
交换方法实现
利用消息转发机制,避免方法找不到而产生崩溃