iOS 底层探索篇 —— Cache分析

232 阅读8分钟

这是我参与8月更文挑战的第5天,活动详情查看:8月更文挑战

Cache底层分析

cache结构猜测

在这里插入图片描述

之前我们在获取bits的时候,是通过内存平移,那么我们获取cache,也可以通过内存平移。 isasuperclass都是8位,所以需要平移16位才能获得cache。 我们在lldb中得到LGPerson类的地址,然后平移16位,并将其转为cache_t *类型,然后将里面的内容打印出来。

在这里插入图片描述

这里我们就获得了cache_t的数据。

在这里插入图片描述

对比一下在源码里的结构,证明确实是cache_t的数据。在这里我们不知道到底哪个才是我们想要的数据,所以我们可以去看一下结构里的方法,看看对哪个数据进行增删改查。

在这里插入图片描述

我们发现,结构体里面的方法,大部分是对 bucket_t进行操作的,我们再点进去bucket_t。

在这里插入图片描述

发现里面装着impsel

在这里插入图片描述

LLDB证明cache结构

我们来找一下,哪里可以获取到bucket_t. 我们用之前拿到的cache_t,输出_bucketsAndMaybeMask

在这里插入图片描述

我们发现我们无法获得里面的值。我们换个目标,到_maybeMask里面试试。

在这里插入图片描述

发现依然不是我们想要的。我们再换个目标,到_originalPreoptCache里面试试。

在这里插入图片描述

发现_originalPreoptCache也不是我们想要的数据,那么我们就转移方向,去方法那里寻找。

在这里插入图片描述

我们在结构体方法中发现buckets()这个方法,并且返回struct bucket_t 指针,我们来验证一下。

在这里插入图片描述

我们发现,虽然sel,imp的值是,但是确实是我们想要的数据。为什么这里的sel,imp是为空呢?因为这里是缓存区,我们需要先调用方法,才会把方法缓存到缓存区。我们执行一下saySomething方法。

在这里插入图片描述

然后在重新获取一遍数据。

在这里插入图片描述

在这里插入图片描述

buckets 其实是用哈希数组存储的。

哈希数组

散列表Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。哈希表存在哈希冲突的问题,我们通过哈希函数获取两个下标,哈希函数给的下标是一样的。 那么如何解决这个问题呢,有个方法就是拉链法

拉链法 的实现比较简单,将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。

在回到我们的buckets, 这里的[1]并不是取数组的第二个元素,而是取内存平移的意思。

在这里插入图片描述

验证一下用内存平移取值:

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

发现确实是的。

我们看到我们获得了bucket_t结构体,但是我们的_sel的值是空的,我们就去方法区中寻找是否有方法返_sel。

在这里插入图片描述

我们在方法区中找到类似sel的get方法,我们来实验一下。

在这里插入图片描述

发现我们确实得到了sel,我们再来找一下imp。

在这里插入图片描述

发现了类似imp的get方法。我们来实验一下。

在这里插入图片描述

发现确实得到了imp。

脱离源码环境证明cache结构

优点:

  • 适用于源码无法调适的情况
  • 省略LLDB过程,操作只需要运行即可
  • 小规模取样,对底层结构更加清晰

创建方法

首先我们知道我们要获取cache,我们知道cache在class里面,所以我们去获取objc_class的数据结构。

在这里插入图片描述

我们将类的数据结构改成只包含成员的,并且将名字改掉与系统的隔离开来。 接下来我们要将缺少的数据结构cache_t以及class_data_bits_t 按照同样的方法改成自己的结构体。

在这里插入图片描述

因为我们知道我们的系统支持 __lp64__,所以可以把if去掉。这里我们需要的是_maybeMask_flags_occupied, 所以我们就把 explicit_atomic<preopt_cache_t *> _originalPreoptCache去掉,精简一下,得到下面的结构体。

在这里插入图片描述

这里缺少mask_t,我们在源码中点进去看一下。

在这里插入图片描述

发现mask_t是unit32_t,最后得到结构体。 接下来简化class_data_bits_t

在这里插入图片描述

简化后:

在这里插入图片描述

因为我们要用到bucket_t,所以把bucket_t也拿出来。 原结构体:

在这里插入图片描述

因为我用的是mac,简化后:

在这里插入图片描述

接下来,就是代码实现:

在这里插入图片描述

这里我们获得LGPerson的类,并将其强转为我们自定义的类,然后获取里面的数据。

在这里插入图片描述

发现是可以打印的,我们再来打印_occupied_maybeMask试试。

在这里插入图片描述

在这里插入图片描述

发现打印出来的数字大的离谱。这里发现我们获取的数据结构有问题。哪里出了问题呢? 原因就是我们的ls_objc_class少了一个成员,就是从objc_object 那里继承来的isa,因为数据结构强转的话是一一对应的,我们现在的结构,相当于把isa值赋给了superclass.而把superclass 的值赋给了cache。 我们把isa加上去,重新打印一下试试。

在这里插入图片描述

在这里插入图片描述

终于打印出我们想要的东西了。

我们写个for循环把要的数据打印出来。因为我们要获取bucket_t数据,所以要想办法将bucket_t获取出来。因此,我们去看cache里面是如何获取bucket_t的。

在这里插入图片描述

发现原来是用_bucketsAndMaybeMask来进行操作获取的bucket_t。接下来蒙一下,把 uintptr_t _bucketsAndMaybeMask 换成 struct ls_bucket_t *buckets. 然后就可以写for循环了。

在这里插入图片描述

接着运行一下试试。

在这里插入图片描述

发现sayNB1 确实被放进缓存了。多运行一个sayNB2方法试试。

】02321261.png)

在多运行一个sayNB3方法打印试试。

在这里插入图片描述

发现一个问题,我们刚开始打印的是 1 ======= 3,这里变成了 1 =====7,并且cache里面的方法只有sayNB3。我们在加一个类方法试试。

在这里插入图片描述

得到的是一样的结果。我们继续添加一个对象方法sayNB4。

在这里插入图片描述

添加成功,证明类方法是不会储存在类的cache里面的。我们把之前的对象 init 一下。再把之前调用的方法注释掉。

在这里插入图片描述

运行一下发现打印结果如下:

在这里插入图片描述

我们是没有写init 方法的,那么这里打印出init方法,说明了这里调用的是NSObject里面的init 方法。

这里有个问题。之前的_occupied和_maybemask,为什么会从 1 3, 变为 17呢?我们就去探究cache里面到底是如何实现的。

cache insert 流程

我们想一下切入点,我们现在有一个cache,cache里面肯定有一个插入,因为有写才能够有读,刚才我们sayNB1 ,sayNB2 用的就是读的方法。那接下来我们就去找插入的方法,然后在结构体中找到:

在这里插入图片描述

我们点进去这个方法看一下。

在这里插入图片描述

当我们第一次进来的时候,我们的cache是空的,所以关注这个if里面的函数。 我们看一下INIT_CACHE_SIZE 是什么东西,点进去看一下。

在这里插入图片描述

在点一下INIT_CACHE_SIZE_LOG2 看看是什么。

在这里插入图片描述

发现INIT_CACHE_SIZE_LOG2 是 2.(1 << INIT_CACHE_SIZE_LOG2), 那么这个意思就是1在二进制中左移两位,等于十进制的4。那么就代表INIT_CACHE_SIZE等于4.所以capacity等于4. 我们在点击reallocate看看里面进行了什么操作。

在这里插入图片描述

这里我们看到,这个方法创建了新的桶子,并且为bucketsmask进行了赋值,点进去这个方法看一下。

在这里插入图片描述

发现这个方法为_bucketsAndMaybeMask赋值了buckets,为_maybeMask赋值了mask。 这里的_occupied为什么设为0呢?因为 sel 和 imp 还没插入进去。 接下来我们继续看方法的下部分。

在这里插入图片描述

我们之前得到capacity = 4,那么mask_t m = 4-1 = 3begin是通过哈希计算得到的起始索引位置, 同时我们看到下面的incrementOccupied方法,知道这里进行了occupied值的操作。 我们看到occupied值的操作是一个do while 循环while (fastpath((i = cache_next(i, m)) != begin))代表的是从散列表里查找,如果上述条件不成立(索引冲突),那么通过cache_next计算出新的索引再查找插入。 if (fastpath(b[i].sel() == 0)) 说明如果通过索引找到的该SEL为空,代表从来没有进来过,那么就插入bucket_t,然后进行 occupied ++操作,

if (b[i].sel() == sel)代表,用索引从bucket里面取sel和传进来的sel做比较,如果一样证明已经存这个方法,那么就直接返回。

为什么 _maybeMask 会突然变为7呢?

我们来看下第二次进去的情况。

在这里插入图片描述

这里有个条件, 就是newOccupied + CACHE_END_MARKER <= cache_fill_ratio(capacity), newOccupied 我们去找前面的赋值发现为

在这里插入图片描述

CACHE_END_MARKER 点进去,发现为

在这里插入图片描述

cache_fill_ratio点进去,发现为

在这里插入图片描述

所以就是 原有的Occupied + 2 <= (capacity * 3 / 4)的情况下,就会进行正常的插入(不做任何的处理直接进入下面的插入操作)。 为什么到容量的四分之三(负载因子)就扩容呢?这是因为负载因子是0.75的时候空间利用率是比较好的,还有一个原因就是在负载因子在0.75的时候对哈希冲突有一定有效的避免。

在这里插入图片描述

这个条件是在支持100&利用buckets的时候,如果capacity 小于FULL_UTILIZATION_CACHE_SIZE 的时候 而且 newOccupied + CACHE_END_MARKER <= capacity 的情况下正常插入(不做任何的处理直接进入下面的插入操作)。

在这里插入图片描述

如果不是第一次进来,Occupied + 2 > (capacity * 3 / 4) 的话,我们就会进行一个两倍扩容的操作, 比如,4*2 = 8,而_maybeMask = capacity -1 = 8-1 = 7.

这就是为什么_maybeMask从3变为7的原因。

那么 什么时候会第二次扩容呢, 我们算一下 x + 2 > 8 * 3 / 4, x = 5,那么也就是在要添加第六个方法的时候,会进行扩容,证明一下。

在这里插入图片描述

这里是从sayNB2开始算起,因为sayNB2扩容的时候把之前的方法清空了,运行一下。

在这里插入图片描述

发现果然在添加第六个(第二个方法扩容了所以算第一个方法,sayNB7就是第7个了)方法,也就是sayNB7的时候扩容了。

为什么之前的方法都被清空了呢?

我们注意到,扩容的时候,

在这里插入图片描述

这里的reallocate方法传入的是true。

在这里插入图片描述

而这个参数代表的是,是否要释放旧的空间,因为扩容的时候传的是true,那么就代表在扩容的时候,会把之前的内存地址全都清空。 为什么我们要清空而不直接将之前的方法放入到新开辟的容器里呢? 这是因为如果我们要将之前的方法放入到新开辟的容器里,要进行数组平移,会消耗大量的内存和性能,相对比重新插入缓存的话,消耗大的多,所以这里就不把之前的方法放入到新开辟的容器里。况且之前调用的方法,也有可能不会调用第二次。

总结

1. _occupied 是什么?

  • _occupied表示哈希表中 sel-imp占用大小(即可以理解为分配的内存中已经存储了sel-imp的的个数),

  • init会导致occupied变化

  • 属性赋值,也会隐式调用,导致occupied变化

  • 方法调用,导致occupied变化

2. 什么时候进行扩容

  • 当_occupied + 2 > (当前的容积大小 *3 /4)的 时候,进行扩容,大小为扩容前两倍。
  • 假如 当前的容积大小为4 ,那么就是在添加第三个放的的时候进行跨容(2 + 2 > 3)。

3. 扩容后为什么之前的方法清空了

  • 这是因为如果我们要将之前的方法放入到新开辟的容器里,要进行数组平移,会消耗大量的内存和性能,相对比重新插入缓存的话,消耗大的多,所以这里就不把之前的方法放入到新开辟的容器里。
  • 之前调用的方法,也有可能不会调用第二次。

4.为什么方法的打印不是连续的

  • 因为sel-imp的存储是通过哈希算法计算下标的,所以导致下标是随机的,并不是固定的。

5. cache_t流程图

在这里插入图片描述