Runtime(三)方法缓存

在了解类的基本结构之后,本文开始了解探讨iOS 中的消息发送,即消息调用。

首先开始讨论的是——在真正消息调用之前,我们会去方法缓存里面寻找真实的函数地址,iOS提供的缓存机制用于提高效率。

For speed, objc_msgSend does not acquire any locks when it reads method caches. Instead, all cache changes are performed so that any objc_msgSend running concurrently with the cache mutator will not crash or hang or get an incorrect result from the cache.

一、方法的相关结构

1.1 回顾Class结构

不管讨论,什么离不开最基本的类结构,现在我们又回到Class结构,不过这次需要关注的是方法

1.1.1 Class结构

image-20181213214129678

以上是在运行时使用到类的最终结果,那么在编译期,其实在之前的文章中也有提到,类结构略有不同,主要在于bits里指向的是class_ro_t,而非class_rw_t

1.1.2 class_ro_t结构

image-20181213214152399

上述的结果,呈现的是定义在类里面的方法,就是我们写在代码里硬编码方法,而不是通过以下方式添加的方法:

  • 分类方法;
  • 通过运行时添加的方法;

程序在运行时,会重新组织bits里结构的内容,获取bits.data(),即class_rw_t结构,该结构是运行时的结构。

1.1.3 class_rw_t

class_rw_t在程序开始运行后,会加载分类方法,会将分类方法重新组织成下面的结构:二维数组

image-20181213214238046

1.2 method_t 方法结构

根据上面的class_rw_t结构,我们可以清晰的观察到,在底层中方法的结构体是method_t,即一个方法对应一个method_t

下面是method_t结构体的组成。

image-20181213223155534

针对method_t的成员变量,上面阐述的很清楚。

  • 该结构包含了函数指针,指向具体实现。
  • 通过types来声明该方法的返回值及参数,用于底层调用实现的校验。
  • SEL是方法名。

那么我们要调用一个函数,还需要解决两个问题:

第一个问题:如何根据SEL找到函数实现地址IMP

第二个问题:方法声明校验。

1.2.1 Type Encoding

下面我们先讨论第二个问题,方法声明校验,这个校验,是通过给方法指定一个编码实现的,相对应的编码如下:

image-20181213223037331

二、方法缓存

我们继续讨论上面的第一个问题——如何根据SEL找到函数实现地址IMP

在无缓存时,找到isa指向的类结构,遍历class_rw_t中的方法列表method_array_t即可。

那么有缓存的时候呢?

2.1 窥探方法缓存

我们要窥探缓存的结构,从源码读起。下面是源码的的顺序图:

image-20181213222821800

在对源码的剖析之后,我们有以下的成果:

2.1.1 方法缓存结构

其中bucket_t *_buckets就是存放缓存列表的结构,它本质是一个哈希表。

而哈希表中的存放的是bucket_t的结构体。

image-20181213223301620

2.1.2 验证

验证的代码在01方法缓存探索

image-20181213231248316

2.2 哈希表

通过代码的探索,我们整理了下面这些哈希表中最重要的节点处理

image-20181213223414603

2.2.1 哈希表的处理

上面有一些需要注意的点:

(1)hash函数

mask为缓存空间大小-1,所以hash之后一定不会超过缓存空间大小;

1
2
3
4
static inline mask_t cache_hash(cache_key_t key, mask_t mask) 
{
return (mask_t)(key & mask);
}

####(2)碰撞处理

碰撞的处理因平台而已,在iOS下做了如下处理,其实就是简单的开放寻址来进行碰撞处理:

1
2
3
static inline mask_t cache_next(mask_t i, mask_t mask) {
return i ? i-1 : mask;
}

(3)缓存扩容

缓存空间是会动态变化的,其变化如下:

1
2
3
4
5
6
7
8
9
10
11
void cache_t::expand()
{
uint32_t oldCapacity = capacity();
//假如旧的容量大小为0,就分配4(INIT_CACHE_SIZE)
//旧容量大小不为0,分配当前容量的2倍
uint32_t newCapacity = oldCapacity ? oldCapacity*2 : INIT_CACHE_SIZE;
if ((uint32_t)(mask_t)newCapacity != newCapacity) {
newCapacity = oldCapacity;
}
reallocate(oldCapacity, newCapacity);
}

(4)注意点

  • mask为缓存空间大小-1,所以hash之后一定不会超过缓存空间大小;

2.2.2 读写缓存

(1) 缓存方法

我们看看方法缓存的哈希表,是如何存放方法的。

  1. 传入key(@selector(method))通过hash——cache_hash获得索引index
  2. 检查当前index是否被占用
    • 如果被占用,即本次哈希冲突,重新进行寻址——cache_next,算出index,回到2。
  3. 如果没占用,存放到index处。

(2)查找缓存

那么又是如何查找缓存的呢?

  1. 传入key(@selector(method))通过hash——cache_hash获得索引index
  2. 根据index获取bucket_t,检查该bucket._key是否与传入的key一致。
    • 若不一致,即由于存方法时,有hash冲突,再次hash——cache_next,算出index,回到2。
  3. 如果key一致性通过,即获取该bucket._imp返回;
  4. 调用bucket._imp处的函数。

三、总结

3.1 方法缓存

  1. 每个类对象都存有一个cache——方法缓存列表;
  2. cache本质是哈希表,其hash函数为:f(@selector()) = @selector() & _mask;
  3. 子类没有实现方法会调用父类的方法,并且将父类方法加入到子类自己的cache 里。

3.2 方法调用

经过上面的探讨,我们大致明白了如何调用一个方法。

image-20181213225432248

当然,这并不是一个消息发送的完整流程,下篇文章,将会开启探索如何调用一个方法的完整流程之旅。

参考

链接

  1. Type Encodings
  2. Apple souce objc4

示例代码

  1. 01方法缓存探索