iOS 底层探索 - cache_t

iOS 底层探索系列算法

iOS 查漏补缺系列缓存

上一篇咱们一块儿探索了 iOS 类的底层原理,其中比较重要的四个属性咱们都简单的过了一遍,咱们接下来要重点探索第三个属性 cache_t,对于这个属性,咱们能够学习到苹果对于缓存的设计与理解,同时也会接触到消息发送相关的知识。bash

1、探索 cache_t

1.1 cache_t 基本结构

咱们仍是先过一遍 OC 中类的结构:markdown

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

    class_rw_t *data() { 
        return bits.data();
    }

    ...省略代码...    
}
复制代码

接着咱们查看源码中 cache_t 的定义:数据结构

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

而后咱们发现 cache_t 结构体的第一个成员 _buckets 也是一个结构体类型 bucket_t,咱们再查看一下 bucket_t 的定义:less

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 其实缓存的是方法实现 IMP。这里有一个注意点,就是 IMP-firstSEL-first函数

IMP-first is better for arm64e ptrauth and no worse for arm64.oop

  • IMP-first 对 arm64e 的效果更好,对 arm64 不会有坏的影响。

SEL-first is better for armv7* and i386 and x86_64.post

  • SEL-first 适用于 armv7 * 和 i386 和 x86_64。

若是对 SELIMP 不是很熟悉的同窗能够去 objc4-756 源码中查看方法 method_t 的定义:性能

struct method_t {
    SEL name;   // 方法选择器
    const char *types; // 方法类型字符串
    MethodListIMP imp;  // 方法实现

    ...省略代码... 
};
复制代码

经过上面的源码,咱们大体了解了 bucket_t 类型的结构,那么如今问题来了,类中的 cache 是在何时以什么样的方式来进行缓存的呢?

1.2 LLDB 大法

了解到 cache_tbucket_t 的基本结构后,咱们能够经过 LLDB 来打印验证一下:

image.png

cache_t 内部的这三个属性,咱们从其名称不难看出 _occupied 应该是表示当前已经占用了多少缓存,_mask 暂时不知道,_buckets 应该是存放具体缓存的地方。那么为了验证咱们的猜测,咱们调用代码来测试:

image.png

咱们发现,断点断到 45 行的时候,_ocuupied 的值为 1,咱们打印一下 _buckets 里面的内容看看:

image.png

咱们能够看到,打印到 _buckets 的第三个元素的时候,咱们的 init 方法被缓存了,也就是说 _ocuupied 确实是表示当前被缓存方法的个数。这里可能读者会说为何 allocclass 为何没有被缓存呢?其实这是由于 allocclass 是类方法,而根据咱们前面探索类底层原理的时候,类方法是存储在元类里面的,因此这里类的缓存里面只会存储对象方法。 咱们接着把断点过到 46 行:

image.png

_ocuupied 的值果真发生了变化,咱们刚才的猜测进一步获得了验证,咱们再往下面走一行:

image.png

此时 _ocuupied 值已经为 3 了,咱们回顾一下当前缓存里面缓存的方法:

_ocuupied 的值 缓存的方法
1 NSObject下的init
2 NSObject下的init,person下的 sayHello
3 NSObject下的init,person下的 sayHello, person下的 sayCode

那么,当咱们的断点断到下一行的时候,是否是 _ocuupied 就会变为 4 呢? 咱们接着往下走:

image.png

使人惊奇的事情发生了,_ocuupied 的值变成了 1,而 _mask 变成了 7。这是为何呢?

若是读者了解并掌握散列表这种数据结构的话,相信已经看出端倪了。是的,这里其实就是用到了 开放寻址法 来解决散列冲突(哈希冲突)。

关于哈希冲突,能够借助鸽笼理论,即把 11 只鸽子放进 10 个抽屉里面,确定会有一个抽屉里面有 2 只鸽子。是否是理解起来很简单? :)

经过上面的测试,咱们明确了方法缓存使用的是哈希表存储,而且为了解决没法避免的哈希冲突使用的是开放寻址法,而开放寻址法必然要在合适的时机进行扩容,这个时机确定不是会在数据已经装满的时候,咱们能够进源码探索一下,咱们快速定位到 cache_t 的源码处:

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) {
        // mask overflow - can't grow further
        // fixme this wastes one bit of mask
        newCapacity = oldCapacity;
    }

    reallocate(oldCapacity, newCapacity);
}
复制代码

从上面的代码不难看出 expand 方法就是扩容的核心算法,咱们梳理一下里面的逻辑:

cacheUpdateLock.assertLocked();
复制代码
  • 缓存锁断言一下判断当前执行上下文是否已经上锁
uint32_t oldCapacity = capacity();
复制代码
  • 经过 capacity() 方法获取当前的容量大小
uint32_t newCapacity = oldCapacity ? oldCapacity*2 : INIT_CACHE_SIZE;
复制代码
  • 判断当前的容量大小,若是为0,则赋值为 INIT_CACHE_SIZE,而根据
enum {
    INIT_CACHE_SIZE_LOG2 = 2,
    INIT_CACHE_SIZE      = (1 << INIT_CACHE_SIZE_LOG2)
};
复制代码

可知 INIT_CACHE_SIZE 初始值为 4;若是当前容量大小不为 0,则直接翻倍。

到了这里相信聪明的读者根据咱们上面的测试应该猜到了,咱们的 _mask 其实就是容量大小减 1 后的结果。

reallocate(oldCapacity, newCapacity);
复制代码
  • 最后调用 reallocate 方法进行缓存大小的重置

咱们接着进入 reallocate 内部一探究竟:

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

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

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

    setBucketsAndMask(newBuckets, newCapacity - 1);
    
    if (freeOld) {
        cache_collect_free(oldBuckets, oldCapacity);
        cache_collect(false);
    }
}

void cache_t::setBucketsAndMask(struct bucket_t *newBuckets, mask_t newMask)
{
    mega_barrier();

    _buckets = newBuckets;
    
    mega_barrier();
    
    _mask = newMask;
    _occupied = 0;
}
复制代码

显然,_mask 是这一步 setBucketsAndMask(newBuckets, newCapacity - 1); 被赋值为容量减 1 的。

一样的,咱们还能够经过 capacity 方法来验证

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

2、深刻 cache_t

其实咱们在探索 iOS 底层的时候,尽可能不要站在上帝视角去审视相应的技术点,咱们能够尽可能给本身多抛出几个问题,而后尝试去解决每一个问题,经过这样的探索,对提升咱们阅读源码的能力十分重要。

经过前面的探索,咱们知道了 cache_t 实质上是缓存了咱们类的实例方法,那么对于类方法来讲,天然就是缓存在了元类上了。这一点我相信读者应该都能理解。

2.1 方法缓存策略

按照最常规的思惟,缓存内容最省时省力的办法确定是来一个缓存一个,那么咱们的 cache_t 是否是这么作的呢,实践出真知,咱们一试便知。

咱们在源码中搜索 capacity() 方法,咱们找到了 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);
}
复制代码

cache_fill_nolock 方法乍一看有些复杂,咱们不妨将它分解一下:

第一行代码仍是加锁的判断,咱们直接略过,来到第二行:

if (cache_getImp(cls, sel)) return;
复制代码
  • 经过 cache_getImp 来判断当前 cls 下的 sel 是否已经被缓存了,若是是,直接返回。而 cache_getImp 底层实现是 _cache_getImp,而且是在汇编层实现的。
cache_t *cache = getCache(cls);
cache_key_t key = getKey(sel);
复制代码
  • 调用 getCache 来获取 cls 的方法缓存,而后经过 getKey 来获取到缓存的 key,这里的 getKey 实际上是将 SEL 类型强转成 cache_key_t 类型。
mask_t newOccupied = cache->occupied() + 1;
复制代码
  • cache 已经占用的基础上进行加 1,获得的是新的缓存占用大小 newOccupied
mask_t capacity = cache->capacity();
复制代码
  • 而后读取如今缓存的容量 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();
}
复制代码
  • 若是缓存不为空,且缓存占用大小已经超过了容量的四分之三,则须要进行扩容。
bucket_t *bucket = cache->find(key, receiver);
复制代码
  • 经过前面生成的 key 在缓存中查找对应的 bucket_t,也就是对应的方法实现。
if (bucket->key() == 0) cache->incrementOccupied();
bucket->set(key, imp);
复制代码
  • 判断获取到的 bucket 是不是新的桶,若是是的话,就在缓存里面增长一个占用大小。而后把 keyimp 放到桶里面。

cache_fill_nolock 的基本流程咱们分析完了,这个方法主要针对的是没有缓存的状况。
可是这个方法里面的 cache->find 咱们并不知道是怎么实现的,咱们接着探索这个方法:

2.2 查找缓存策略

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

    bucket_t *b = buckets();
    mask_t m = mask();
    mask_t begin = cache_hash(k, m);
    mask_t i = begin;
    do {
        if (b[i].key() == 0  ||  b[i].key() == k) {
            return &b[i];
        }
    } while ((i = cache_next(i, m)) != begin);

    // hack
    Class cls = (Class)((uintptr_t)this - offsetof(objc_class, cache));
    cache_t::bad_cache(receiver, (SEL)k, cls);
}
复制代码

find 方法咱们乍一看会发现有一个 do-while 循环,由于这个方法的做用是根据 key 查找 IMP,但须要注意的是,这里返回的并非一个 IMP,而是 bucket_t 结构体指针。

  • 经过 buckets() 方法获取当前 cache_t 下全部的缓存
  • 经过 mask() 方法获取当前 cache_t 的缓存大小减一的值 mask_t
  • 而后把 mask_t 的值做为循环的索引。
  • do-while 循环里遍历整个 bucket_t,若是 key 为 0,说明当前索引位置上尚未缓存过方法,则须要中止循环,返回当前位置上的 bucket_t;若是 key 为要查询的 k,说明缓存命中了,则直接返回结果。
  • 这里的循环遍历是经过 cache_next 方法实现的,这个方法内部就是当前下标 imask_t 的值进行与操做,来实现索引更新的。

3、cache_t 探索后的疑问点

整个 cache_t 的工做流程,简略描述以下:

  • 当前查找的 IMP 没有被缓存,调用 cache_fill_nolock 方法进行填充缓存。
  • 当前查找的 IMP 已经被缓存了,而后判断缓存容量是否已经达到 3/4 的临界点
    • 若是已经到了临界点,则须要进行扩容,扩容大小为原来缓存大小的 2 倍。扩容后处于效率的考虑,会清空以前的内容,而后把当前要查找的 IMP 经过 cache_fill_nolock 方法缓存起来。
    • 若是没有到临界点,那么直接返回找到的 IMP

咱们梳理完 cache_t 的大体流程以后,咱们还有一些遗留问题没有解决,接下来一一来解决一下。

3.1 mask 的做用

咱们先回顾一下 mask 出如今了哪些地方:

setBucketsAndMask(newBuckets, newCapacity - 1);

void cache_t::setBucketsAndMask(struct bucket_t *newBuckets, mask_t newMask)
{
    mega_barrier();

    _buckets = newBuckets;
    
    mega_barrier();
    
    _mask = newMask;
    _occupied = 0;
}

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

首先,mask 是做为 cache_t 的属性存在的,它表明的是缓存容量的大小减一的值。这一点在 setBucketsAndMaskcapacity 方法中能够获得证明。

cache_fill_nolock {
    cache_key_t key = getKey(sel);
    
    bucket_t *bucket = cache->find(key, receiver);
}

find { 

    // Class points to cache. SEL is key. Cache buckets store SEL+IMP.
    // Caches are never built in the dyld shared cache.
    static inline mask_t cache_hash(cache_key_t key, mask_t mask) {
        return (mask_t)(key & mask);
    }
    
    static inline mask_t cache_next(mask_t i, mask_t mask) {
        return (i+1) & mask;
    }
}
复制代码

根据上面的伪代码,cache_fill_nolock 方法里面,会先根据要查找的 sel 强转成 cache_key_t 结构,这是由于 sel 其实为方法名:

而通过强转以后为:

也就是说最后缓存的 key 实际上是一个无符号长整型值,这样相对于直接拿字符串来做为键值,明显效率更高。

通过强转以后,把 key 传给 find 方法。而后会有一个 cache_hash 方法,其注释以下:

类指向缓存,SEL 是键,buckets缓存存储的是 SEL + IMP。 方法缓存永远不会存储在 dyld 共享缓存里面。

实际测试如上图所示,cache_hash 方法其实就是哈希算法,获得的是一个哈希值。拿到这个哈希值后就能够在哈希表中进行查询。在 find 方法中就是得到索引的起始值。

经过上图的测试咱们能够得出这里是使用的 LRU 缓存算法。

LRU 算法的全称是 Least Recently Used ,也就是最近最少使用策略。这个策略的核心思想就是先淘汰最近最少使用的内容。

3.2 capacity 的变化

capacity 的变化主要发生在扩容的时候,当缓存已经占满了四分之三的时候,会进行两倍原来缓存空间大小的扩容,这一步是为了不哈希冲突。

3.3 为何是在 3/4 时进行扩容

在哈希这种数据结构里面,有一个概念叫装载因子,装载因子是用来表示空位的多少。其公式为:

散列表的装载因子=填入表中的元素个数/散列表的长度
复制代码

装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会降低。 苹果这里设计的装载因子显然为 1 - 3/4 = 1/4 => 0.25 。 由于本质上方法缓存就是为了更快的执行效率,因此为了不发生哈希冲突,在采用开放寻址法的前提下,尽量小的装载因子能够提升散列表的性能。

/* Initial cache bucket count. INIT_CACHE_SIZE must be a power of two. */
enum {
    INIT_CACHE_SIZE_LOG2 = 2,
    INIT_CACHE_SIZE      = (1 << INIT_CACHE_SIZE_LOG2)
};

cache->reallocate(capacity, capacity ?: INIT_CACHE_SIZE);
复制代码

初始化的缓存大小是 1 左移 2,结果为 4。而后在 reallocate 方法进行一下缓存的从新开辟。这也就意味着初始的缓存空间大小为 4。

3.4 方法缓存是否有序

方法缓存是无序的,这是由于计算缓存下标是一个哈希算法:

static inline mask_t cache_hash(cache_key_t key, mask_t mask) {
    return (mask_t)(key & mask);
}
复制代码

经过 cache_hash 以后计算出来的下标并非有序的,下标值取决于 keymask 的值。

3.5 bucket 与 mask, capacity, sel, imp 的关系

一个类有一个属性 cache_t,而一个 cache_tbuckets 会有多个 bucket。一个 bucket 存储的是 impcache_key_t

mask 的值对于 bucket 来讲,主要是用来在缓存查找时的哈希算法。 而 capacity 则能够获取到 cache_tbucket 的数量。

sel 在缓存的时候是被强转成了 cache_key_t 的形式,更方便查询使用。 imp 则是函数指针,也就是方法的具体实现,缓存的主要目的就是经过一系列策略让编译器更快的执行消息发送的逻辑。

4、总结

  • OC 中实例方法缓存在类上面,类方法缓存在元类上面。
  • cache_t 缓存会提早进行扩容防止溢出。
  • 方法缓存是为了最大化的提升程序的执行效率。
  • 苹果在方法缓存这里用的是开放寻址法来解决哈希冲突。
  • 经过 cache_t 咱们能够进一步延伸去探究 objc_msgSend,由于查找方法缓存是属于 objc_msgSend 查找方法实现的快速流程。

咱们下一篇将开始探索 iOS 中方法的底层原理,敬请期待~

相关文章
相关标签/搜索