OC类—— 缓存分析(cache)

200 阅读7分钟

我们从上篇文章类原理分析下已经了解到类的大致结构,这个章节我们来分析下cache,也就是它的缓存,开始讲解之前,我们先来看一个面试题。

isKindOfClass、isMemberOfClass原理分析

截屏2022-04-22 下午4.45.38.png

我们平时的开发中应该也常用isKindOfClassisMemberOfClass这两个方法,那么对它们底层的原理是否清楚呢,我们看下result1result8的结果,具体分析下。因为result1result4是类方法调用返回的结果,所以我们首先在源码里看下+(BOOL)isKindOfClass+(BOOL)isMemberOfClass,如下图:

截屏2022-04-22 下午4.57.36.png

    分析result1,我们可以知道这里传入的cls就是NSObject类,我们之前分析isa的走位图应该清楚,NSObject类的isa指向RootMetaClass(根元类),但是RootMetaClass继承于NSObject类,所以for循环这里tcls->getSuperclass()获取到的就是NSObject类,所以最终返回的YES,那么result1自然结果就是1。至于result2就更容易理解了,CTPerson类的isa指向它的metaClass(元类),通过tcls->getSuperclass()获取父类的话,最终会找到rootMetaClass->NSObject->nil,所以最终返回的结果肯定是NO。我们接着看下result3result4,如下图:

截屏2022-04-22 下午5.16.37.png

    这个图上非常明显的看出它是拿当前类的isa指向的类和当前类进行比较,NSObject类的isa是根元类,而CTPerson继承于NSObject,它的isa也是根元类,自然两者都不相等,所以result3result4结果都是0。接下来我们看下,result5至result8的结果,首先他们都是实例方法,我们先找出底层代码:

截屏2022-04-22 下午5.28.21.png

截屏2022-04-22 下午5.35.27.png

    从-(BOOL)isKindOfClass的方法实现中这里[self class],这里就可以直接得出result5result8结果是1,因为当前的self是当前类的实例,自然cls肯定和[self class]是同一个类。自然result6result9同理。

汇编调试isKindOfClass的调用过程

我们之前从objc底层源码看isKindOfClass的实现是没什么问题,但是当我们汇编调试后发现,系统并没有直接调用+(BOOL)isKindOfClass:(Class)cls这个方法,看下图:

截屏2022-04-22 下午5.47.58.png

系统最终调用的是objc_opt_isKindOfClass方法,我们看下它的实现:

截屏2022-04-22 下午5.50.09.png

我们现在最新使用的代码都是调用的__OBJC2__这里,所以我们可以看到并没有直接调用isKindOfClass方法,但是仔细看的话这里的实现跟之前isKindOfClass的实现并没有太多区别,所以我们之前分析的也没什么问题。

类的缓存结构

cache底层源码分析

我们之前已经看过objc_class结构体的具体结构,通过内存平移拿到了bits,这里自然通过内存平移也可以拿到cache,如下图:

截屏2022-04-22 下午7.48.42.png

之前是平移了32个字节大小取到bits,这里取cache只需要平移16个字节,我们lldb调试如下:

截屏2022-04-22 下午7.54.19.png

我们在源码里看下cache_t的结构,下图看到跟lldb还原出的结构是一样的:

截屏2022-04-22 下午7.56.22.png

我们能想到的是类这里缓存的是什么,是属性还是方法?那么它缓存在这些字段的哪个里呢?这就需要我们看cache_t这个结构体里面具体的方法,我们可以找到重要的方法:

截屏2022-04-22 下午8.07.03.png

下图是void insert(SEL sel, IMP imp, id receiver);方法的实现

截屏2022-04-22 下午8.08.40.png

这些重要的方法都在操作bucket_t这个结构体,所以接下来我们就看bucket_t,下图我们可看到它里面主要是selimp,也就是cache里主要缓存的是方法:

截屏2022-04-22 下午8.13.37.png

这里我们大致对cache的结构大致有个基本的了解,我画个图标注下:

截屏2022-04-22 下午8.33.01.png

lldb调试验证方法存储

下面我们就通过lldb调试验证下我们上面的猜测,我们上面已经拿到cache_t,怎么拿到bucket_t呢,这时候我们需要在cache_t这个结构体里找到相应的方法了:

截屏2022-04-22 下午9.02.38.png

上图中我们可以看到一个buckets()函数,我们试着调用下:

截屏2022-04-22 下午9.05.19.png

我们发现里面是空值,这是因为我们还没调用过方法,自然没有方法缓存,我们先调用下方法再回过头看下:

截屏2022-04-22 下午9.10.02.png 截屏2022-04-22 下午9.11.22.png

这里我们可以看到没有在buckets中索引值是0的位置取出值,而是在1的位置,这是因为哈希存储的原因,它是个无序的结构,这样是为了方便快速查找,后面我们具体分析。下面我们通过在bucket_t这个结构体里找到获取sel名称和img的方法调试下:

截屏2022-04-22 下午9.32.49.png

可以看到我们已经取出sel名称run,那获取imp呢,我们可以通过另一个方法imp(bucket_t *base,Class cls)

截屏2022-04-22 下午9.35.15.png

lldb调试结果,可以看到已经正常获取都它的imp

截屏2022-04-22 下午9.37.21.png

脱离源码调试

我们大部分时候下载下来的源码是无法直接进行调试的,需要配置很多的选项,所以这时候我们只能在我们自己的工程进行调试,我们需要把源码中相关结构体经过转变在自己工程部分还原下:

截屏2022-04-23 上午10.40.18.png

我们还原出大概我们需要的这些与类相关的结构体,下面我们可以调试打印:

截屏2022-04-23 上午10.46.39.png

单独调用方法run_occpupied值为1,同时调用run和run2,我们基本可以确定这个值跟方法缓存的占位数量有关,接着我们可以借着这个值打印出相关的selimp:

截屏2022-04-23 上午10.55.43.png

接下来我们打印输出下:

截屏2022-04-23 上午11.06.37.png

我们可以看到_occupied值为2,但是我们在下面没取到run2,这其实就跟我们之前讲的哈希表有关了,这个它是无序的,我们根据_maybeMask的值打印输出:

截屏2022-04-23 上午11.09.05.png

我们如果同时调用3个方法,我们发现_occupied值变为了1_maybeMask值变为了7,这里涉及到哈希表的扩容,后面我们会详细讲这个:

截屏2022-04-23 上午11.04.27.png

cache底层原理分析

我们之前调试的发现调用一次run方法,_occupied值为1_maybeMask值为3,为什么呢,这时候我们需要进源码看下具体的实现,我们知道既然缓存可以读出来,那肯定也有插入数据的时候,我们就从它的insert方法开始梳理:

截屏2022-04-23 上午11.55.22.png

首次进行插入数据的时候,cache肯定是空的,所以会走第一个条件,这里我们可以知道capacity=4,接着看reallocate函数:

截屏2022-04-23 下午12.09.33.png

我们进入setBucketAndMask看下:

截屏2022-04-23 下午12.12.35.png

发现_bucketsAndMaybeMask在这里进行了赋值,存储了newBuckets,但是它是被强转为uintptr_t类型存储的,这里跟isa那里的位域存储是一样的,都是为了节约内存。我们看到这里_occupied=0,因为这里还没完成插入数据,所以为0。看完reallocate函数,我们继续回来往下看insert方法:

截屏2022-04-23 下午12.39.48.png

通过哈希得到一个哈希地址,取出当前位置的sel,判断它为空,进行_occupied++,然后设置sel,imp进行插入。这也就解释了为什么开始调用一次run方法的时候,_occupied=1,而_maybeMask=3。那同时调用3个方法后,为什么_occupied=1,_maybeMask=7呢,我们继续看源码:

截屏2022-04-23 下午1.01.59.png

当我们再次插入数据时,newOccupied + 1 <= capacity * 3 / 4时,正常走原来的逻辑,但是我们调了3个方法,来到这里的时候,newOccupied此时是3,而capacity=4,已不满足条件,就会走下面的条件capaticy * 2mask_t m = 8 - 1,此时_maybeMask = 7。我们还没看到为什么这时候_occupied=1,我们看扩容后的reallocate函数调用reallocate(oldCapaticy,capaticy,true)

截屏2022-04-23 下午1.25.55.png

我们可看到此时freeOld=true,旧的内存被回收了,重新开辟了扩容之后新的内存,而此时只会插入触发扩容时调用的方法。我们此时是在调用run3时,触发了扩容,所以缓存里只有run3

截屏2022-04-23 下午1.30.16.png

这里为什么没有把之前缓存的方法进行拷贝过来呢,目前是2个方法,那以后如果有100个方法呢,岂不是消耗很大,再有一个就是我们再次调用之前缓存的方法概率也很小,而目前最新插入的方法才是最关键的。后面如果再次调用run、run1等方法我们还是会重新进行缓存的。