今天研究一下 OC
中方法的底层实现原理,在研究method
之前,我们先搞清楚Class
的底层数据结构.
先用一张图说明类的底层数据结构,然后我们在从runtime
源码中验证:
我们在runtime
源码中搜索struct objc_class {
知道类的底层数据结构主要如下:
struct objc_class {
Class ISA;
Class superclass;
cache_t cache; // 方法缓存
class_data_bits_t bits; // 获取具体的类信息
}
class_data_bits_t bits
中存储具体的类的信息,在class_data_bits_t
结构体内部仔细查找,发现有这么一句代码:
class_rw_t* data() {
return (class_rw_t *)(bits & FAST_DATA_MASK);
}
class_rw_t
是可读可写的表,里面存储着类和分类的信息:
struct class_rw_t {
uint32_t flags;
uint32_t version;
const class_ro_t *ro;//只读表,存储类原始信息
method_array_t methods;//方法列表
property_array_t properties;//属性列表
protocol_array_t protocols;//协议列表
Class firstSubclass;
Class nextSiblingClass;
char *demangledName;
}
class_ro_t
是只读表,里面存储着类的原始信息:
struct class_ro_t {
uint32_t flags;
uint32_t instanceStart;
uint32_t instanceSize;//instance对象占用的内存空间,class_getInstanceSize
#ifdef __LP64__
uint32_t reserved;
#endif
const uint8_t * ivarLayout;
const char * name;//类名
method_list_t * baseMethodList;
protocol_list_t * baseProtocols;
const ivar_list_t * ivars;//成员变量
const uint8_t * weakIvarLayout;
property_list_t *baseProperties;
method_list_t *baseMethods() const {
return baseMethodList;
}
};
实际上,一开始的时候,类中是没有class_rw_t
的,是runtime
后来创建的,这一点我们也可以从源码中找到:
现在结合
rutime
源码和截图,我们总结一下class
底层结构关系:
- 1:
Class
底层结构体主要有4个成员变量:isa , superClass , catche , bits
. - 2:
catche
中存储的调用过的方法的缓存,这个我们下面会讲. - 3:
bits & FAST_DATA_MASK
会得到一个可读可写的数据表:class_rw_t
,class_rw_t
用来存储类原始信息和分类附加的信息.需要注意的是,class_rw_t
这个表一开始是不存在的,后来需要的时候才创建的. - 4:
class_ro_t
是只可读的数据表,它里面存储着类原始的信息
OC
方法的调用顺序是如果是调用实例方法,就通过实例对象的isa
指针找到类对象,从类对象的方法列表中查找,如果如果没找到,在通过superClass
从父类的方法列表中查找,这样一层一层往上找;如果是调用类方法,就通过类对象的isa
找到元类,从元类的方法列表中查找,如果没找到再打通过superClass
到元类的父类中查找...
但是我们想想,如果一个方法调用的很频繁,难道每次都要通过这种方式一遍遍查找吗?显然这种方式是低效的,runtime
采用了一种更高效的方式来处理这种情况:如果方法第一次被调用后,会缓存到 cache 中,下次再调用的时候直接从 cache 中查找.
cache
的底层结构如下:
bucket_t
的底层结构如下:buckets
就是一个数组,里面存放着一个一个的bucket_t
:那么
runtime
是如何从cache
中查找方法的呢?难道也是遍历buckets
数组吗?肯定不是,遍历数组的那不就跟没优化一样吗?cache
的工作原理是:采用散列表的方式把方法插入到buckets
时,会用 SEL & _mask
得到一个索引值
,直接把bucket_t
插入到索引值
所在的位置.这样的话就不用每次一个个遍历去查找方法,效率很高.但是这样会有个问题:
SEL & _mask 得到索引值并不是按顺序的,他是无序的.比如说:如果 SEL & _mask = 20,那么前面 19 个内存单元就要置为 null 了,这就是 散列表的弊端,牺牲空间换时间.
这种方式虽然效率大大提升了,但是会有个弊端:如果两个
SEL
按位与 _mask
得到的索引相同怎么办?这种情况是很可能发生的.我们从runtime
源代码中看看是怎么处理这种情况的.在
cache_t
结构体中有一个struct bucket_t * find(cache_key_t key, id receiver)
方法,这个方法里面就是从buckets
查找方法的逻辑:继续进入
chche_next()
:从上图可以看到,如果索引相等,从
buckets
中找到bucket_t
,然后取出bucket_t
中的key
和传入的key
判断,如果两个key
不相等,在arm64
环境下,会先把i - 1
后再&_mask
得到一个新的索引,继续查找,直到找到两个key
相等位置.现在我们已经知道了
Class
的底层数据机构以及runtime
是如何存储和查找方法的.下面我们将研究一下method_t
:-
SEL
: 方法名,函数名,一般叫做选择器,底层结构跟char *
类似
· 可以通过@selector
和sel_registerName()
获得.
· 可以通过sel_getName()
和NSStringFromSelector()
转成字符串.
· 不同类中相同名字的方法,所对应的方法选择器是相同的. IMP
: 函数的具体实现-
types
: 包含了函数的返回值类型,参数类型编码的字符串
例如我们随便声明一个函数- (void)test;
,他的types
就是v16@0:8
.代表的意思是:
types 解释图
有人可能会觉得奇怪,- (void)test
方法并没有参数呀,为什么types
会多出两个参数?
实际上,每一个OC
方法都会默认有两个参数,比如说- (void)test
的完整形式就是:- (void)test:(id self SEL sel)
,这也就是为什么我们能在每个方法中调用self
,其实是方法参数.至于为什么void
用v
表示,id
用@
,这都是苹果规定的,在苹果官方文档中有对照表:
对照表
另外iOS
中还提供了一个@encode()
指令,可以将具体类型转换成字符串编码.
总结:
- OC 方法在第一次调用后,会被添加到
cache_t
缓存中,下次调用时直接从缓存中查找. -
cache_t
中有三个成员变量buckets
,_mask
,_occupied
:
buckets
是一个数组,存放着一个个的bucket_t
.
_mask
是buckets
数组的数量减 1,外部传入的 SEL & _mask
得到buckets
数组中的索引(下标).
_occupied
:已经缓存的方法. -
bucket_t
有两个成员key
,imp
key
就是SEL
;imp
就是方法的实现地址. -
OC 方法的底层是
method_t 结构体
,主要有三个成员:
name
:函数名称;
types
:编码,(函数返回值类型和参数类型);
imp
:函数地址
验证:
上面讲的都是从源码中推测出来的理论,实际上是不是这样呢?我们自己敲代码验证一番.
我们创建3个类Son , Mother , Person
,他们之间的继承关系是:Son : Mother : Person
,这3个类中都有一个- (void)personTest;
方法.
_buckets
扩容:
现在我们更改一下代码,创建一个Son
的实例对象son
,分别调用Son , Mother , Person
的test
方法:
我们过掉断点看看会发生什么:
从结果中我们可以看到
_mask
的数量从4变成了8,并且_buckets
中之前缓存的方法也没有了,只缓存了一个方法personTest
.这是由于
_buckets
的扩容机制造成的.我们在objc-cache.mm
中查找void cache_t::expand()
方法:我们在进入
reallocate
方法:OK,通过上面两张图我们知道了buckets
是如何扩展容量的:如果 buckets 的容量不够用了,就直接用旧容量 乘以 2 ,重新分配内存空间.并且把旧的缓存方法都清除.