iOS底层-cache原理探究

608 阅读10分钟

前言

在前面几个章节中我们知道了类的底层结构,包含isasuperClasscachebits,并相继学习isa走位图,类的继承链,存储了属性,方法,协议等信息的bits,除了这些,类里面还有一个重要的变量,那就是cache,按照字面意思来说是缓存,那它到底是个什么东西呢?又做了些什么事情呢?接下来我们一起进入到cache原理的学习。

cache的认识

cache本身的字面意思来说,我们知道是缓存,而其作为类的一个变量,那它到底是缓存了什么信息呢?首先我们还是回到objc源码,找到类的实现:

image.png

cache_t结构

我们知道了cachecache_t类型,那进入到cache_t去瞧一瞧:

image.png 可以看到cache_t也是一个结构体,有很多的方法和static修饰的变量,主要的变量有_bucketsAndMaybeMask变量和一个联合体

  • explicit_atomic<uintptr_t> _bucketsAndMaybeMask:是一个泛型的结构体,真正的大小由uintptr_t(无符号长整型,占用8字节)决定,占用8字节;

  • 联合体union:里面只有一个变量一个结构体,

    • 这个结构体中有3个变量:占用8字节

      • _maybeMask:一个mask_t(uint32_t)型的结构体 占用4个字节
      • _flags: uint16_t类型 占用2字节
      • _occupied: uint16_t类型 占用2字节
    • _originalPreoptCache:是一个结构体指针类型,占用8字节

而联合体是里面是互斥的,那么可以得到联合体大小为8字节。

union {
    struct {
        explicit_atomic<mask_t>    _maybeMask; //4
#if __LP64__
            uint16_t               _flags; //2
#endif
            uint16_t               _occupied;//2
        };
    explicit_atomic<preopt_cache_t *> _originalPreoptCache;
    }
复制代码

所以通过上面分析我们可以得到cache_t占用16个字节。

注意架构宏判断

iShot2021-08-17 22.39.03.png

我们往下看会发现很多在不同架构下对 _bucketsAndMaybeMask的说明:

//_bucketsAndMaybeMask is a buckets_t pointer
//_maybeMask is the buckets mask

_bucketsAndMaybeMask确切的说,不单单是一个bucket_t的指针地址,而是一个掩码,包含了首个bucket_t的地址和mask(针对真机环境);继续后面看又能发现很多bucket_t的代码,那bucket_t又是什么呢?

image.png

从上面的代码我们大概可以看到初始化bucket_t等一些操作,那更加证明cache_t主要就是围绕着bucket_t来做了一些操作。

bucket_t结构

image.png 果不其然,通过bucket_t结构我们发现

  • bucket_t结构里面有_sel_imp两个变量(注意这边有个架构的区分,__arm64__和其他,不过都是_sel_imp,只不过顺序不同);
  • 可以确定bucket_t就是对方法操作;进一步确定了cache_t就是来缓存方法的。 那所得结论是否正确呢?下面通过LLDB调试一下看看。

LLDB验证方法的存储

还是那个LhkhPerson类,然后我们通过断点进入到LLDB;前面我们探索过bits,那么我们这边也类似(cache_t是首地址偏移16个字节,也需要强转为cache_t类型),取到cache_t内部变量,然后通过指针地址取到数据,结果给我们的是errorerrorerror。。。什么鬼?

image.png 是哪里错了吗?我们回想一下,探索bits的时候,我们是通过查找到class_rw_t其内部methods()properties()等最终获取到我们想要的数据,那cache_t呢?别废话了,一头扎进源码吧:

image.png 上面探究的时候我们就已经知道cache_t是围绕bucket_t来的,那这边我们发现了buckets(),直接取出来看看呢:

image.png 有地址了额,看来方向是对的,然后取出里面的数据,结果呢_sel里面value=nil_imp里面value=0,这啥玩意儿啊?回过头一想,既然是缓存方法的,那肯定得调用方法才行啊,我们这都没有调用方法怎么可能有缓存呢。。。好吧,调用一下我们LhkhPerson里面声明的方法:

image.png 方法调用了,那我们重新来打印一次

image.png 发现还是nil0,但是我们发现_occupied相比之前值变了,既然值变了,那说明调用方法过后有变化,buckets()看着又像是一个数组,那我们试着取一下数组里面的值呢

image.png

我们再去探究一下bucket_t

image.png

我们发现sel()imp(UNUSED_WITHOUT_PTRAUTH bucket_t *base, Class cls),通过sel()打印一下看看呢:

image.png 打印出了我们的方法selsaySomething,对应imp0x0000000100003c90 (KCObjcBuild-[LhkhPerson saySomething])

我们可以多调用几个方法看看呢: 这个是没有调用方法的时候,_maybeMaskvalue = 0_occupied = 0 image.png 我们调用第一个方法saySomething后,_maybeMaskvalue = 7_occupied值变成1了(问题点1

image.png

和上面一样,我们取了很多次才找到(问题点2),打印出来方法名确定是我们的saySomething

image.png

那继续调用方法sayHello,_maybeMaskvalue = 7没变,_occupied值变成4

image.png

image.png

image.png

经过这一步后我们找到了找到了sayHello,继而也找到了saySomething,那么我们继续调用第三个方法sayNB_maybeMaskvalue = 7没变,_occupied 值变成5

image.png

image.png

三个方法都找到了,继续验证当我们调用7个方法时,_maybeMaskvalue = 15_occupied 值变成6(我们调用了7个方法,为什么是6呢)

image.png

image.png

image.png

image.png

我们找到了say666say777say999三个方法,然后其他4个方法往后取了20多个数据没有找到,那其余方法呢?(问题点3

总结

  • bucket_t里面存的是方法的selimp
  • cache_t中存的是很多的bucket_t

未命名文件(13).png

使用LLDB探究时,我们肯定会发现:

  • 每当多调用一个方法过后_occupied_mask会发生改变这个是为什么呢?
  • 读取buckets里面的数据时,可以发现里面缓存的方法并不是有序的,而是无序的,这又是为什么呢?
  • 当调用很多不同的方法过后,我们在后面取方法数据时发现有方法已经找不到了,这又是什么情况?

代码转换探索

上面我们使用LLDB调试分析cache,每次调用以此方法后重新获取数据,过程还是有点繁琐的,那有没有更方便直接的方法呢?那肯定是有的啊,我们通过自定义源码的一些结构体实现,来脱离源码分析:

我们的cache是在class里面,那么我们来自己写一个class的结构体出来:

image.png 参照底层源码我们自定义的结构体,然后下面我们就使用一下:

image.png 调用方法都打印出来了,这样就使得我们脱离源码也是可以得到上面lldb相同的结果:

  • _maybeMask_occupied两者的值随着方法的调用会改变;
  • 方法符号取出来无序性;
  • 方法丢失。 既然结果是相同的,那么我们就只有通过源码来探究这几个问题了。

cache底层探索

既然cache是在缓存方法,那么要想缓存那必须得将方法插入到cache_t表中啊,所以肯定有insert方法,那么带着这个思路我们到cache_t中去看看:

image.png

cache_t::insert分析

可以看到在cache_t中可以查找到插入的方法,并且传入了三个参数,sel方法符号,imp地址指针,receiver消息接受者;进入到方法实现中:

image.png

这部分源码里面包含的信息量还是很大的,也调用了不少的方法,也有不少的逻辑判断,我们一起详细分解一下:

容器空间创建

除去一些无关紧要的代码,我们知道因为是插入方法,那么刚开始时肯定是空的,那么我们肯定就是找到刚开始为空的时候做了些什么:

mask_t newOccupied = occupied() + 1; // 刚开始occupied()肯定为0,也就是调用一个方法加一次1
unsigned oldCapacity = capacity(), capacity = oldCapacity;//取到这个容量,相当于初始化
if(slowpath(isConstantEmptyCache())) {
    // Cache is read-only. Replace it.
    if (!capacity) capacity = INIT_CACHE_SIZE;//4
    reallocate(oldCapacity, capacity, /* freeOld */false);
}
  • if (!capacity) capacity = INIT_CACHE_SIZE; 判断是否为空,开始的时候肯定是空,这个时候给他赋值INIT_CACHE_SIZE     
    • INIT_CACHE_SIZE  = (1 << INIT_CACHE_SIZE_LOG2),INIT_CACHE_SIZE_LOG2 = 2,所以也就是1左移两位也就是4
      
  • reallocate(oldCapacity, capacity, /* freeOld */false);然后就是调用这个reallocate方法创建容器分配容量,这个方法内部又调用了很多的方法.
reallocate方法:
void cache_t::reallocate(mask_t oldCapacity, mask_t newCapacity, bool freeOld)
{
    bucket_t *oldBuckets = buckets();//取到老容器
    bucket_t *newBuckets = allocateBuckets(newCapacity);//创建新的容器并开辟内存
    // Cache's old contents are not propagated. 
    // This is thought to save cache memory at the cost of extra cache fills.
    // fixme re-measure this
    ASSERT(newCapacity > 0);
    ASSERT((uintptr_t)(mask_t)(newCapacity-1) == newCapacity-1);
    setBucketsAndMask(newBuckets, newCapacity - 1);//设置mask和bucket
    if (freeOld) {//释放掉老的容器空间取决于freeOld
        collect_free(oldBuckets, oldCapacity);
    }
}
  1. allocateBuckets新容器创建和开辟内存空间;

image.png

  1. setBucketsAndMask设置mask和bucket,并设置_occupied=0

image.png 3. collect_free释放老容器,回收内存;取决于freeOld参数。

capacity容量:
  • 第一次进入缓存时4-1也就是3;
  • 小于3/4容量和小于总容量时啥也没干;
  • 扩容:
//capacity是否有值判断,有则两倍扩容,否则就是4(1<<2)
capacity = capacity ? capacity * 2 : INIT_CACHE_SIZE;
//判断capacity是否大于最大值2^15,大于则直接等于2^15,所以最大扩容为2^15
if (capacity > MAX_CACHE_SIZE) {
    capacity = MAX_CACHE_SIZE;
}
//重新创建容器,开辟空间,freeOld=true 释放老容器,回收内存
reallocate(oldCapacity, capacity, true);

capacity容量验证:

image.png

image.png

image.png 第一次调用1个方法时,首次进入时occupied=1,capacity=4; 当我们调用了3个方法时

image.png

调用4个方法时

image.png 通过源码调试我们验证了扩容的操作,但是也给我带来了新问题,为什么调用了4个方法,但是occupied=2呢?我们将这个联系到问题点3,猜想难道是方法丢失?

方法缓存

可以看出来,方法缓存时通过哈希表,我们可以先脑补一下数组,链表和哈希表:

数组,链表,哈希表补充
  • 数组:数组是用于储存多个相同类型数据的集合。主要有以下优缺点:

    • 优点:访问某个下标的内容很方便,速度快
    • 缺点:数组中进行插入、删除等操作比较繁琐耗时
  • 链表:链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。主要有以下优缺点:

    • 优点:插入或者删除某个节点的元素很简单方便
    • 缺点:查找某个位置节点的元素时需要挨个访问,比较耗时
  • 哈希表:是根据关键码值而直接进行访问的数据结构。主要有以下优缺点:

    • 优点:1、访问某个元素速度很快 2、插入删除操作也很方便
    • 缺点:需要经过一系列运算比较复杂

方法缓存主要代码: image.png

  • 通过selmask调用cache_hash进行计算出哈希表下标;
  • 通过do-while循环防止哈希冲突;
  • 下标占用就调用cache_next再次哈希计算下标;
  • 有问题的缓存则调用bad_cache异常。

我们来看看这几个方法:

cache_hash算法

通过传入的selmask进行计算,将sel强转为uintptr_t类型,然后将得到的value值与上mask掩码

image.png

cache_next算法

非真机情况下通过上次计算出的下标加上1再次与上掩码mask;真机情况下如果i有值,则减1得到新下标,没有则直接取mask image.png

incrementOccupied方法

_occupied自增 image.png

bad_cache方法

得到有问题的缓存,直接抛异常 image.png

总结
  • 方法缓存是bucket容器存储方法的selimp
  • 方法缓存时每缓存一个方法_occupied就会加1,当我们的_occupied+2大于容量capacity时系统就会进行扩容,也就是capacity*2,这也就解释了我们问题1数值变化问题;由于扩容会使得重新分配容量也就是调用了reallocate方法,所以我们问题3所出现的丢失就是因为扩容导致释放了老容器,回收内存;
  • bucket通过哈希表实现存储的,每个bucket都是通过hash算法获取的下标,这也就能解释我们问题2为什么是无序的;

还有待探索,后续继续补充!