五:类原理之cache_t的探索

252 阅读10分钟

在之前,我们探索了类的结构,成员变量,属性和方法是如何存储的,但是我们似乎漏了什么? 类的结构中还剩一个cache_t我们还没有探究,今天我们就来探究在cache_t

struct objc_class : objc_object {
    // Class ISA;     8字节
    Class superclass; // 8字节
    cache_t cache;      // 16字节       // 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初探

1. 定义

首先我们看看cache_t在源码中如何定义的

struct cache_t {
    struct bucket_t *_buckets; // 结构体 8字节
    mask_t _mask;              // mask_t 是 uint32_t 4字节
    mask_t _occupied;          // 4字节
}

2. 成员

从源码中可以看出cache_t一共有三个成员:

    1. buckets,这是一个bucket_t类型的结构体指针
    1. _mask,这是一个mask_t类型,和hash容量相关
    1. _occupied,这也是一个mask_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);
};

现在我们大致的了解了cache_t的结构和定义,但是我们还将继续探索下去系统究竟是怎么缓存的呢?

二:LLDB找到问题

既然要研究方法是怎么缓存的,那么首先我们现在WYPerson类中定义三个方法

@interface WYPerson : NSObject
- (void)eat;
-(void)jump;
- (void)smile;
@end
@implementation WYPerson
-(void)eat
{
    NSLog(@"eat");
}
-(void)jump
{
    NSLog(@"jump");
}
- (void)smile
{
    NSLog(@"smile");
}
@end

分别在三个方法前打上断点

WYPerson *p1 = [WYPerson alloc]init];
Class pClass = [p1 class];
[p1 eat]; // 断点
[p1 jump]; // 断点
[p1 smile]; // 断点
NSLog(@"%@ -- %p",p1,pClass);

之前的文章我们通过内存偏移的方式打印出了ro,现在我们用同样的方式,去获取cache_t

当运行到eat方法,打印cache_t,注意_occupied = 2,确实缓存有[NSObject class]()[NSObject init]()方法。或许有人会有疑问,为什么没有缓存alloc方法呢?原因在于alloc是类方法,存在元类的cache_t中。

(lldb) p/x pClass
(Class) $0 = 0x0000000100001420 WYPerson
(lldb) p (cache_t *)0x0000000100001430
(cache_t *) $1 = 0x0000000100001430
(lldb) p *$1
(cache_t) $2 = {
  _buckets = 0x0000000101954fc0
  _mask = 3
  _occupied = 2
}
(lldb) p $3[0]
(bucket_t) $5 = {
  _key = 4309273064
  _imp = 0x00000001003916b0 (libobjc.A.dylib`::-[NSObject class]() at NSObject.mm:2010)
}
(lldb) p $3[1]
(bucket_t) $6 = {
  _key = 4309273101
  _imp = 0x0000000100393520 (libobjc.A.dylib`::-[NSObject init]() at NSObject.mm:2330)
}

当运行到jump方法,打印cache_t,注意_occupied = 3,确实缓存有[NSObject class]()[NSObject init](),以及[WYPerson eat]方法

(lldb) p *$1
(cache_t) $3 = {
  _buckets = 0x0000000100f34610
  _mask = 3
  _occupied = 3
}
(lldb) p $12._buckets
(bucket_t *) $13 = 0x000000010188d5f0
(lldb) p *$13
(bucket_t) $14 = {
  _key = 4309273064
  _imp = 0x00000001003916b0 (libobjc.A.dylib`::-[NSObject class]() at NSObject.mm:2010)
}
(lldb) p $13[0]
(bucket_t) $15 = {
  _key = 4309273064
  _imp = 0x00000001003916b0 (libobjc.A.dylib`::-[NSObject class]() at NSObject.mm:2010)
}
(lldb) p $13[1]
(bucket_t) $16 = {
  _key = 4309273101
  _imp = 0x0000000100393520 (libobjc.A.dylib`::-[NSObject init]() at NSObject.mm:2330)
}
(lldb) p $13[2]
(bucket_t) $17 = {
  _key = 4294971286
  _imp = 0x0000000100000da0 (objc-test`-[WYPerson eat] at WYPerson.m:12)
}
(lldb) p $13[3]
(bucket_t) $18 = {
  _key = 0
  _imp = 0x0000000000000000
}

当运行到smile方法,打印cache_t,注意_occupied = 1,但是里面缓存的方法却一个都没有,你没有看错,真的一个都没有。

(lldb) p *$1
(cache_t) $4 = {
  _buckets = 0x00000001010709b0
  _mask = 7
  _occupied = 1
}
(lldb) p $2._buckets
(bucket_t *) $3 = 0x0000000102201f80
(lldb) p *$3
(bucket_t) $4 = {
  _key = 0
  _imp = 0x0000000000000000
}
(lldb) p $3[0]
(bucket_t) $5 = {
  _key = 0
  _imp = 0x0000000000000000
}
(lldb) p $3[1]
(bucket_t) $6 = {
  _key = 0
  _imp = 0x0000000000000000
}

smile执行完成之后,打印cache_t,注意_occupied = 2,从中也的确找到了[WYPerson jump][WYPerson smile]

(lldb) p *$1
(cache_t) $5 = {
  _buckets = 0x00000001010709b0
  _mask = 7
  _occupied = 2
}
......shenglue部分无用的打印
(lldb) p $10[2]
(bucket_t) $14 = {
  _key = 4294971290
  _imp = 0x0000000100000dd0 (objc-test`-[WYPerson jump] at WYPerson.m:16)
}
(lldb) p $10[7]
(bucket_t) $19 = {
  _key = 4294971295
  _imp = 0x0000000100000e00 (objc-test`-[WYPerson smile] at WYPerson.m:20)
}

通过上面的打印,我们发现似乎和我们预想的不太一样啊,按理说应该会出现1,2,3,4,5的啊,来一个方法缓存一个,再来个方法再缓存一个。现实是很骨感,而且我们惊悚的发现前面缓存的方法没有了,虽然没有了,但是_occupied确实是等于缓存的方法数。这难道就结束了吗?显然没有!

三:源码查漏补缺

通过上面的分析,_occupied取决于缓存的方法数量,而_buckets里面存的就是方法的信息,_mask3变到7发生了什么呢? 我们只能从cache_t中与mask相关的入手了,我们点击mask()函数,来到了cache_t的源文件

struct cache_t {
    struct bucket_t *_buckets; // 结构体 8字节
    mask_t _mask;              // mask_t 是 uint32_t 4字节
    mask_t _occupied;          // 4字节

public:
    struct bucket_t *buckets();
    mask_t mask(); // 从这个mask()入手
    mask_t occupied();

cache_t中,mask()仅仅做了一个返回的操作

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

显然不会那么简单,全局搜索mask(),发现capacity()方法

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

从字面来看,capacity()大概率跟容量有关,我们看看什么地方调用capacity()

1.缓存扩容

贴出关键的一处

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);
}

int32_t oldCapacity = capacity();

  • 通过capacity()获取当前容量的大小

uint32_t newCapacity = oldCapacity ? oldCapacity*2 : INIT_CACHE_SIZE;

enum {
    INIT_CACHE_SIZE_LOG2 = 2,
    INIT_CACHE_SIZE      = (1 << INIT_CACHE_SIZE_LOG2)
};
  • 判断当前的容量大小,如果为0,则赋值为 INIT_CACHE_SIZE,通过枚举可知 INIT_CACHE_SIZE 初始值为4,如果当前容量大小不为 0,则直接翻倍,newCapacity扩容到原来的2倍。

if ((uint32_t)(mask_t)newCapacity != newCapacity)

  • 检查容量溢出,如果无法再扩容,还是保持原来的容量,但是会重新分配空间,reallocate方法就是开辟空间,在内部有一个释放旧空间的操作。
if (freeOld) {
        cache_collect_free(oldBuckets, oldCapacity);
        cache_collect(false);
    }

2. 缓存的入口

顺着扩容这根线索,我们看看在什么时候扩容的呢,于是我们找到了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 newOccupied
    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);
   }
}

下面对这个方法作出相应的解释

  • cacheUpdateLock.assertLocked();加锁的判断
  • if (cache_getImp(cls, sel)) return; 取缓存,如果已经有缓存直接返回
  • cache_t *cache = getCache(cls)** 先拿到当前类的缓存对象.也就是cache_t
  • cache_key_t key = getKey(sel);根据sel 拿到keygetKey内部有一个强转操作
  • mask_t newOccupied = cache->occupied() + 1;cache 已经占用的基础上进行加 1,得到的是新的缓存占用大小 newOccupied
  • mask_t capacity = cache->capacity();获取当前hash表的容量

接下来就是一些判断条件

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 是否是新的,如果是的话,就在缓存里面增加一个占用大小。然后把 key 和 imp 放到里面。

到此为止,我们大概的了解了cache_fill_nolock中的操作,下面就是深入这些方法。

3. 开辟缓存内存空间

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);
    }
}

void cache_t::setBucketsAndMask(struct bucket_t *newBuckets, mask_t newMask)
{
    mega_barrier();// 异常处理
    _buckets = newBuckets;
    mega_barrier();// 异常处理
    _mask = newMask;
    _occupied = 0;
}

reallocate方法中,系统干了三件事情:

    1. 根据传入newCapacity开辟一块新的内存空间
    1. 更新cache成员变量,这一步在setBucketsAndMask(newBuckets, newCapacity - 1);中实现,你会发现去掉异常处理其实结构和cache_t一样,但是需要注意_buckets = newBuckets; 现在_buckets指向的是新开辟的内存空间了。而且_mask也是根据外界传入重新赋值。
    1. 释放旧空间

注意: 在最上面我们打印的时候曾经出现过_mask为刚开始的 3 变成后来的 7,其实在这个方法中我们找到答案。在setBucketsAndMask(newBuckets, newCapacity - 1);更新成员的时候,系统有一个newCapacity - 1的操作,所以我们看到的容量将会是 3/7/15....

4. 查找缓存

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);
}

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;
}

这里很明显看出,存储缓存是通过hash表来实现的。实际上在cache_t中的结构体指针_buckets其实就是hash表的起始地址,最终find返回的是bucket_t,里面有key(sel)和IMP。

  • bucket_t *b = buckets();mask_t m = mask();分别直接返回_buckets和_mask
  • mask_t begin = cache_hash(k, m); 这句其实就是调用cache_hash方法,key即为sel,这一步是通过key(sel)与mask(掩码)进行按位与操作之后得到sel在hash表中的索引并赋值得begin变量
  • i = begin 进行赋值,相当于从i开始找
  • b[i] 是取出hash表这个索引位置的bucket_t对象,如果key是否和入参k相等,相等就命中缓存,返回bucket_t对象的地址 &b[i]。
  • (i = cache_next(i, m)) != begin,其实说白了就是从begin位置开始查找,然后i-1的位置继续找,循环找一圈,如果一圈都没有找到,就会走bad_cache,进行相应的异常处理 这个查找过程可以用一张图来概括

四:总结

整篇文章的思路都是基于探索的一个过程,站在一个探索的角度,而非上帝视角,一上来就知道整个流程。

  • mask()
  • capacity()
  • expand()
  • cache_fill_nolock
  • reallocate
  • find

下面我用一张思维导图来总结下缓存的正确流程,相信结合探索的过程,我们将理解的更加深刻

不积跬步无以至千里,不积小流无以成江海