上篇类的本质中,我们知道结构体 objc_class
中的 cache
是用来方法缓存的。
cache_t
初探
cache_t
是一种用来进行快速查找执行函数的机制。我们知道 OC
为了实现其动态性,将函数地址的调用包装成了 SEL
寻找 IMP
的过程,随之带来的负面影响就是降低了方法的调用率,苹果为了解决这一问题,就引入了方法缓存的机制。
cache_t
结构
根据 libObjc objc4-756.2
版本中,cache_t
的源码:
struct cache_t { |
从源码层面不难看出,cache_t
占 16 字节。从结构上来看,bucket_t
存储了方法的 SEL
和 IMP
,下面我么来验证下是否如此。
cache_t
验证
Class cls = NSClassFromString(@"SMPerson"); |
不调用方法
我们首先在方法调用前断点看看:
cache
的地址根据cls
的首地址偏移 16 个字节得到- 在方法调用之前,类中的
cache
的_buckets
数组是为空的,_mask
和_occupied
均为 0
调用一个方法
我们接着单步调试后cache
中的情况:
- 在调用方法之后,
cache
中的值发生了变化,_occupied
为 1 表示已有一位被占用 _buckets
数组中有值,bucket_t
缓存了方法的sel
和imp
- 这里需要注意的点:
buckets
中的值为什么不是从下表为 0 开始
调用两个方法
当继续执行时,调用 [p sayHello]
方法之后:
_occupied
为 2, 此时增加了方法sayHello
的sel
和imp
此时的 _mask
为 3,_occupied
为 2,如果继续执行发生方法会发生什么呢?
调用三个方法
_mask
为 3,_occupied
为 3,新增方法method1
的缓存。
调用四个方法
- 当继续执行
[p method2]
之后,_mask
为 7,_occupied
为 1 - 此时
buckets
的地址发生了变化
cache_t
小结
结合上面的例子,我们整理后得到:
调用方法 | buckets | mask | occupied |
---|---|---|---|
未调用方法 | - | 0 | 0 |
1 | init |
3 | 1 |
2 | init sayHello |
3 | 2 |
3 | init sayHello method1 |
3 | 3 |
4 | method2 |
7 | 1 |
从表格中看到:
buckets
:桶, 用来存储缓存的方法occupied
:表示已经缓存的方法数量mask
:看上去并不知道表示什么,但是结构体cache_t
中有一个方法:mask_t cache_t::capacity()
{
return mask() ? mask()+1 : 0;
}所以
mask
可看作是该buckets
的容量
当缓存的方法数量超过其 mask
的值后,mask
的值会增大,这里很容易想到的是对缓存扩容,缓存的方法又是如何操作的呢?
cache_t
源码解析
在上面一节我们猜测,当缓存方法的数量超多某个值时会对缓存数组进行扩容,结合源码,我们发现在结构体 cache_t
中提供了两个方法:
void expand(); |
这两个方法是如何调用的呢?
在这两个方法的下面有方法 cache_fill_nolock
,这个方法是提供给外部调用的 api,主要服务于 msgSend
。
cache_fill_nolock
通过调用方法,触发 cache_fill_nolock
,此函数查找并填充缓存:
static void cache_fill_nolock(Class cls, SEL sel, IMP imp, id receiver) |
该方法的流程可分为三个阶段:
- 从
cls
获取cache
- 确定缓存容量(初始化、扩容或者直接使用)
- 根据方法
sel
查找bucket
,更新数组
第一个阶段很简单,我们接着看下面的过程。
确定缓存容量并开辟空间
在 cache_fill_nolock
方法中,将确定缓存的容量:
enum { |
这段代码表现为以下三种情况:
- 初始化:即原始容量为 0 时,初始化容量为 4
- 新增方法缓存后数量小于当前容量的 3/4 时:无需操作
- 扩容
reallocate
申请内存空间
如果是第一次使用缓存,肯定是第一种情况,即进行 reallocate
操作:
void cache_t::reallocate(mask_t oldCapacity, mask_t newCapacity) |
这里也分为几个阶段:
- 生成新的
buckets
,重新分配内存空间 - 重置
buckets
的指向,更新mask
的值,值为新的容量- 1
,更新occupied
的值为 0 - 扩容操作时会释放旧的数组,旧数组中的数据一并丢弃,即旧缓存不会并入新的数组中
扩容
当当前缓存数量占比总容量大于 3/4 时,会进行扩容,新的容量为旧容量的两倍,并重新申请内存空间
void cache_t::expand() |
更新数组
确定 buckets
后,需要更新数组中的内容:
bucket_t *bucket = cache->find(sel, receiver); |
首先通过
sel
找到目标bucket
:这里通过哈希算法,从而获取bucket
目标
bucket
的sel
和imp
不存在时,更新occupied
的值+ 1
填充
sel
和imp
bucket_t * cache_t::find(SEL s, id receiver)
{
assert(s != 0);
bucket_t *b = buckets();
mask_t m = mask();
// 哈希算法为 s & mask
mask_t begin = cache_hash(s, m);
mask_t i = begin;
do {
/**
* b[i].sel() == 0 表示该位置还没有缓存方法
* b[i].sel() == s 命中缓存
*/
if (b[i].sel() == 0 || b[i].sel() == s) {
return &b[i];
}
// 循环条件为:最后尝试的位置 = 开始的位置
} while ((i = cache_next(i, m)) != begin); // 发生哈希冲突时,冲突函数为 cache_next
// hack
// 按照正常的逻辑,我们一定会从 buckets 中找到目标 bucket,因为 buckets 桶的课使用容量为总容量的 3/4,所以一定会命中
// 若走到这里,则将异常情况打印出来
Class cls = (Class)((uintptr_t)this - offsetof(objc_class, cache));
cache_t::bad_cache(receiver, (SEL)s, cls);
}这里涉及到两个点:
- 哈希算法:
mask
和sel
来确定bucket
在数组中的位置 (数组的下标 =(mask_t)(uintptr_t)sel & _mask
) - 哈希冲突算法:
(i+1) & _mask
- 哈希算法:
多线程与方法缓存
我们知道,方法调用时可以多线程并发执行的,那么 cache_t
在更新数据时,是如何保证线程安全的呢?
多线程同时读取缓存
在整个 objc_msgSend
过程中,为了达到最佳的性能,对方法缓存的读取操作是没有添加任何锁的,而多个线程同时调用已缓存的方法,并不会引发 _buckets
和 _mask
的变化,所以多个线程同事读取方法缓存是安全的。
多线程同时写缓存
从源码我们知道在缓存的桶的数量扩容和写数据之前,系统使用了一个全局的互斥锁 cacheUpdateLock.assertLocked()
来保证写入的同步处理,并且在锁住的的范围内还做了一次查找缓存的操作 if (cache_getImp(cls, sel)) return;
,这样就避免了多个线程同时写入同一个方法,即多线程同时写缓存也是安全的。
static void cache_fill_nolock(Class cls, SEL sel, IMP imp, id receiver) |
编译内存屏障
第一次或者扩容时,我们从新分配缓存空间,在更新 _buckets
和 _mask
时:
void cache_t::setBucketsAndMask(struct bucket_t *newBuckets, mask_t newMask) |
这段代码中,我们先更新 _buckets
的值然后在更新 _mask
的值(更新后的 _buckets
容量一定是比旧桶的大),为了保证这个顺序不被编译器优化,使用 mega_barrier
来实现编译内存屏障。
我们在扩容后,新的容量是原来的两倍,此如果 _buckets
在 _mask
更新之后,当多线程读写数据时,此时使用新的 _mask
值来计算方法在数组中的位置,极大可能会造成数组越界,进而造成崩溃。
在使用编译内存屏障技术后,我们得到的 _buckets
数组的长度一定不小于 _mask + 1
的,如此就保证了数组不会越界。可见,借助内存编译屏障的技术可以在一定程度上实现无锁读写技术。
内存回收
在多线程读写缓存时,写线程可能正在对缓存进行扩容,在释放旧的缓存时,如果有某个线程正在读取其中的内容,此时释放就会造成一些不可预料的结果。
为了解决这个问题,在回收旧的 buckets
时,会把需要释放的 buckets
加入一个全局的数组 garbage_refs
中。等待真正没有其他线程使用数组中的元素时,在进行释放。
缓存相关的问题
类方法缓存的位置
在上面的探索过程中,我们发现,在类的方法缓存中值有实例方法并没有类方法,如 alloc
、sayHappy
。
在类的本质一节中我们知道,类的实例方法存在类的 ro
中,类方法存放在元类的 ro
中。这里,实例方法缓存在类的 cache
中,那么我们是不是可以同样的猜测:类方法缓存在元类的 cache
中。
下面我们验证看看是否如此:
mask
存在的意义
在 cache_t
中:
mask_t cache_t::mask() |
_mask
反应了缓存的容量,并且保证查找哈希桶时不会出现越界的情况。
从源码中我们知道,缓存的初始容量为 4,以后每次扩容,容量增加一倍,所以缓存的容量一直是 4 的倍数。而 _mask
为容量 - 1
,所以 _mask
的二进制位上都是 1
。
在查找哈希桶的时候,获取数组下标(哈希函数)是由 i && _mask
得到的,这就使得下标值不会大于 _mask
的值,保证数组不会越界。
缓存扩容
当缓存的容量使用率达到 3/4 时,会进行扩容。每次扩容都是重新开辟一块内存空间,并且释放旧的缓存。旧的缓存空间缓存的数据并不会拷贝过来,这对性能是否有影响呢?
在方法调用的过程中,由于 OC 的动态性,为了提高方法调用的效率才有了方法缓存这一概念。既然是为了提高效率,就需要避免一些耗时的操作。这里直接舍弃旧的缓存空间和数据,就是一种以空间换时间的处理手段。
总结
cache_t
是方法缓存的载体,实例方法缓存在类的cache
中,类方法缓存在元类的cache
中cache_t
的三个成员变量:_buckets
:是一个指针数组,又称哈希桶,存储已调用过的方法_mask
:侧面表示了当前缓存的总容量,保证查找bucket
时不会出现数组越界的情况_occupied
:表示当前已缓存方法的数量
- 当缓存的容量使用率达到 3/4 时,会进行扩容。每次扩容都是重新开辟一块内存空间,并且释放旧的缓存。旧的缓存空间缓存的数据并不会拷贝过来
- 多线程调用方法缓存是线程安全的