iOS底层探究 - 类的结构剖析(cache_t)

1,296 阅读9分钟

目录:

cache_t在源码中的定义
cache_t的作用
cache_t的缓存流程

引言:

上一篇我们一起探索了 iOS 类的底层结构,我们先回顾下他的定义:

// 在objc-runtime-new.h这个文件发现了这段定义
struct objc_class : objc_object {
    // Class ISA;
    Class superclass;           // 8
    cache cache;             // formerly cache pointer and vtable 16
    class_data_bits_t bits;    // class_rw_t * plus custom rr/alloc flags 8
     //下面还有很多方法,在这里暂时我们不关注
};

我们已经介绍了类的几个重要成员,其中重点探索了class_data_bits_t bits的内部结构,这里面还有一个cache_t, 一起来看一看这个东西。顾名思义就是缓存的意思,那么用来缓存什么呢?
答案是: 缓存方法
它的底层是通过散列表(哈希表)的数据结构来实现存储和读取的,用于缓存曾经调用过的方法,再次调用时可以从缓存里面直接读取,提高方法的查找速度。那么接下来我们详细介绍下这个家伙。

一:cache_t在源码中的定义

先看下类结构的定义:

我们可以看出ISA,superclass分别都占8个字节,而cache_t是在class首地址平移16字节的位置,接下来我们看下cache_t的定义:

struct cache_t {
    struct bucket_t *_buckets; // 8字节,*即是指针,指针占 8 字节
    mask_t _mask;  // 4字节,uint32_t mask_t,int 类型 4 字节
    mask_t _occupied; // 4字节,同上
}

其中:

  • _mask 散列表长度 - 1
  • _occupied 已缓存方法数量

而_buckets是一个数组,数组里面的每一个元素就是一个bucket_t,我们看下源码里bucket_t的定义:

struct bucket_t {  
private:  
    // IMP-first is better for arm64e ptrauth and no worse for arm64.  
    // SEL-first is better for armv7* and i386 and x86_64.  
#if __arm64__  
    MethodCacheIMP _imp;  
    cache_key_t _key;  
#else  
    cache_key_t _key;  
    MethodCacheIMP _imp;  
#endif  
public:  
    inline cache_key_t key() const { return _key; }  
    inline IMP imp() const { return (IMP)_imp; }  
    inline void setKey(cache_key_t newKey) { _key = newKey; }  
    inline void setImp(IMP newImp) { _imp = newImp; }  

    void set(cache_key_t newKey, IMP newImp);  
};  

从源码可以可看出bucket_t里面包含了2个参数_imp和_key.

  • _key 方法的SEL作为key
  • _imp 函数实现的内存地址

二:cache_t的作用

引言里面我们提到cache_t是用来缓存方法的,那么为什么要缓存方法呢,直接调用不可以吗?讲到这里我们先回顾下方法的查找流程:
正常时候我们调用方法是周NORMAL这种形式,也就是普通查找,假设有个person类的实例方法eat被调用[person eat],我们来看下系统的查找流程:

  • obj -> isa -> objClass对象 -> method_array_t methods -> 对该表进行遍历查找,找到就调用,没找到继续往下走
  • objClass对象 -> superclass父类 -> method_array_t methods -> 对父类的方法列表进行遍历查找,找到就调用,没找到就重复本步骤
  • 找到就调用,没找到重复流程 ...
  • 直到跟类NSObject -> isa -> NSObject的Class对象 -> method_array_t methods
  • 最后没找到才会走各种判断,抛出异常等

看下,多么复杂和繁琐,但是苹果的工程师就很聪明,在每个类里面放一个缓存的盒子,你只要调用我就给你发方法的SELIMP保存下来,下次调用的时候只要根据SEL就能在缓存中很快的得到方法的实现地址,岂不是极大的提高了效率。

三:cache_t的缓存流程

关于流程源码里面有这样一段注释

* Cache readers (PC-checked by collecting_in_critical())
 * objc_msgSend*
 * cache_getImp
 *
 * Cache writers (hold cacheUpdateLock while reading or writing; not PC-checked)
 * cache_fill         (acquires lock)
 * cache_expand       (only called from cache_fill)
 * cache_create       (only called from cache_expand)
 * bcopy               (only called from instrumented cache_expand)
 * flush_caches        (acquires lock)
 * cache_flush        (only called from cache_fill and flush_caches)
 * cache_collect_free (only called from cache_expand and cache_flush)

可以看出读缓存的时候过程很简单,就是调用objc_msgsend之后通过cache_getImp去读取函数的地址,所以我们着重研究下写的流程,我们看些的过程很多,但是他的入口是从cache_fill开始的:

void cache_fill(Class cls, SEL sel, IMP imp, id receiver)
{
#if !DEBUG_TASK_THREADS
   mutex_locker_t lock(cacheUpdateLock);
   cache_fill_nolock(cls, sel, imp, receiver);
#else
   _collecting_in_critical();
   return;
#endif
}

cache_fill这个函数内部又调用了cache_fill_nolock这个函数:

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

    // Never cache before +initialize is done
    if (!cls->isInitialized()) return;

    // Make sure the entry wasn't added to the cache by some other thread 
    // before we grabbed the cacheUpdateLock.
    if (cache_getImp(cls, sel)) return;

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

    // Use the cache as-is if it is less than 3/4 full
    mask_t newOccupied = cache->occupied() + 1;
    mask_t capacity = cache->capacity();
    if (cache->isConstantEmptyCache()) {
        // Cache is read-only. Replace it.
        cache->reallocate(capacity, capacity ?: INIT_CACHE_SIZE);
    }
    else if (newOccupied <= capacity / 4 * 3) {
        // Cache is less than 3/4 full. Use it as-is.
    }
    else {
        // Cache is too full. Expand it.
        cache->expand();
    }

    // Scan for the first unused slot and insert there.
    // There is guaranteed to be an empty slot because the 
    // minimum size is 4 and we resized at 3/4 full.
    bucket_t *bucket = cache->find(key, receiver);
    if (bucket->key() == 0) cache->incrementOccupied();
    bucket->set(key, imp);
}

这么大段代码,可以感觉到这个是个核心函数,函数内部做了很多的操作,我们逐行去研究下

首先是判断cls也就是类是否被初始化,如果没有直接return,接下来判断cache_getImp(cls, sel)是否有值,这里应该是防止在多线程的调用中,别的线程也会调用相同的方法,所以判断下是否在别的线程被写入,如果有就return

// Never cache before +initialize is done
    if (!cls->isInitialized()) return;

    // Make sure the entry wasn't added to the cache by some other thread 
    // before we grabbed the cacheUpdateLock.
    if (cache_getImp(cls, sel)) return;

接下来是通过调用函数内部使用内存平移,拿出类内部的缓存,然后根据sel生成一个key

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

首先定义newOccupied等于旧的占用数+1,取出cache_t中的capacity也就是缓存的容量值,

mask_t newOccupied = cache->occupied() + 1;
mask_t capacity = cache->capacity();

接下来就是判断比较了:

1:如果缓存是是空的,则进行cache->reallocate()
2:如果新的占位容量小于等于当前容量的3/4,则不作处理
3:然后如果新的占位容量大于当前容量的3/4,则进行扩容处理cache->expand()

  if (cache->isConstantEmptyCache()) {
     // Cache is read-only. Replace it.
     cache->reallocate(capacity, capacity ?: INIT_CACHE_SIZE);
 }
 else if (newOccupied <= capacity / 4 * 3) {
     // Cache is less than 3/4 full. Use it as-is.
 }
 else {
     // Cache is too full. Expand it.
     cache->expand();
 }

其中cache->reallocate(capacity, capacity ?: INIT_CACHE_SIZE)是对buckets重新生成,我们看下他的实现:

void cache_t::reallocate(mask_t oldCapacity, mask_t newCapacity)
{
    bool freeOld = canBeFreed();

    bucket_t *oldBuckets = buckets();
    bucket_t *newBuckets = allocateBuckets(newCapacity);

    // Cache's old contents are not propagated. 
    // This is thought to save cache memory at the cost of extra cache fills.
    // fixme re-measure this

    assert(newCapacity > 0);
    assert((uintptr_t)(mask_t)(newCapacity-1) == newCapacity-1);

    setBucketsAndMask(newBuckets, newCapacity - 1);
    
// 下面这个就是把旧的bucket_t给抹掉,释放内存
    if (freeOld) {
        cache_collect_free(oldBuckets, oldCapacity);
        cache_collect(false);
    }
}

函数是根据新的newCapacity生成一个新的Buckets然后把老的Buckets给替换掉,最后释放掉老的Bucket占用的内存空间。

接下来我们看下cache->expand()这个函数的调用:

void cache_t::expand()
{
    cacheUpdateLock.assertLocked();
    
    uint32_t oldCapacity = capacity();
    uint32_t newCapacity = oldCapacity ? oldCapacity*2 : INIT_CACHE_SIZE;
  *
  能进入到扩容的这里面 _mask 是有值的,并且是并且我们知道得到的oldCapacity是_maks + 1,
  申请的一份新的容量是 oldCapacity * 2,我们可以验证一下开辟两倍的空间是最划算的。
  *

    if ((uint32_t)(mask_t)newCapacity != newCapacity) {
        // mask overflow - can't grow further
        // fixme this wastes one bit of mask
        newCapacity = oldCapacity;
     }
    reallocate(oldCapacity, newCapacity);
}

以上我们可总结出cache扩容,就是重新申请一个容量是原来2倍的新容量。

在这里我们有一个疑问就是在容量不够的时候为什么要销毁重建呢,那样之前的缓存不就没有了吗,为什么保存之前缓存的方法呢?

苹果的程序员在设计这块的时候可能考虑到保存之前的调用cache,开辟空间之后还要把老的缓存进行内存平移,这样本身缓存是让人节省时间的设计,这样做反而更耗时,不如销毁直接重建来的快速。

扩容和销毁重建的函数我们已经了解了,那么回到主线,此时Buckets存储筒已经准备好,接下来就是存储的过程,首先我们通过cache->find(key, receiver)来寻找个合适的筒子,我们看下他是怎么做寻找的:

bucket_t * cache_t::find(cache_key_t k, id receiver)
{
    assert(k != 0);

    bucket_t *b = buckets();
    mask_t m = mask();
// 通过cache_hash函数 [begin = k & m]计算出key的值 k 对应的index的值 begin,用来记录查询起始索引
    mask_t begin = cache_hash(k, m);
    
    // begin赋值给i,用于切换索引
    mask_t i = begin;
    do {
        if (b[i].key() == 0  ||  b[i].key() == k) {
            // 用这个i从散列表取值,如果取出来的bucket_t 的 key = k,则查询成功,返回bucket_t
            // 如果key = 0, 说明在索引i的位置上还没有缓存过方法,同样需要返回该bucket_t,用于终止缓存查询。
            return &b[i];
        }
    } while ((i = cache_next(i, m)) != begin);
// 这里其实就是找到我们cache_t中buckets列表里面需要匹配的bucket。
    // hack
    // 如果此时还没有找到key对应的bucket_t,或者是空的bucket_t,则循环结束,说明查找失败,调用下面的bad_cache函数
    Class cls = (Class)((uintptr_t)this - offsetof(objc_class, cache));
    cache_t::bad_cache(receiver, (SEL)k, cls);
}

我们知道Buckets其实是一个数组,数组的底层也是个散列表,根据key计算出index值的这个算法称作散列算法。index = @selector(XXXX) & mask 根据&运算的特点,可以得知最终index <= mask,而mask = 散列表长度 - 1,也就是说0 <= index <= 散列表长度 - 1,这实际上覆盖了散列表的索引范围。

这个函数调用之后我们获取到了合适的bucket筒子,接下来判断if (bucket->key() == 0) cache->incrementOccupied()如果为真也就是筒子没被占用过,那么Occupied占用数要加一。

最后,调用set(key, imp)进行填充

bucket->set(key, imp);

我们总结下cache_t的总体流程:

1: 当一个对象通过objc_megsend接收到消息时;首先根据objisa指针进入它的类对象cls里面。
2: 在obj的cls里面,首先到缓存cache_t里面查询方法message的函数实现,如果找到,就直接调用该函数。
3: 如果上一步没有找到对应函数,在对该cls的方法列表进行二分/遍历查找
4: 如果找到了对应函数,接下来就是对cache_t进行填充

(1) 进行容错判断,准备一些临时变量。
(2) 在每次进行缓存操作之前,首先需要检查缓存容量,如果缓存内的方法数量超过规定的临界值(设定容量的3/4),需要先对缓存进行2倍扩容,原先缓存过的方法全部丢弃,然后将当前方法存入扩容后的新缓存内
(3) 在Buckets数组里通过散列算法进行查找合适的bucket
(4) 找到之后判断是否曾经占用过,如果没有占用过,那么就把Occupied加一
(5) 将方法缓存到bucket

5:调用该方法。

本片类的结构剖析(cache_t)分析完毕。