iOS - 底层原理cache_t结构分析

192 阅读5分钟

一.cache_t结构分析

Objective_C层,一切类均继承自NSObject。对应到底层,类(objc_class)继承自objc_object。查看源码objc_runtime_new.h,结构体objc_class的内部结构。见下图:

1.png

其中isa来自父类objc_object,占8个字节superclass8个字节cache占用16个字节cache_t中存储了类的一些缓存信息。cache_t结构体的部分源码见下图:

2.png

1. cache_t结构体大小

解读cache_t源码,其提供了两个属性,_bucketsAndMaybeMask和一个联合体,其中_bucketsAndMaybeMaskuintptr_t泛型,占8个字节,是一个指针地址。联合体中包含一个结构体和一个指针,联合体也占用8个字节cache_t一共占用16字节的内存空间。

2. _bucketsAndMaybeMask

_bucketsAndMaybeMask确切的说,不单单是一个bucket_t的地址,而是一个掩码,包含了首个bucket_t的地址和mask(针对真机环境)。bucket_t存储了当前缓存一个方法的方法编号方法实现。针对arm64真机环境,maskbucket进行了掩码运算,将maskbuckets放在了一起,优化减少了占用空间,_bucketsAndMaybeMask占用8个字节,共64位。其中前16位存储mask后48位存储buckets

 _bucketsAndMaybeMask.store(((uintptr_t)newMask << maskShift) | (uintptr_t)newBuckets, memory_order_relaxed);
 
static constexpr uintptr_t maskShift = 48;

bucket_t结构见下图:

3.png bucket_t中存储了类对象的方法编号_sel,及其指向方法实现的地址指针_imp。同样对环境进行了区分,不同的区别在于selimp的顺序不一致。

3. 架构区分

针对不同的系统架构提供了不同的解决方案。机型区分如下图:

4.png 针对不同架构,配置参数的设置区分:

#if CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_OUTLINED
    // _bucketsAndMaybeMask is a buckets_t pointer
    // _maybeMask is the buckets mask

    static constexpr uintptr_t bucketsMask = ~0ul;
    static_assert(!CONFIG_USE_PREOPT_CACHES, "preoptimized caches not supported");
#elif CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_HIGH_16_BIG_ADDRS
    static constexpr uintptr_t maskShift = 48;
    static constexpr uintptr_t maxMask = ((uintptr_t)1 << (64 - maskShift)) - 1;
    static constexpr uintptr_t bucketsMask = ((uintptr_t)1 << maskShift) - 1;
    
    static_assert(bucketsMask >= MACH_VM_MAX_ADDRESS, "Bucket field doesn't have enough bits for arbitrary pointers.");
#if CONFIG_USE_PREOPT_CACHES
    static constexpr uintptr_t preoptBucketsMarker = 1ul;
    static constexpr uintptr_t preoptBucketsMask = bucketsMask & ~preoptBucketsMarker;
#endif
#elif CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_HIGH_16
    // _bucketsAndMaybeMask is a buckets_t pointer in the low 48 bits
    // _maybeMask is unused, the mask is stored in the top 16 bits.

    // How much the mask is shifted by.
    static constexpr uintptr_t maskShift = 48;

    // Additional bits after the mask which must be zero. msgSend
    // takes advantage of these additional bits to construct the value
    // `mask << 4` from `_maskAndBuckets` in a single instruction.
    static constexpr uintptr_t maskZeroBits = 4;

    // The largest mask value we can store.
    static constexpr uintptr_t maxMask = ((uintptr_t)1 << (64 - maskShift)) - 1;
    
    // The mask applied to `_maskAndBuckets` to retrieve the buckets pointer.
    static constexpr uintptr_t bucketsMask = ((uintptr_t)1 << (maskShift - maskZeroBits)) - 1;
    
    // Ensure we have enough bits for the buckets pointer.
    static_assert(bucketsMask >= MACH_VM_MAX_ADDRESS,
            "Bucket field doesn't have enough bits for arbitrary pointers.");

二.lldb探索cache_t

定义一个类JHSPerson,调用了四个对象方法,设置三个断点,分别在这三个断点处查看cache_t的变化情况。(此种方式操作较麻烦,但是相对比较容易理解)见下面代码:

5.png

1.三断点调试

运行程序,在断点处,分别分析cache_t的数据存储情况。

  • 断点1——方法saySomething1saySomething2还没有执行,此时查看cache_t的存储情况。获取cache_t数据后,打印数据内容,此时mask=0occupied=0。见下图:

6.png

  • 断点2——继续执行代码,到达断点2处,方法saySomething1saySomething2已被执行,查看此时cache_t的存储情况mask=3occupied=2。见下图:

7.png 在执行两个方法后,通过调用buckets()方法,获取存储容器中首个bucket的地址,$4即为buckets存储空间的首地址,第一个元素存储着方法saySomething2。根据地址偏移,下标+1,获取的bucket,得到saySomething1。见下图:

8.png

9.png

  • 断点3——继续执行代码,到达断点3处,方法saySomething3saySomething4已被执行,查看此时cache_t的存储情况,mask=7occupied=2。见下图:

10.png

断点3处,继续打印buckets中的bucket。根据首地址偏移,最终找到了saySomething3saySomething4,但是无法获取saySomething2saySomething1, 这个结果和设想的完全不一样!。见下图:

11.png

2.问题思考

  • _mask是什么?_occupied是什么?

  • 随着方法的执行,_occupied_mask的变化为0-0 -> 2-3 -> 2-7,为什么?

  • buckets中的数据丢失,无法找到sayHello1sayHello2?为什么数据存储是不连续的?

三.底层探索

1.探究思路

思路:决定一个类功能的是函数,所以从cache_t的函数中去寻找突破口

cache_t结构体中,有一个方法void incrementOccupied();,增加占用,内部实现为:_occupied++;,很容易理解:向cache_t中插入内容,占用数加1

全局搜索incrementOccupied()方法,只有一个地方用到了该方法,向cache_t中插入数据,cache_t::insert方法。参数有:方法编号sel方法实现地址指针imp消息接受者。见下图:

12.png

继续全局搜索cache_t::insert方法找到了一段非常重要的注释,解读注释:cache_t分为cache读取cache写入两个点。见下图:

13.png

通过注释可以了解,objc_msgSendcache_getImp会进行缓存数据的读取,cache_t::insert会进行缓存数据的插入创建。

2.insert方法研究

cache_t::insert方法关键步骤,见下图。整体可以分为两个部分,第一步进行容器的初始化工作,第二步进行数据插入

14.png

面详细解读insert流程。

1.容器初始化

设置断点,确保调用saySomething1方法进入断点,lldb输出当前类clsJHSPerson,方法编号selsaySomething1。见下图:

15.png 调用occupied()方法lldb调试输出可发现,当前占用数为0,新的占用数newOcuupied = 1;可以理解初次插入,当前容器占用为空。所以首次进入缓存为空,会进行容器的创建!见下图:

16.png

默认初始化容量:4,即1 << INIT_CACHE_SIZE_LOG2 (1<<2),然后调用reallocate()方法进行初始化。见下图:

17.pngsetBucketsAndMask中,会对内存空间进行开辟,同时设置当前_occupied = 0。见下面源码:

#elif CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_HIGH_16 || CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_HIGH_16_BIG_ADDRS

void cache_t::setBucketsAndMask(struct bucket_t *newBuckets, mask_t newMask)
{
    uintptr_t buckets = (uintptr_t)newBuckets;
    uintptr_t mask = (uintptr_t)newMask;

    ASSERT(buckets <= bucketsMask);
    ASSERT(mask <= maxMask);

    _bucketsAndMaybeMask.store(((uintptr_t)newMask << maskShift) | (uintptr_t)newBuckets, memory_order_relaxed);
    _occupied = 0;
}

从上面的源码中可以看出,_bucketsAndMaybeMask中存储的内容,是newMasknewBucketsnewMask是容量减1newBuckets是新开辟的内存空间地址。

2.数据插入

容器完成初始化后,进行首次数据插入前,occupied()占用数还为0。见下图:

18.png

初始化mask为当前容量减1,即mask = capacity - 1;,并通过cache_hash方法进行hash运算sel & mask,获取哈希下标,即sel存储下标hash下标算法源码:

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

}

所以这里也就解释了,为什么buckets是无序的,因为容器中数据并不是一个有序存储的,方法的存储是通过hash下标存储在容器中。 同时,为避免hash冲突,进行do while循环处理。如果发现hash下标已经被占用,会调用cache_next方法,重新计算hash下标再进行存储。hash下标容错算法:

#if CACHE_END_MARKER

static inline mask_t cache_next(mask_t i, mask_t mask) {

    return (i+1) & mask;

}

#elif __arm64__

static inline mask_t cache_next(mask_t i, mask_t mask) {

    return i ? i-1 : mask;

}

#else

#error unexpected configuration

#endif

针对不同的环境提供了不同容错算法,如果容错算法算出的新下标与一开始提供的小标一致,也就是等于begin,则直接退出插入流程,表明此时是一个bad cache。进入 bad_cache(receiver, (SEL)sel);流程。

如果下标没有被占用,则会插入对应的方法编号和方法实现,并调用incrementOccupied();增加占用数。

3.扩容

继续执行代码,除第一次插入数据时容器为空,需要进行初始化外;其他插入数据过程中,都会对容器占用情况进行判断,当执行到第三个方法aySomething3时,新的占用情况达到了总容量的3/4,就会走到else流程中,对容器进行扩容。见下图:

19.png

fastpath(newOccupied +CACHE_END_MARKER<= capacity /4*3,如果当前占用不超过容量的四分之三,则不需要扩容;否则进入else,对容器进行扩容。扩容算法:

// Historical fill ratio of 75% (since the new objc runtime was introduced).
static inline mask_t cache_fill_ratio(mask_t capacity) {
    return capacity * 3 / 4;
}

扩容方式为:当前容量的2倍capacity = capacity ? capacity *2:INIT_CACHE_SIZE;,并且不能超出缓存的最大容量(1<<16)。最大容量:

   MAX_CACHE_SIZE_LOG2  = 16,

    MAX_CACHE_SIZE       = (1 << MAX_CACHE_SIZE_LOG2),

在完成容器的容量判断和扩容后,同样会调用reallocate()方法对容器进行初始化。reallocate方法实现见下图:

20.png

因为是扩容,所以会释放之前创建的buckets。这也就解释了,为什么在执行完saySomething3saySomething3后,容器中只有两个方法,找不到saySomething1ssaySomething2的原因!因为在执行到第三个方法时,进行了扩容,销毁了老的容器,并重新创建了一个容器。

3.cache_t总结

  1. _mask是什么?_occupied是什么?
  • _mask = capacity - 1; 即容器的总容量-1
  • _occupied为当前已占用的数量。

2.随着方法的执行,_occupied_mask的变化为0-0 -> 2-3 -> 2-7,为什么?

  • 没有插入数据时,占用为0mask=0;, 即0 - 0
  • 插入两个数据,占用数2mask = 4-1 = 3;
  • 插入4个数据,进行了一次扩容,容量为8,占用数2mask = 8-1 = 7

3.buckets中的数据丢失,无法找到saySomething1saySomething2?为什么数据存储是不连续的?

  • 数据丢失的原因是进行了扩容,重新创建了buckets,原地址已经释放。
  • buckets存储的数据并不是连续的,通过hash算法获取存储下标,进行存储。