iOS八股文(五)class类结构cache_t源码详解
cache_t结构
在objc4源码中,objc_class
结构中有一个cache_t
的成员变量。
struct objc_class : objc_object {
// Class ISA;
Class superclass;
cache_t cache; // formerly cache pointer and vtable
class_data_bits_t bits; // class_rw_t * plus custom rr/alloc flags
}
cache
的作用是在objc_msgSend
过程中会先在cache
中根据方法名来hash查找方法实现,如果能查找到就直接掉用。如果查找不到然后再去rw_t
中查找。然后再在cache
中缓存。
struct cache_t {
private:
explicit_atomic<uintptr_t> _bucketsAndMaybeMask;
union {
struct {
explicit_atomic<mask_t> _maybeMask;
#if __LP64__
uint16_t _flags;
#endif
uint16_t _occupied;
};
explicit_atomic<preopt_cache_t *> _originalPreoptCache;
};
可以看到有两个成员变量组成。但从定义中看不出成员变量的含义,我们需要结合其中一个方法的实现去了解。
insert方法
前面提到在objc_msgSend
的时候会在cache
中添加方法缓存,而这个方法缓存是通过insert
方法来添加的。
insert方法分为两部分,一部分是真正的插入,另一部分是hash扩容。我们先看真正的插入实现。
insert的插入
源码提示:
fastpath:大概率执行
slowpath:小概率执行
先看源码:
b
是方法缓存的桶子(哈希表)的指针;
capacity
是目前桶子目前的总容量,那么m
是桶子目前的容量减1,即桶子最大的索引。
其中cache_hash
是hash计算index的函数。通过方法名和m计算出index。
可以简单看一看,不做重点,不理解也不妨碍对整体的理解。
最关键的代码是对两者做了
与运算
,其中mask
肯定是2的n次方减1(低位全是1: 0x11、0x1111这类的),这样相当于是对2的n次方取余了。这样算出的index不会有越界的问题。至于为什么是2的n次方减1,capacity
的扩容都是2倍的,初始化的容量也是1左移1位(arm64)或者2位(x86)的值,m = capacity - 1
,这个mask
就是2的n次方减1了。
剩余是do while
的执行,详细注释如下。
其中,如果hash冲突了,会通过
cache_next
方法寻找下一个索引i。
在阅读的时候要注意两个容易混淆的概念:
capacity :容量,容纳能力 occupied :实际占用的容量 例如,一个10L的桶子,装了5L水,那么capacity = 10,occupied = 5.
insert的扩容
在插入之前,有对backet的容量判断,如果不够的话将会对其扩容。先看源码;
以上代码分为4个分支3个判断,我在代码上面写了简单易懂的注释。
其中
INIT_CACHE_SIZE
的值在arm64的时候是2,在x86_64的结构下是4。
CACHE_END_MARKER
在arm64下为 0, 在x86_64下为 1。
cache_fill_ratio()
在arm64下为7/8, 在x86_64下位3/4。
总结:
arm64结构下,当目前缓存的大小+1小于等于桶子的大小的7/8的时候不扩容,当桶子的大小小于等于8,并且目前缓存的大小+1小于等于桶子的大小的时候也不扩容(桶子小于8的时候存满了才扩容)。 x86_64结构下,当目前缓存的大小+1,再+1小于等于桶子大小的3/4的时候不扩容。
代码证实
我们可以通过c++代码定义class的结构,然后通过桥接的方式来获取cache的结构,从而观察backet里面的缓存情况(代码可留言,邮箱发送)。
@implementation NSObject (OSCacheT)
- (void)printCacheT {
Class cls = [self class];
//将类型转换成自定义的源码os_objc_class类型,方便后续操作
struct os_objc_class *pClass = (__bridge struct os_objc_class *)(cls);
struct os_cache_t cache = pClass->cache;
struct os_bucket_t * buckets = cache.buckets();
struct os_preopt_cache_t origin = cache._originalPreoptCache;
uintptr_t mask = cache.mask();
NSLog(@"桶子里缓存方法的个数 = %u, 桶子的长度 = %lu",origin.occupied,mask+1);
//打印buckets
for (int i = 0; i < mask + 1; i++ ) {
SEL sel = buckets[i]._sel;
IMP imp = buckets[i]._imp;
NSLog(@"%@-%p",NSStringFromSelector(sel),imp);
}
NSLog(@"------\r");
}
@end
方法调用如下:
打印结果如下(arm64 模拟器环境):
可以看到,分别在method1
(4/4),method8
(8/8),method22
(14/16)的时候进行了扩容。这恰好印证了我们之前得到的结论。注意环境为arm64 模拟器。
思考
在扩容的时候,苹果为什么要释放旧的缓存,而不是把旧的放入到新的缓存中呢?
- 提高
msgSend
效率,扩容是发生在msgSend
中,如果再做copy
操作,会影响消息发送的效率。 - 缓存命中概率,每个方法调用的概率在底层设计的时候,都视为是一样的。所以之前缓存的方法,在后面调用的概率和其他方法的概率是一样的。即清除之前的缓存,不会影响命中概率。
- 减少扩容次数,从而提高效率。还是2的衍生,如果及时清除,可以缓存更多的方法,这样,扩容的概率跟放入新缓存相比更小。
转载自:https://juejin.cn/post/7091212924106047495