iOS底层-cache_t流程分析

本文概述

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

cache_t初探

1.cache_t 结构

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

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类,自定义四个方法,在外部对其进行调用post

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]处打断点 ui

x/4gx打印 cls1内存地址的前四位,由于 cache_t前面的两个属性 isasuperclass合占16字节,故 cache_t的地址等于 isa的地址偏移16个字节,便是图中标注的地址,强转输出这个地址获得 cls1cache_t内的属性值,依次类推,输出其余的 cls

类对象 _mask _occupied
cls1 3 1
cls2 3 2
cls3 3 3
cls4 7 1
cls5 7 2

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

cache_t源码

1.寻找切入点

虽然说从源码入手这个想法是好的,但是那么多源码,究竟要从哪里看起。思来想去,仍是先搜索下cache_tspa

有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对应的 indexbegin,用来记录查询起始索引,而后把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有一个更宏观的理解。敬请期待。

相关文章
相关标签/搜索