类的底层: cache_t详解

202 阅读7分钟

前面我们分析了objc_class结构体中的class_data_bits_t bits,今天我们来研究一下cache_t cache这个缓存。

// objc_class结构体
struct objc_class : objc_object {
    // 类对象isa指针,占8字节
    Class ISA;
    // 指向父类的指针,占8字节
    Class superclass;
    // cache缓存,占16字节
    cache_t cache;
    // 类对象中存储的数据
    class_data_bits_t bits;
    ...
    ...
}

cache源码分析

我们通过对类对象的内存首地址进行内存偏移16个字节查看一下cache_t cache的内存结构

创建一个MyClass

@interface MyClass : NSObject

- (void)method1;
- (void)method2;
- (void)method3;
- (void)method4;

@end

@implementation MyClass
- (void)method1 {
    NSLog(@"%s", __func__);
}

- (void)method2 {
    NSLog(@"%s", __func__);
}

- (void)method3 {
    NSLog(@"%s", __func__);
}

- (void)method4 {
    NSLog(@"%s", __func__);
}

@end

断点调试打印一下MyClass类对象的内存地址

截屏2022-05-09 下午4.55.13.png

类对象的内存首地址为0x100008188,偏移16个字节得出cache的内存首地址为0x100008198 = 0x100008188 + 0x10。获取一下cache里面存储的内容

截屏2022-05-09 下午5.02.55.png

获取到cache里面的内容发现并不好观察,我们去源码里查找看一下cache_t这个结构体

struct cache_t {
private:
    explicit_atomic<uintptr_t> _bucketsAndMaybeMask;
    union {
        struct {
            explicit_atomic<mask_t>    _maybeMask;
#if __LP64__
            uint16_t                   _flags;
#endif
            uint16_t                   _occupied;
        };
        explicit_atomic<preopt_cache_t *> _originalPreoptCache;
    };
    ...
    ...
public:
    struct bucket_t *buckets() const;
    void insert(SEL sel, IMP imp, id receiver);
    ...
    ...
}

一、结构解析

从源码里我们可以看到cache_t中我们刚才打印的值,存储的是_bucketsAndMaybeMask一个联合体。在内存中占16个字节大小

  • 1、其中_bucketsAndMaybeMaskuintptr_t类型,也就是说_bucketsAndMaybeMaskunsigned long类型,内存大小占8个字节。
#ifndef __has_attribute
typedef unsigned long           uintptr_t;
#else
typedef unsigned long           uintptr_t;
#endif /* __has_attribute */
  • 2、联合体中_maybeMaskmask_t类型,占4个字节;_flags占2个字节;_occupied占2个字节;_originalPreoptCache指针占8个字节。总体来说,联合体整体占8个字节。
typedef unsigned int uint32_t;
#if __LP64__
typedef uint32_t mask_t// x86_64 & arm64 asm are less efficient with 16-bits
#else
typedef uint16_t mask_t;
#endif
typedef unsigned short uint16_t;

同时还提供有两个方法buckets()void insert(SEL sel, IMP imp, id receiver);

二、cache的插入和扩容

从名字我们也可以看出void insert(SEL sel, IMP imp, id receiver);方法是向cache缓存中插入数据的,从参数里可以看出缓存插入的值是方法,也就是说cache是用来缓存方法的。接着我们进入insert方法中去看一下

void cache_t::insert(SEL sel, IMP imp, id receiver)
{
    runtimeLock.assertLocked();
    
        // Never cache before +initialize is done
    if (slowpath(!cls()->isInitialized())) {
        return;
    }
    
    if (isConstantOptimizedCache()) {
        _objc_fatal("cache_t::insert() called with a preoptimized cache for %s",
                    cls()->nameForLogging());
    }

#if DEBUG_TASK_THREADS
    return _collecting_in_critical();
#else
#if CONFIG_USE_CACHE_LOCK
    mutex_locker_t lock(cacheUpdateLock);
#endif

    ASSERT(sel != 0 && cls()->isInitialized());


        // Use the cache as-is if until we exceed our expected fill ratio.
    mask_t newOccupied = occupied() + 1;
    unsigned oldCapacity = capacity(), capacity = oldCapacity;
    if (slowpath(isConstantEmptyCache())) {
            // Cache is read-only. Replace it.
        if (!capacity) capacity = INIT_CACHE_SIZE;
        reallocate(oldCapacity, capacity, /* freeOld */false);
    }
    else if (fastpath(newOccupied + CACHE_END_MARKER <= cache_fill_ratio(capacity)))
    {
    // Cache is less than 3/4 or 7/8 full. Use it as-is.
    }
#if CACHE_ALLOW_FULL_UTILIZATION
    else if (capacity <= FULL_UTILIZATION_CACHE_SIZE && newOccupied + CACHE_END_MARKER <= capacity) {
        // Allow 100% cache utilization for small buckets. Use it as-is.
    }
#endif
    else {
        capacity = capacity ? capacity * 2 : INIT_CACHE_SIZE;
        if (capacity > MAX_CACHE_SIZE) {
            capacity = MAX_CACHE_SIZE;
        }
        reallocate(oldCapacity, capacity, true);
    }
    
        //bucket_t 存储方法的hash表
    bucket_t *b = buckets();
    mask_t m = capacity - 1;
    mask_t begin = cache_hash(sel, m);
    mask_t i = begin;

        // Scan for the first unused slot and insert there.
        // There is guaranteed to be an empty slot.
    do {
        if (fastpath(b[i].sel() == 0)) {
            incrementOccupied();
            b[i].set<Atomic, Encoded>(b, sel, imp, cls());
            return;
        }
        if (b[i].sel() == sel) {
            // The entry was added to the cache by some other thread
            // before we grabbed the cacheUpdateLock.
            return;
        }
    } while (fastpath((i = cache_next(i, m)) != begin));

    bad_cache(receiver, (SEL)sel);
#endif // !DEBUG_TASK_THREADS
}

1、缓存写入

void insert(SEL sel, IMP imp, id receiver);方法内我们看到有个bucket_t *b = buckets();,还有个do...while循环,在循环内部b变量调用了set方法。我们先看一下b变量存储的内容,进入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__
    explicit_atomic<uintptr_t> _imp;
    explicit_atomic<SEL> _sel;
#else
    explicit_atomic<SEL> _sel;
    explicit_atomic<uintptr_t> _imp;
#endif
    ...
    ...
    
public:
    inline SEL sel() const {return _sel.load(memory_order_relaxed);}
    
    inline IMP imp(UNUSED_WITHOUT_PTRAUTH bucket_t *base, Class cls) const {
        uintptr_t imp = _imp.load(memory_order_relaxed);
        if (!imp) return nil;
#if CACHE_IMP_ENCODING == CACHE_IMP_ENCODING_PTRAUTH
        SEL sel = _sel.load(memory_order_relaxed);
        return (IMP)
            ptrauth_auth_and_resign((const void *)imp,
                                    ptrauth_key_process_dependent_code,
                                    modifierForSEL(base, sel, cls),
                                    ptrauth_key_function_pointer, 0);
#elif CACHE_IMP_ENCODING == CACHE_IMP_ENCODING_ISA_XOR
        return (IMP)(imp ^ (uintptr_t)cls);
#elif CACHE_IMP_ENCODING == CACHE_IMP_ENCODING_NONE
        return (IMP)imp;
#else
#error Unknown method cache IMP encoding.
#endif
    }
    ...
    ...
};

bucket_t结构体内我们看到bucket_t中存储的是sel和imp,也就是说bucket_t中存储的就是类的方法。同时还提供了两个方法:sel()来获取方法名,imp(UNUSED_WITHOUT_PTRAUTH bucket_t *base, Class cls)来获取方法的函数实现。

知道bucket_t中存储的是方法之后,再接着看do...while循环中调用的set方法中做了什么操作。

截屏2022-05-09 下午5.54.07.png

在set方法中其实就是在保存类的方法,而所插入的下标i是通过cache_hash(sel, m)来获取的

截屏2022-05-09 下午6.01.03.png

其中sel我们知道是方法名,而m = capacity - 1capacity又是通过capacity()方法获取的

unsigned oldCapacity = capacity(), capacity = oldCapacity;

capacity()方法内又调用了mask()方法

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

mask()方法内只做了一个操作,就是获取_maybeMask的值。而_maybeMask = bucket_t的长度 - 1

mask_t cache_t::mask() const
{
    return _maybeMask.load(memory_order_relaxed);
}

也就是说capacity = bucket_t的长度m = bucket_t的长度 - 1。m的值了解了,我们再看一下cache_hash(sel, m)方法内的操作

static inline mask_t cache_hash(SEL sel, mask_t mask) 
{
    uintptr_t value = (uintptr_t)sel;
#if CONFIG_USE_PREOPT_CACHES
    value ^= value >> 7;
#endif
    return (mask_t)(value & mask);
}

其中uintptr_t value = (uintptr_t)sel;sel转换得到的value值是一个比较大的数字。对两个数进行&得到的值不会超过较小的那个数。所以value & mask得到的返回值最大就是mask,也就是bucket_t的长度 - 1set插入下标的获取我们知道了之后还有一个cache_next(i, m)方法

static inline mask_t cache_next(mask_t i, mask_t mask) {
    return (i+1) & mask;
}

cache_next很简单就是查找下一个存储位置,那么至此do...while循环中的逻辑就很清晰了

截屏2022-05-09 下午6.41.45.png

2、cache扩容

① cache扩容引出

既然我们知道了方法是缓存在bucket_t中,那我们去类对象中找一下。前面我们分析得知cache_t中提供的buckets()方法可以获取到bucket_t,调用一下buckets()方法

截屏2022-05-10 下午5.26.27.png

得到bucket_t的地址之后,我们再通过bucket_t中提供的sel()方法来获取缓存的方法名

截屏2022-05-10 下午5.34.36.png

我们在缓存中找到了两个方法respondsToSelector:class,但是这两个方法在代码中并没有去调用。这是什么时候调用的呢?带着这个疑问我们在代码中调用一下p对象的方法method1,然后再打印一下bucket_t中缓存的方法

截屏2022-05-10 下午5.42.11.png

bucket_t中只找到了一个class方法,method1不仅没有找到,刚才打印的respondsToSelector:方法也丢失了。这个现象出现的原因就是因为cache的扩容

insert方法中do...while循环前我们可以看到这段代码的执行

    mask_t newOccupied = occupied() + 1;
    unsigned oldCapacity = capacity(), capacity = oldCapacity;
    if (slowpath(isConstantEmptyCache())) {
            // Cache is read-only. Replace it.
        if (!capacity) capacity = INIT_CACHE_SIZE;
        reallocate(oldCapacity, capacity, /* freeOld */false);
    }
    else if (fastpath(newOccupied + CACHE_END_MARKER <= cache_fill_ratio(capacity)))
    {
    // Cache is less than 3/4 or 7/8 full. Use it as-is.
    }
#if CACHE_ALLOW_FULL_UTILIZATION
    else if (capacity <= FULL_UTILIZATION_CACHE_SIZE && newOccupied + CACHE_END_MARKER <= capacity) {
        // Allow 100% cache utilization for small buckets. Use it as-is.
    }
#endif
    else {
        capacity = capacity ? capacity * 2 : INIT_CACHE_SIZE;
        if (capacity > MAX_CACHE_SIZE) {
            capacity = MAX_CACHE_SIZE;
        }
        reallocate(oldCapacity, capacity, true);
    }
② 条件判断1

第一个条件判断if (slowpath(isConstantEmptyCache()))判断当缓存为空时,即第一次向缓存中插入数据时会执行if (!capacity) capacity = INIT_CACHE_SIZE;reallocate(oldCapacity, capacity, false);其中isConstantEmptyCache()如下

bool cache_t::isConstantEmptyCache() const
{
    return
        occupied() == 0  &&
        buckets() == emptyBucketsForCapacity(capacity(), false);
}

我们前面已经分析了capacity = bucket_t的长度,那么当buckets() = 0时,capacity会进行初始化赋值为INIT_CACHE_SIZE。而INIT_CACHE_SIZE相关计算如下

截屏2022-05-10 下午3.24.42.png 截屏2022-05-10 下午3.25.02.png 截屏2022-05-10 下午3.25.37.png

CACHE_END_MARKER__x86_64__架构下值为1,在__arm64__架构下值为0。那么INIT_CACHE_SIZE_LOG2的值在__x86_64__架构下为2,在__arm64__架构下为1。所以在__x86_64__架构下INIT_CACHE_SIZE值为4 = (1 << 2),在__arm64__架构下INIT_CACHE_SIZE值为2 = (1 << 1)。那么capacity初始化时在__x86_64__架构下capacity = 4,在__arm64__架构下capacity = 2。也就是说当cache缓存为空时,在__x86_64__架构下会开辟一个长度为4的桶子,在__arm64__架构下会开辟一个长度为2的桶子。初始化完了之后会调用reallocate(oldCapacity, capacity, false);方法把capacityoldCapacity传进去。reallocate方法内部实现如下,当第一次缓存的时候,没有老的桶子,所以初始化时freeOld传入的是false。

截屏2022-05-10 下午3.48.24.png

③ 条件判断2

第二个条件判断fastpath(newOccupied + CACHE_END_MARKER <= cache_fill_ratio(capacity))。其中newOccupied = occupied() + 1occupied()如下

mask_t cache_t::occupied() const
{
    return _occupied;
}

那么newOccupied = 缓存的大小,而CACHE_END_MARKERcache_fill_ratio在不同架构下的表述如下

截屏2022-05-10 下午4.01.50.png

其中cache_fill_ratio__x86_64__架构下为bucket_t长度的4分之3,在__arm64__ && __LP64__架构下为bucket_t长度的8分之7。也就是说在__arm64__ && __LP64__架构下缓存的大小 <= 桶子长度的7/8和在__x86_64__架构下缓存的大小 < 桶子长度的3/4,则什么也不干。

④ 条件判断3

第三个条件判断capacity <= FULL_UTILIZATION_CACHE_SIZE && newOccupied + CACHE_END_MARKER <= capacity。其中CACHE_ALLOW_FULL_UTILIZATION的定义在__arm64__ && __LP64__的架构下

截屏2022-05-10 下午4.24.34.png

那就是在__arm64__ && __LP64__架构下才会有可能执行这块代码。capacitynewOccupiedCACHE_END_MARKER的定义我们前面已经分析了。其中FULL_UTILIZATION_CACHE_SIZE的值计算如下

截屏2022-05-10 下午4.32.10.png

FULL_UTILIZATION_CACHE_SIZE值为8 = 1 << 3,也就是说在__arm64__ && __LP64__架构下当桶子的长度 <= 8 且 缓存的大小 <= 桶子的长度。则什么也不干。允许小桶的缓存利用率为100%

⑤ 条件判断4

以上三种条件都不满足的情况下capacity = capacity ? capacity * 2 : INIT_CACHE_SIZE;桶子进行2倍扩容。且有个极限值判断

if (capacity > MAX_CACHE_SIZE) {
    capacity = MAX_CACHE_SIZE;
}

截屏2022-05-10 下午4.53.51.png

极限值MAX_CACHE_SIZE = 1 << 16。接着会执行reallocate(oldCapacity, capacity, true);此时freeOld传入的是true。也就是这一步会把老的桶子释放掉。

⑥ 案例

前面我们还遗留了一个问题respondsToSelector:class方法到底是在什么时候调用的,既然我们cache必然会调用insert(SEL sel, IMP imp, id receiver)方法插入数据,那么我们就在此方法内打印一下进入缓存的方法

截屏2022-05-10 下午7.12.21.png

断点调试在调用method1方法前并没有发现respondsToSelector:class方法的调用

截屏2022-05-10 下午7.14.44.png

此时再打印一下MyClass类对象的地址

截屏2022-05-10 下午7.17.22.png

此时发现respondsToSelector:class被调用了,也就是说这两个方法是LLDB调试所调用的方法。同时我们前面所遇到的respondsToSelector:method1方法丢失的问题应该也能想到是因为cache扩容导致的。

总结:cache扩容规则

  • 1、cache桶子开辟的初始长度在arm64架构下为2,在x86_64架构下为4
  • 2、在x86_64架构下:当缓存的大小等于桶子长度的3/4的时候进行2倍扩容
  • 3、在arm64架构下:当缓存的大小大于桶子长度的7/8的时候,进行2倍扩容;当桶子长度小于等于8且未缓存满时,不会扩容