李峰峰博客

Runtime 之 method

2020-04-26

1、method 与 Class

先看下 Class 的结构:

class_rw_t 里面的 methodspropertiesprotocols 是二维数组,是可读可写的,包含了类的初始内容、分类的内容:

也就是说,这里的 methods 既包含分类中的方法,也包含了类的初始方法。实际上,我们在使用 category 为类增加方法时,会将 category 中的方法插到初始的方法前面。

class_ro_t 里面的 baseMethodListbaseProtocolsivarsbaseProperties 是一维数组,是只读的,包含了类的初始内容:

也就是说,class 中 methodmethod_t 结构体的形式进行存储的,method_t 结构体就是对方法/函数的一个封装。

2、method_t

method_t 是对方法/函数的封装:

1
2
3
4
5
struct method_t {
SEL name; // 函数名
const char *types; // 编码(返回值类型、参数类型)
IMP imp; // 指向函数的指针(函数地址)
};

(1) IMP

IMP 是指向函数的指针,即 imp 存储了函数的地址,是函数的具体实现:

1
typedef id _Nullable (*IMP)(id _Nonnull, SEL _Nonnull, ...);
  • 前面的 id 代表返回值。
  • 第一个参数指向 self(它代表当前类实例的地址,如果是类则指向的是它的元类),作为消息的接受者;
  • 第二个参数代表方法的 SEL
  • … 代表可选参数;

(2) SEL

SEL 一般称做选择器或选择子,代表方法\函数名,底层结构跟 char * 类似,是方法在 Runtime 期间的标识符。

1
typedef struct objc_selector *SEL
  • 可以通过 @selector() sel_registerName() 获得
  • 可以通过 sel_getName()NSStringFromSelector() 转成字符串
  • 在类加载的时候,编译器会生成与方法相对应的 SEL,并注册到 Objective-C 的 Runtime 运行系统。不论两个类是否存在依存关系,只要他们拥有相同的方法名,那么他们的 SEL 都是相同的。

(3) types

types 是个 char 指针,其实存储着方法的参数类型、返回值类型,即是 Type Encoding 编码。

iOS 中提供了一个叫做 @encode 的指令,可以将具体的类型表示成字符串编码,例如:

1
NSLog(@"%s", @encode(int)); // 打印 i

types 对照表:

例如,对于以下方法:

1
- (int)test:(int)age height:(float)height;

其 types 为:
i24@0:8i16f20 (开发时也可以简写为 i@:if)

  • i 代表返回值类型为 int
  • @ 代表 id 类型参数(self
  • : 代表 SEL 类型参数(_cmd
  • i 代表 int 类型参数(age
  • f 代表 float 类型参数(height

对于数字:

  • 24 代表所有参数占用的字节数
  • idSEL 为指针类型,各占 8 字节;intfloat 均占 4 字节。总计 8+8+4+4=24 字节)
  • 0 代表 @self)从第 0 个字节开始
  • 8 代表 :_cmd)从第 8 个字节开始
  • 16 代表 iage)从第 16 字节开始
  • 20 代表 fheight)从第 20 字节开始

2、方法缓存

(1) cache_t 的结构

再来来看下 class 结构:

每一个类都维护了一个 cache,当我们在调用一个方法时,runtime 会先在 cache 中查找对应方法,没有找到对应方法,再从 methods 中寻找。并且每次调用方法的时候,都会将方法存到 cache 中。

接下来看下 cache_t 的数据结构:

Class 内部结构中有个方法缓存(cache_t),用散列表(哈希表)来缓存曾经调用过的方法,可以提高方法的查找速度。

cache_t 结构体里面有三个元素:

  • _buckets 散列表,是一个数组,数组里面的每一个元素就是一个 bucket_tbucket_t 结构:
    • SEL 作为 key
    • 函数的实现 IMPvalue
  • _mask 散列表的长度
    • _mask 它代表的是散列表的长度 -1,最初分配的值是 4,方法 SEL & _mask 得到的值,就是对应 bucket_t 在散列表中的索引。
  • _occupied 已经缓存的方法数量

(2) cache_t 的存入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
void cache_t::insert(Class cls, SEL sel, IMP imp, id receiver)
{
// 插入新的方法缓存,数量+1
mask_t newOccupied = occupied() + 1;
unsigned oldCapacity = capacity(), capacity = oldCapacity;
if (slowpath(isConstantEmptyCache())) {
//如果还没有缓存过方法
// INIT_CACHE_SIZE 这是一个容量为 4 的宏,最初分配的容量就是 4
if (!capacity) capacity = INIT_CACHE_SIZE;
// 创建-分配内存
reallocate(oldCapacity, capacity, /* freeOld */false);
}
else if (fastpath(newOccupied <= capacity / 4 * 3)) {
// 保证缓存表存的方法数小于等于 容量的3/4
}
else {
//超过容量的3/4,容量翻倍
capacity = capacity ? capacity * 2 : INIT_CACHE_SIZE;
// 不能超过最大值,最大值是1<<16
if (capacity > MAX_CACHE_SIZE) {
capacity = MAX_CACHE_SIZE;
}
// 超过容量后,将之前缓存的方法全部清空 "true"
reallocate(oldCapacity, capacity, true);
}

bucket_t *b = buckets();
mask_t m = capacity - 1;
mask_t begin = cache_hash(sel, m);
mask_t i = begin; // 通过 sel & mask 计算出sel该存放的位置 "i"
// 如果计算出来的值没有缓存方法,则直接插入,保存;
// 如果已经有方法插入了,在__arm64__架构 i--(其他架构i++),即,如果被占用,往上走一格,还被占用继续往上走,因为规则限定了3/4,所以肯定能找到没有保存方法的位置.
do {
if (fastpath(b[i].sel() == 0)) {
incrementOccupied();
// 保存方法
b[i].set<Atomic, Encoded>(sel, imp, cls);
return;
}
if (b[i].sel() == sel) {
// 这种情况是其他线程,已经把方法添加到这里了,那就直接退出循环
return;
}

} while (fastpath((i = cache_next(i, m)) != begin)); // "i"不等于初始位置 i--,继续循环.

cache_t::bad_cache(receiver, (SEL)sel, cls);
}

总结:

  • 先看缓存中是否已经存在了该方法,如果已经存在,直接 return 掉,不再缓存。

  • 如果当前 cache 还没被初始化,则分配一个大小为 4 的数组,并设置 _mask 为 3。

  • 如果存入缓存后的大小小于当前大小的 3/4,则当前缓存大小还可以使用,无需扩容。

  • 否则缓存太满,需要扩容,扩容为原来大小的 2 倍。放弃旧的缓存,已缓存的数据全部清空,新扩容的缓存为空,并将 _occupied 重新初始化为 0。

  • 方法的存储并不是按照数组一样从上到下存储,而是通过 SEL & _mask (例如:@selector(myMethod:) & _mask)的值作为索引来存储的,所以难免会存在内存利用率低,但是加快了方法查找的速度,即:空间换时间。

  • 在 arm64 架构下,如果 SEL & _mask 结果为 i,若索引 i 位置已经有 bucket_t 值了,就取出 bucket_t 中的 keySEL 进行比较,如果不相同,则继续往 i -1 继续寻找,直到找到未被占用的位置,然后将对应 bucket 存进去。如果找到 i = 0 仍然未找到合适位置,则从 i = _mask (即最后一个索引)开始继续往上寻找。

  • 方法缓存是先于 isa 的方法查找,就是说,缓存中找不到,再到自己的方法列表中查找,找到之后也会缓存到 cache_t 中,如果是父类的方法,也是会缓存到自己的表当中的。