Objective-C 底层类的Cache

212 阅读11分钟

目录

1. 背景

学习不迷茫,无阻我飞扬!大家好我是Tommy!虽然沉寂的了一段时间,但是我又回来了,本次我们继续对底层学习进行深入探索。咱们这就上路!~(每一篇文章都是用心做的)

2. cache数据结构

  • 类结构的回顾

    《Objective-C 底层类的探究-上》这篇文章中我们已经对类的结构进行了说明,其中我们针对bits这个属性进行了详细的探索,本章内容我们不妨现对类的结构再简单的回顾一下。 图片.png 在源码objc_class结构体中包含了4个变量,分别是ISA、superclass、cache、bits,ISA和superclass这里不再赘述,其中bits中包含了类的属性和方法,在后面我们还会再遇到它;最后只有cache我们还没有进行详细的探索,剩余的3个我们应该已经有所掌握了。那么本章的重点就是针对cache或者说对cache_t这个结构体来进行探究。

  • cache的数据结构

    首先我们根据名称就可以知道这个变量代表的是缓存,那么缓存中存放的是什么?我们现在还不得而知。那么我们还是使用之前学到的方法先使用LLDB+源码分析来调查cache_t的内容。 我们先通过LLDB命令得到cache_t的指针,依旧用内存平移的方式。之前我们获取bits是将类的指针平移了16字节,那么本次cache的上面只有一个superclass8字节)我们只需平移0x10即可。 图片.png 打印得到的结果显示:_bucketsAndMaybeMask、_maybeMask、_originalPreoptCache 这些内容都是cache_t结构体中的属性,我们可以进入源码中查看。 图片.png 通过上图我们可以一览到cache_t的结构体,但是我们现在还不知道这些结构体中的属性那些是比较重要的,这时候我们就要继续去查看源码,看看那些属性被操作的次数最多,或者被一些重要方法使用到,那么这个属性大概率就是核心属性。 图片.png 通过仔细观察发现有个叫bucket_t的结构体出现频率很高,而且他所在的方法都与开辟、清空、设置等方法关系密切。所以我们大胆猜测bucket_t应该是一个核心属性。我们进入到bucket_t内部看一眼,发现其中存放的是impsel的数据,而且这两个数据应该是一一映射的,此时我们也应该可以猜到cache中其实存放的应该是对象或类的方法。既然有了猜测那么接下来我们就要用实践来证明了。 图片.png

  • 节点小结:

    本小结先是对objc_class结构体做了简要回顾,然后利用咱们之前的探索方法对cache_t进行了结构上的探索,接下来要对其内部核心属性的用途进行探究,本小结到此结束。

3.cache底层LLDB分析

  • 在调用方法前后cache_t的变化

    我们猜测cache是把方法给缓存住了,那么我们不妨通过一个实验来证明一下,我们先用LLDBcache_t结构打印出来,然后通过LLDB调用一个方法,最后再来观察cache_t结构中的数值是否发生了变化。 图片.png

    我们在此时调用一个方法,我这里调用的是ZXPerson中的eat方法,此方法是一个类方法。

    图片.png 那么此时我们就可以看到原cache_t发生了变化,之前为0的值已经有了数据,那么我们也可以顺便看一下bucket_t是否也有数据了。我们可以通过查看cache_t结构体的源码找到一个叫做buckets()方法,我们可以直接在LLDB调用这个,像是下面图片展示的样子。 图片.png 图片.png 这里有一个注意点,如果你按照我的方法打印出来发现_sel中的valuenil并且_imp中的value0那么你可以这样再试一下。

    图片.png

    为什么会发生这样的事情,原因是buckets本身是一个散列结构数据结构即哈希(HASH),所以本身是没有顺序的,我们可以通过改变下标来查看不同位置的数值。

  • 打印cache中存放的方法名称和指针地址

    经过上面的一番操作我们已经看到cache_t中的变化了,但是这还不是我们想要的结果,我们虽然调用了set()方法,但是缓存中存放的到底是不是set()方法还需要我们进一步的验证才可以得到最后的结果,那么我们就看看在bucket_t结构体中是否可以有我们想要的东西。

    图片.png

    接下来我们在LLDB中调用一下:

    图片.png

    我这里用的是类方法,大家可以自己再去试试用对象方法,看看打印的结构是否会有什么变化。

  • 节点小结:

    本小结主要是对cache_t中的内容进行了详细的探索,我们最终验证了我们的猜想,结论就是cache_t中的缓存数据都保存在了buckets中,而且该数据保存的是方法数据。但通过LLDB的方式探索的效率不高,是不是还有更简单更快捷的方式呢?答案当时是肯定的,那么我们再下一小结中给大家介绍另一种方式,本小结到此结束。

4.代码方式分析

  • 将源码的结构体copy出来:

    上一节中我们通过LLDB将缓存中存放的方法的SEL以及IMP都打印了出来,但是每次都需要通过LLDB命令一步步的进行太过于繁琐了,经过我们的调查以及知晓其实存放缓存的数据都被放到buckets这个数据结构中,那么我们是否可以通过代码将buckets获取到,然后再通过遍历将缓存的数据输出来呢?答案当然是可行的,那么接下来我们就来实现一下。(不用怕!都是复制粘贴的高端操作 O(∩_∩)O哈哈~)

    • 1、首先我们先新建一个工程项目,项目模板选择Command Line Tool即可。
    • 2、将源码中有关的结构体都copy到新建工程中的main.m文件中,涉及mask_t、bucket_t、cache_t、class_data_bits_t、objc_class这几个。
    • 3、都复制好后应该是这个样子的:
    typedef uint32_t mask_t;  // x86_64 & arm64 asm are less efficient with 16-bits
    
    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
    };
    
    struct cache_t {
        private:
            explicit_atomic<uintptr_t> _bucketsAndMaybeMask;   //uintptr_t类型无符号存放指针地址,所以占8字节
            union {
                struct {
                    explicit_atomic<mask_t>    _maybeMask;       //实际是uint32_t类型占4字节
        #if __LP64__
                    uint16_t                   _flags;           //占2字节
        #endif
                    uint16_t                   _occupied;        //占2字节
                };
                explicit_atomic<preopt_cache_t *> _originalPreoptCache;    //指针类型占8字节
            };
    };
    
    struct class_data_bits_t {
        friend objc_class;
    
        // Values are the FAST_ flags above.
        uintptr_t bits;
    };
    
    struct objc_class : objc_object {
    //  下面四个没有用可以删掉
    //  objc_class(const objc_class&) = delete;
    //  objc_class(objc_class&&) = delete;
    //  void operator=(const objc_class&) = delete;
    //  void operator=(objc_class&&) = delete;
        // 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
    };
    
    

    我们复制好之后应该Xcode会报一些错误,这是正常现象后面我们会对复制的代码进行简化,简化之后就不会报错了。

  • 将定义声明简化:

    现在我们来进行简化工作,主要就是以下几点: 1、将结构体重新命名一下方面我们可以分辨出来; 2、将无用的字段声明或与运行架构无关的代码删除掉; 3、经过简化之后你会得到以下这样的内容,相关注意的点我已经写到注释中了。

    //简化后---------------------
    
    typedef uint32_t zx_mask_t;  // x86_64 & arm64 asm are less efficient with 16-bits
    
    struct zx_bucket_t {
        SEL _sel;
        IMP _imp;
    };
    
    struct zx_cache_t {
        //uintptr_t _bucketsAndMaybeMask;   //uintptr_t类型无符号存放指针地址,所以占8字节  源码中通过此变量来获取buckets
        struct zx_bucket_t *_buckets;     //这里我们直接用zx_bucket_t来进行对应
        zx_mask_t    _maybeMask;       //实际是uint32_t类型占4字节
        uint16_t  _flags;           //占2字节
        uint16_t  _occupied;        //占2字节
    };
    
    struct zx_class_data_bits_t {
        // Values are the FAST_ flags above.
        uintptr_t bits;
    };
    
    struct zx_objc_class {
        Class ISA;                  //这里需要注意【类型转换的时候是一一对应的,所以需要加上源码从objc_object继承过来的ISA】
        Class superclass;
        struct zx_cache_t cache;             // formerly cache pointer and vtable
        struct zx_class_data_bits_t bits;    // class_rw_t * plus custom rr/alloc flags
    };
    
    
  • 遍历buckets:

    首先我们在第二小节中发现当调用方法之后cache_t中的maybeMaskoccupied发生了变化,未调用之前都是0,调用之后数值都有所增加。那么我们就先打印这两个属性。

    图片.png 这里我们输出得到的结果是: 1-3

    我们可以通过For循环来遍历整个buckets

    图片.png

    输出得到的结果是:

    图片.png drink方法SELIMP的信息被打印出来了,达到了跟我们使用LLDB一样的效果。

    这里我们可以再多调用几次方法看看会发生什么事情。记住现在缓存中只有一条数据就是drink方法的。

  • 调用多次方法:

    图片.png

    输出得到的结果是:

    图片.png 这个现象就非常有意思了,有点超出我们的预期了,原来存放在缓存中的drink方法消失了,现存放的是drink3、drink4、drink5三个方法,drink2方法也是新增的为什么没有被缓存记录下来?我们带着这些问题来进入下一小节的内容。

  • 节点小结:

    本小结对探究cache_t中的内容提供了一种代码方式,这种方式的好处就是不用每次都通过LLDB一步步的进行操作了,只需运行一下就可以将缓存数据遍历打印出来,方便我们进行验证操作。本小结到此结束。

5.cache底层原理分析

  • 原理分析:

    经过上面几个小篇章我相信大家对于cache已经有了一定的了解了,那么我们就来通过分析下源码看看是否可以解释我们上面发现的几个问题现象。

    首先,一提到缓存这个概念无非就是2个动作,一个是将数据插入缓存中,再一个就是把数据从缓存中读取出来,我相信大家都应该知道MemoryCache,原理上就没什么好说的了。对于2个动作来说插入相对是比较重要的,那么接下来我就看看能否从cache_t中找到与插入相关的代码吧。

    我们可以再cache_t中找到一个insert()方法,我们可以进入到该方法中进行分析。 图片.png

    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;    //第一次occupied为0,后续为+1
        unsigned oldCapacity = capacity(), capacity = oldCapacity; //提一次时oldCapacity 是0 所以capacity也是0
        if (slowpath(isConstantEmptyCache())) {  // 第一次的时候会走这里,因为缓存还没有被创建
            // Cache is read-only. Replace it.
            if (!capacity) capacity = INIT_CACHE_SIZE;  //第一次的时候 这里注意对capacity进行了数字1左移2位 capacity = 4
            reallocate(oldCapacity, capacity, /* freeOld */false); //
        }
        else if (fastpath(newOccupied + CACHE_END_MARKER <= cache_fill_ratio(capacity))) {   //根据占据大小进行比较 如果大于等于75%了就需要扩容
            // 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 *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
    }
    
    

    以上就是整体insert()方法的源码整体其实不难理解,我会一步一步的给大家简单介绍一下。整体代码可分为两大部分:第一部分主要以对bucket_t的一些初始&获取的操作;第二部分主要是将数据放入得到的bucket_t中。首先我们先看第一部分。

    图片.png

    1、首先第一次进来时已切都是初始状态,newOccupied =1 ;capacity = 0;

    2、第一次会进入 if (slowpath(isConstantEmptyCache())) 之中,INIT_CACHE_SIZE宏定义是对1进行左移2位的操作,那么此时capacity = 4; 图片.png

    3、如果不是首次会进入第二个 else if (fastpath(newOccupied + CACHE_END_MARKER <= cache_fill_ratio(capacity))) 来判断是否当前如果新增数据的大小是否超过了原大小的75%,没超过则走else if 中的内容,超过了继续走下面else if 的逻辑。 图片.png

    4、第三个 else if 的就是如果开启了 CACHE_ALLOW_FULL_UTILIZATION 这个标识,缓存则不再扩容了,可以允许100%的使用。 图片.png

    5、那么反之就是内存的容量会按照当前 capacity 值的2倍来进行扩容。 图片.png

    6、最后注意一下reallocate()这个函数,在初始和扩容时都被调用了,但是最后一个参数稍有不同。 图片.png

    我们再来看第二部分。 图片.png

  • 解释多方法的现象:

    我们可以再分别将调用一个方法、二个方法、五个方法的结果先运行一次,然后再用一张图来说明逻辑。 图片.png 1、_occupied = 1 ;_maybeMask = 3 同时打印了drink()方法的信息.

    图片.png 2、_occupied = 2 ;_maybeMask = 3 同时打印了drink()、drink2()方法的信息。

    图片.png 3、_occupied = 3 ;_maybeMask = 7 同时打印了drink5()、drink4()、drink3()方法的信息。

    那么现在对于_occupied和_maybeMask这两个数据是否有了理解呢?

    _maybeMask:其实就是buckets总共的大小;

    _occupied:则是被占用的个数。

    下面我们用几张图来说明变化的逻辑:

    图片.png

    • 首先,当调用insert时会先判断当前是否已有缓存,如果没有则会先开辟空间将用于存放缓存的buckets初始化出来,并且将它存入到bucketsAndMask中。
    • 扩容:当超出3/4时(75%)的容量时,会将原空间进行清除,并按照原2倍大小进行扩容,扩容后原缓存的内容就会消失。
    • 插入:当一切准备就绪之后,就会进行缓存的插入工作了,先拿到buckets然后计算出数据的hash,如果有重复就重新再hash,直到可以插入为止。
    • 报错:正常情况是在do{}while()中进行return,反之则是出现问题了。

    图片.png

    • 首先,当调用insertbuckets是空的会被初始化;
    • 初始化之后值的变化:occupied=0 mybeMask=0 capacity=4
    • 插入2个缓存数据之后值的变化:occupied=2 mybeMask=3 capacity=4
    • 当再次插入时,发生扩容buckets被清空:occupied=0 mybeMask=7 capacity=8
    • 然后继续插入数据:occupied=3 mybeMask=7 capacity=8
    • 直到下次buckets被清空之后,再次按照以上逻辑运行。
  • 注意点:

    我们之前再LLDB中用的buckets()[1]这种方式打印信息,同学们不要把buckets()理解成为数组,他实际就是一个结构体,buckets()返回的只是一个初始的内存地址,然后通过[1]来进行内存平移。这一点我会在补充篇里进行详述。

  • 节点小结:

    本小结对于底层类的cache插入、扩充逻辑进行了详细的说明,希望大家可以正确的理解。本小结到此结束。

7. 总结:

  • 1、本章节我们首先回顾了class底层的结构,然后对cache_t的结构进行了探究。

  • 2、利用LLDB调试的方式对cache底层的数据进行了打印,并且搞清了缓存数据到底存放的是什么内容,且是在哪里存放的。

  • 3、我们有根据源码中提供的代码开发了一个通过代码方式打印数据的方式。

  • 4、最后我们分析源码对cache缓存的工作逻辑进行了验证。

  • 写到最后:
  • 上一篇::《Objective-C 底层类的探究-下 》 下一篇:待续.....