iOS探索 cache_t分析

3,919 阅读10分钟

欢迎阅读iOS探索系列(按序阅读食用效果更加)

写在前面

在上一篇文章中已经全面地介绍了类的结构,但是还剩下一个cache_t cache没有进行详细的介绍,本文就将从源码层面分析cache_t

一、初探cache_t

1.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
    
    class_rw_t *data() { 
        return bits.data();
    }
    ...
}

其中cache_t的结构如下

struct cache_t {
    struct bucket_t *_buckets;
    mask_t _mask;
    mask_t _occupied;
    ...
};

之前文章也说过,从cache_t的结构中可以得出它是由两个uint32_t类型的_mask_occupied以及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的属性和方法中可以看出它应该与imp有联系——事实上bucket_t作为一个桶,里面是用来装imp方法实现以及它的key

cache_t中的_buckets_mask_occupied从字面意思上理解为面具占据,但是我们不知道这三个的作用是否与他们的名字有关系,下面我们先从LLDB打印一些信息来看看

2.LLDB调试

objc源码准备好代码

#import <objc/runtime.h>

@interface FXPerson : NSObject
- (void)doFirst;
- (void)doSecond;
- (void)doThird;
@end

@implementation FXPerson
- (void)doFirst {}
- (void)doSecond {}
- (void)doThird {}
@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        FXPerson *p = [[FXPerson alloc] init];
        Class cls = object_getClass(p);
        
        [p doFirst];
        [p doSecond];
        [p doThird];
    }
    return 0;
}

_buckets是一个装imp方法实现的桶,那我们在方法调用的时候打个断点(上篇文章讲过,类中isa指针占8字节,superclass指针占8字节,只要拿到类的首地址+16字节就能得到cache_t的地址)

此时_mask为3,_occupied为1,我们继续打印_buckets

打印了多个$3只发现缓存了一个[NSObject init],心中不免有了一个想法

断点来到[p doSecond];一行(笔者这里重新跑项目了)

断点来到[p doThird];一行,得到如下数据:

断点处 _occupied _buckets包含方法
[p doFirst] 1 -[NSObject init]
[p doSecond] 2 -[NSObject init]、-[FXPerson doFirst]
[p doThird] 3 -[NSObject init]、-[FXPerson doFirst]、-[FXPerson doSecond]

上述数据可以得出_buckets是个装方法实现的桶子,_occupied数值是桶子中有多少个方法实现

等等,这里肯定有人还有疑问,FXPerson调用了alloc方法,怎么都没缓存——上一篇文章已经讲过了,alloc方法属于类方法,存在FXPerson元类中

本以为一切都顺顺利利的时候,意外发生了——断点走到下一行

_mask_occupied都发生了不可思议的变化,那么底层到底做了什么呢?为什么先前打印bucket[0]的时候全为空呢?

二、深入cache_t

0.找到切入点

已知_mask的值是增加了,所以我们找到cache_t中的mask_t mask()方法,结果只返回了_mask本身

mask_t cache_t::mask() 
{
    return _mask; 
}

继续搜索mask()方法,发现在capacity方法中有mask的相应操作,但是操作目的不是很明确

mask_t cache_t::capacity() 
{
    return mask() ? mask()+1 : 0; 
}

继续搜索capacity()方法,在expand方法中看到了capacity方法的有意义调用

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方法应该是个扩容方法,继续往上摸,摸到了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);
}

加个断点在函数调用栈中验证了我们找的方向是正确的

1.cache_fill_nolock

cache_fill_nolock方法比较复杂,笔者这里将一步步分析

①if (!cls->isInitialized()) return;

类是否初始化对象,没有就返回

②if (cache_getImp(cls, sel)) return;

传入clssel,如果在缓存中查找到imp就返回,不能就下一步

③cache_t *cache = getCache(cls);

调用getCache来获取cls的缓存对象

④cache_key_t key = getKey(sel);

通过getKey来获取到缓存的key——其实是将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();

如果⑦找到的bucketkey为0,那么_occupied++

⑨bucket->set(key, imp);

keyimp成对放入bucket

总结:

cache_fill_nolock先找到类的缓存cache,如果缓存cache为空就创建并覆盖;如果目标占用(缓存之后的占用大小newOccupied)大于缓存容量的四分之三,先扩容再装入对应key值的桶内bucket;否则直接装入对应key值的桶内bucket

分析完cache_fill_nolock主流程,再根据一些方法进行扩展

2.cache_t::reallocate

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);
    
    if (freeOld) {
        cache_collect_free(oldBuckets, oldCapacity);
        cache_collect(false);
    }
}

bucket_t *allocateBuckets(mask_t newCapacity)
{
    // Allocate one extra bucket to mark the end of the list.
    // This can't overflow mask_t because newCapacity is a power of 2.
    // fixme instead put the end mark inline when +1 is malloc-inefficient
    bucket_t *newBuckets = (bucket_t *)
        calloc(cache_t::bytesForCapacity(newCapacity), 1);

    bucket_t *end = cache_t::endMarker(newBuckets, newCapacity);

#if __arm__
    // End marker's key is 1 and imp points BEFORE the first bucket.
    // This saves an instruction in objc_msgSend.
    end->setKey((cache_key_t)(uintptr_t)1);
    end->setImp((IMP)(newBuckets - 1));
#else
    // End marker's key is 1 and imp points to the first bucket.
    end->setKey((cache_key_t)(uintptr_t)1);
    end->setImp((IMP)newBuckets);
#endif
    
    if (PrintCaches) recordNewCache(newCapacity);

    return newBuckets;
}

void cache_t::setBucketsAndMask(struct bucket_t *newBuckets, mask_t newMask)
{
    // objc_msgSend uses mask and buckets with no locks.
    // It is safe for objc_msgSend to see new buckets but old mask.
    // (It will get a cache miss but not overrun the buckets' bounds).
    // It is unsafe for objc_msgSend to see old buckets and new mask.
    // Therefore we write new buckets, wait a lot, then write new mask.
    // objc_msgSend reads mask first, then buckets.

    // ensure other threads see buckets contents before buckets pointer
    mega_barrier();

    _buckets = newBuckets;
    
    // ensure other threads see new buckets before new mask
    mega_barrier();
    
    _mask = newMask;
    _occupied = 0;
}
  • 先判断能否被释放(缓存是否为空的取反值)并保存
  • oldBuckets获取到当前bucket
  • 传入新的缓存容量allocateBuckets初始化bucket_t,保存在newBuckets
  • setBucketsAndMask做的操作: 用新创建的bucket保存,mask=newcapcity-1occupied置零(因为还没有方法缓存)
  • 如果缓存不为空(需要释放)则释放原先的bucketcapacity

为什么使用cache_collect_free消除记忆,而不是重新读写、内存拷贝的方式?一是重新读写不安全;二是抹掉速度快

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

    reallocate(oldCapacity, newCapacity);
}

enum {
    INIT_CACHE_SIZE_LOG2 = 2,
    INIT_CACHE_SIZE      = (1 << INIT_CACHE_SIZE_LOG2)
};

mask_t cache_t::capacity() 
{
    return mask() ? mask()+1 : 0; 
}
  • oldCapacity的值为mask+1
  • oldCapacity存在的情况下,newCapacityoldCapacity的两倍;否则取INIT_CACHE_SIZE
  • 这里的INIT_CACHE_SIZE二进制的100=>十进制的4
  • 创建并覆盖原来的缓存reallocate

4.cache_t::find

cache_t::find是 找对应的存储桶

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);
}
  • 通过buckets()方法获取当前cache_t下所有的缓存桶bucket
  • 通过mask()方法获取当前cache_t的缓存容量减一的值mask_t
  • key & mask计算出起始索引
  • begin赋值给i,用于切换索引
  • do-while循环里遍历整个bucket_t,如果key = 0,说明在索引i的位置上还没有缓存过方法,同样需要返回该bucket_t,用于中止缓存查询;如果取出来的bucket_tkey = k,则查询成功,返回该bucket_t
  • 通过cache_next返回i-1来更新索引,以此来查询散列表中的每一个元素(相当于绕圈)
  • 如果找不到证明缓存有问题,返回bad_cache

5.LRU算法

LRU算法的全称是Least Recently Used,也就是最近最少使用策略——这个策略的核心思想就是先淘汰最近最少使用的内容,在方法缓存中也用到了这种算法

  • 在扩容前,实例方法随便选择位置坐下
  • 在扩容后,新的实例方法找到最近最少使用的位置坐下并清掉之前的bucket

三、cache_t疑问点

1.mask的作用

  • mask是作为cache_t的属性存在的,它代表的是缓存容量的大小减一的值
  • mask对于bucket来说,主要是用来在缓存查找时的哈希算法

2.capacity的变化

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

3.为什么是在 3/4 时进行扩容

在哈希这种数据结构里面,有一个概念用来表示空位的多少叫做装载因子——装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降

负载因子是3/4的时候,空间利用率比较高,而且避免了相当多的Hash冲突,提升了空间效率

具体可以阅读HashMap的负载因子为什么默认是0.75?

4.方法缓存是否有序

static inline mask_t cache_hash(cache_key_t key, mask_t mask) 
{
    return (mask_t)(key & mask);
}

方法缓存是无序的,因为是用哈希算法来计算缓存下标——下标值取决于keymask的值

5.bucket与mask、capacity、sel、imp的关系

  • cls拥有属性cache_tcache_t中的buckets有多个bucket——存储着方法实现imp和方法编号sel强转成的key值cache_key_t
  • mask对于bucket来说,主要是用来在缓存查找时的哈希算法
  • capacity则可以获取到cache_tbucket的数量

缓存的主要目的就是通过一系列策略让编译器更快的执行消息发送的逻辑

写在后面

关于cache_t的内容虽然不多但还是蛮绕的,多读读源码会有更深的理解。下篇文章讲objc_msgsend,作为cache_fill_nolock前置方法,一定程序上会对cache_t的理解有所帮助