阅读 796

iOS底层-cache_t流程分析

欢迎阅读iOS底层系列(建议按顺序)

iOS底层 - alloc和init探索

iOS底层 - 包罗万象的isa

iOS底层 - 类的本质分析

iOS底层 - cache_t流程分析

iOS底层 - 方法查找流程分析

iOS底层 - 消息转发流程分析

iOS底层 - dyld是如何加载app的

iOS底层 - 类的加载分析

本文概述

本文旨在通过源码分析cache_t相关,如缓存策略,动态扩容等,了解方法查找的前置流程。因其与objc_msgSend流程有密切联系,而发送消息又是iOS方法的本质,故了解cache_t是有必要的。

cache_t初探

1.cache_t 结构

上一篇iOS底层 -- 类的本质分析中对cache_t结构有初步的了解,其底层的结构:

struct objc_class : objc_object {
    // Class ISA;
    Class superclass;
    cache_t cache;              
    class_data_bits_t bits;   
    ..
}
复制代码

cache_t位于objc_class

struct cache_t {
    struct bucket_t *_buckets;
    mask_t _mask;
    mask_t _occupied;
}
复制代码

自身的结构由三个属性组成:

  • buckets是装方法的桶子,里面放着方式实现imp,根据方法编号sel生成的key
  • mask是扩容因子,同时和key共同计算得出寻找bucket_t的哈希索引
  • occupied是当前占用的容量

2.lldb调试

在objc源码中新建CJPerson类,自定义四个方法,在外部对其进行调用

CJPerson * person = [[CJPerson alloc]init];
Class cls1 = object_getClass(person);
   
[person firstAction];
Class cls2 = object_getClass(person);
    
[person secondAction];
Class cls3 = object_getClass(person);

[person thirdAction];
Class cls4 = object_getClass(person);
 
[person fourthAction];
Class cls5 = object_getClass(person);
复制代码

在每个方法调用前输出下各个cls,以cls1为例,在[person firstAction]处打断点 x/4gx打印cls1内存地址的前四位,因为cache_t前面的两个属性isasuperclass合占16字节,故cache_t的地址等于isa的地址偏移16个字节,即是图中标注的地址,强转输出这个地址得到cls1cache_t内的属性值,依次类推,输出其他的cls

类对象_mask_occupied
cls131
cls232
cls333
cls471
cls572

根据这个结果,会发现前面三个cls还有一点规律可循,occupied每次+1,可是到cls4的时候,这个规律又不符合了,并且mask的值也发生了变化。看来只看打印结果是没办法看出是谁在作怪,还是要去源码瞧一瞧。

cache_t源码

1.寻找切入点

虽说从源码入手这个想法是好的,可是那么多源码,究竟要从哪里看起。思来想去,还是先搜索下cache_t 有28个相关结果,一个个点过去查找量也不小啊,再想想,既然cache_t是个结构体,那系统应该会 初始化它,然后在操作它,那尝试搜索下cache_t * 只有4条相关结果,这个压力瞬间减少,一个个查看也没什么问题了。

1.第一条

cache_t *getCache(Class cls) 
{
    assert(cls);
    return &cls->cache;
}
复制代码

这只是个get方法,排除

2.第二条

void cache_t::bad_cache(id receiver, SEL sel, Class isa)
{
    // Log in separate steps in case the logging itself causes a crash.
    _objc_inform_now_and_on_crash
        ("Method cache corrupted. This may be a message to an "
         "invalid object, or a memory error somewhere else.");
    cache_t *cache = &isa->cache;
    _objc_inform_now_and_on_crash
        ("%s %p, SEL %p, isa %p, cache %p, buckets %p, "
         "mask 0x%x, occupied 0x%x", 
         receiver ? "receiver" : "unused", receiver, 
         sel, isa, cache, cache->_buckets, 
         cache->_mask, cache->_occupied);
         ...
}
复制代码

这是在bad_cache方法中,看名字和实现,不难判断出,这是坏缓存的时候发出的警告和崩溃消息

3.第三条

static void cache_fill_nolock(Class cls, SEL sel, IMP imp, id receiver)
{
    cacheUpdateLock.assertLocked();
    if (!cls->isInitialized()) return;
    if (cache_getImp(cls, sel)) return;
    cache_t *cache = getCache(cls);
    ...
}
复制代码

这是在cache_fill_nolock方法中,方法名意为缓存填充,感觉可能是这个,但还不能确定

4.第四条

void cache_erase_nolock(Class cls)
{
    cacheUpdateLock.assertLocked();

    cache_t *cache = getCache(cls);
    ...
}
复制代码

这是在cache_erase_nolock方法中,方法名意为缓存擦除

综上所述,第三条的可能性是最高的,但还需要验证,在每条方法内打断点,[person firstAction]调用后,看下执行结果

断点来到cache_fill_nolock内,可以确认方法调用时,是执行这个方法进行缓存的。

2.cache_fill_nolock解析

确认了这是方法调用时执行缓存的方法,那就有必要对其实现做深入的研究了。

static void cache_fill_nolock(Class cls, SEL sel, IMP imp, id receiver)
{
    cacheUpdateLock.assertLocked(); ①

    if (!cls->isInitialized()) return; ②
    
    if (cache_getImp(cls, sel)) return; ③

    cache_t *cache = getCache(cls); ④
    cache_key_t key = getKey(sel); ⑤

    mask_t newOccupied = cache->occupied() + 1; ⑥
    mask_t capacity = cache->capacity(); ⑦
    if (cache->isConstantEmptyCache()) { ⑧
        cache->reallocate(capacity, capacity ?: INIT_CACHE_SIZE); ⑨
    } 
    else if (newOccupied <= capacity / 4 * 3) { ⑩
    
    }
    else {
        cache->expand(); ⑪
    }
    bucket_t *bucket = cache->find(key, receiver); ⑫
    if (bucket->key() == 0) cache->incrementOccupied(); ⑬
    bucket->set(key, imp); ⑭
}
复制代码
① 缓存锁
② 判断类是否被初始化,如果未初始化,直接返回
③ 判断sel是否已经被cls缓存,如果已经缓存就没必要再走下面的缓存流程,直接返回
④ 通过cls获取cache_t的内存地址
⑤ 通过sel强转生成key
⑥ 获取cache中当前占有量occupied + 1的值,给下面进行容量判断
⑦ 取出cache中的容量capacity
⑧ 判断当前占有量是否为0并且桶子的容量是否为空的
   ⑨占有量为0并且桶子容量为空时,进行reallocate重新分配Buckets和Mask
   ⑩新的占有量小于等于总容量的3/4时,无另外操作
   ⑪新的占有量大于总容量的3/4时,进行expand扩容
⑫ 通过key当作哈希下标寻找对应bucket
⑬ 如果key等于0,说明没找到,需要缓存,则cache中的当前占有量 occupied + 1
⑭ 把key和imp装进桶子bucket
复制代码

总结:

cache_fill_nolock先获取类的cache,判断cache为空时,创建cache,如果cache已经存在,判断存储后的占有量大于容量的3/4时,就扩容。做完这些后,keyimp放入bucket中,把bucket放入cache中。

明白了缓存的主流程,在对里面比较重要的方法,如reallocateexpand等做进一步解析

a.reallocate(mask_t oldCapacity, mask_t newCapacity)

INIT_CACHE_SIZE定义

enum {
    INIT_CACHE_SIZE_LOG2 = 2,
    INIT_CACHE_SIZE      = (1 << INIT_CACHE_SIZE_LOG2)
};
复制代码

实现

void cache_t::reallocate(mask_t oldCapacity, mask_t newCapacity)
{
    ...
    setBucketsAndMask(newBuckets, newCapacity - 1);
    if (freeOld) {
        cache_collect_free(oldBuckets, oldCapacity);
        cache_collect(false);
    }
}
复制代码

可以看到,在第一次调用时,capacity为空,则传入4,然后给setBucketsAndMask入参,重新生成_buckets,并且最后需要丢弃旧的桶子oldBuckets,清空原来的缓存

void cache_t::setBucketsAndMask(struct bucket_t *newBuckets, mask_t newMask)
{
    mega_barrier();
    _buckets = newBuckets;
    mega_barrier();
    _mask = newMask;
    _occupied = 0;
}
复制代码

处理完后,_mask = 4 - 1 = 3,_occupied = 0。mask = newCapacity - 1,newCapacity = 2^n(初始化最小为4,n为扩容次数+2),故mask = 2^n - 1,lldb调试的值得以验证

b.expand()

void cache_t::expand()
{
    cacheUpdateLock.assertLocked();
    
    uint32_t oldCapacity = capacity();
    uint32_t newCapacity = oldCapacity ? oldCapacity*2 : INIT_CACHE_SIZE;

    if ((uint32_t)(mask_t)newCapacity != newCapacity) {
        newCapacity = oldCapacity;
    }
    reallocate(oldCapacity, newCapacity);
}
复制代码

先获取oldCapacity旧容量,

mask_t cache_t::capacity() 
{
    return mask() ? mask()+1 : 0; 
}
复制代码

等于mask + 1,初始化后mask = 3 ,那这里oldCapacity = 4,新容量newCapacity等于旧容量oldCapacity * 2 = 8,然后给reallocate入参,重新分配桶子bucketsmask

c.find(cache_key_t k, id receiver)

bucket_t * cache_t::find(cache_key_t k, id receiver)
{
    assert(k != 0);
    bucket_t *b = buckets();
    mask_t m = mask();
    mask_t i = begin;
    do {
        if (b[i].key() == 0  ||  b[i].key() == k) {
            return &b[i];
        }
    } while ((i = cache_next(i, m)) != begin);
    Class cls = (Class)((uintptr_t)this - offsetof(objc_class, cache));
    cache_t::bad_cache(receiver, (SEL)k, cls);
}
复制代码

通过cache_hash函数【begin = k & m】计算出keyk 对应的 index begin,用来记录查询起始索引,然后把begin 赋值给 i,用于切换索引。 用这个i从散列表取值,如果取出来的bucket_tkey = k,则查询成功,返回该bucket_t;如果key = 0,说明在索引i的位置上还没有缓存过方法,同样需要返回该bucket_t,用于中止缓存查询。然后while循环其实就相当于绕圈,把散列表头尾连起来,从begin值开始,递减索引值,当走过一圈之后,必然会重新回到begin值,如果此时还没有找到key对应的bucket_t,或者是空的bucket_t,则循环结束,说明查找失败,调用bad_cache方法抛出异常。

cache_t部分问题汇总

1.类的cache_t会对任何方法进行缓存嘛?

类的cache_t只会缓存对象方法,类方法缓存在元类中

2.为什方法编号sel需要强转成key?

方法编号sel是字符串类型,在底层传递的效率不如long类型的key

3.mask的作用?

mask做为判断是否扩容的参数之一,可以称之为扩容因子;同时maskkey共同生成哈希下表用来查找bucket_t。这里的maskisa_mask 做为面具的功能不一样,需要进行区分。

4.方法缓存是否有序?

方法缓存的bucket_t是根据哈希下表得到的,找到空位子就直接坐下占有,不存在有序

5.为什么扩容是3/4?

HashMap的负载因子等于0.75时,空间利用率比较高,而且避免了相当多的Hash冲突,使得底层的链表或者是红黑树的高度比较低,提升了空间效率。

6.bucket和mask,capacity,sel和imp的关系

  • bucket内装着impsel生成的key
  • bucket是通过keymask进行寻找的
  • bucket的数量取决于capacity

7.为什么扩容后需要丢弃原先所有的缓存?

感觉上,扩容后丢弃缓存,下次调用在重新缓存的行为使得缓存变的没有意义。实际上,一般类中有较多方法,如果扩容后,还需要保存原先的方法,新bucket_t需要从旧的bucket_t中获取所有方法,在一个个放入,这本身是比较耗时的行为。方法缓存的目的就是使消息发送流程更快速,苹果为了更快,还使方法查找走汇编流程,总之这一切都是为了一个字--快。扩容后丢弃原先缓存就是在这个目的下被设计出来,而且这样设计,从某种意义上来说,也符合LRU缓存策略,最近被缓存的方法使用率也较高,较早被缓存的使用率较低。

写在最后

以上就是我对cache_t的部分理解,cache_t是消息发送的一个切入点。下一章objc_msgSend会对cache_t有一个更宏观的理解。敬请期待。