OC底层原理之-类结构的cache_t分析

595 阅读8分钟

前言

我们之前介绍过类的结构(类的结构传送门),重点介绍了class_data_bits_t的内部结构。这张我们着重介绍下类结构里的另一个重要的属性cache_t。

初探cache_t

我们先准备环境,创建Person类,在.h,.m,main.m写如下代码

@interface Person : NSObject
- (void)likeFood;
- (void)homeAddress;
- (void)toWorkTime;
- (void)toSleepTime;
@end
@implementation Person
- (void)likeFood {
    NSLog(@"Person say : %s",__func__);
}
- (void)homeAddress {
    NSLog(@"Person say : %s",__func__);
}
- (void)toWorkTime {
    NSLog(@"Person say : %s",__func__);
}
- (void)toSleepTime {
    NSLog(@"Person say : %s",__func__);
}
@end
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // insert code here...
        Person *p  = [Person alloc];
        Class pClass = [Person class];
        [p likeFood];
        [p homeAddress];
        [p toSleepTime];
        [p toWorkTime];
    }
    return 0;
}

运行打断点,我们知道在获取class_data_bits_t时用了内存平移,这次获取cache_t也应该类似,我们试一下。 我们看到断点还没有执行方法,此时打印的cache_t如上图所示,注意红框内容,此时为0; 我们过一下断点 我们发现红框的内容_mask由之前的0变为了3,_occupied有原来的0变为了1。这中间的变化就是执行了一个方法。我们继续过断点 我们发现_mask还是3并未变化,_occupied有原来的1变为了2。这中间的变化就是又执行了一个方法。我们继续过断点 我们发现_mask由之前的3变为了7,_occupied有原来的2变为了1。这中间的变化就是又执行了一个方法。继续过断点 我们发现_mask还是7并未变化,_occupied有原来的1变为了2。这中间的变化就是又执行了一个方法。

我们发现cache_t的变化跟执行方法有关,因为cache_t是做缓存的,那就可能里面存了执行过的方法。至于_mask跟_occupied的变化规律,我们通过源码来分析。

了解cache_t内部结构

我们看下源码的cache_t内部结构是什么样的,里面代码太多了,只贴重要的信息

  • CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_OUTLINED这个指如果是Mac,
  • CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_HIGH_16指是真机
  • CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_LOW_4指模拟器

我们再去看看bucket_t里面都是什么,贴重要的

explicit_atomic是原子性操作,因为是缓存,所以保证安全

可以看到里面有_imp和_sel其中#if__arm64__如果是真机的意思 我们发现这里面没有我们打印出现的_occupied,那它在哪呢?我们全局搜一下 看了下好像都不对,我们再搜下occupied,我们找打如下的方法

insert是加入的意思,那这个方法是不是向cache_t里插入方法的方法呢?我们打断点验证下。

探究cache_t的插入过程

运行代码,我们发现代码进来了,我们先总览一下这个方法 下面我们解释下方法

  • mask_t newOccupied = occupied() + 1;occupied()调用方法,返回的是_occupied,初始值为0,也就是新来一个方法,newOccupied就等于1,来一个方法就+1,它的值相当与当前存了多少方法
  • unsigned oldCapacity = capacity(), capacity = oldCapacity;先取旧值capacity()返回的是上次的内存空间大小,让capacity等于上次的空间大小
  • if (slowpath(isConstantEmptyCache())) {就是判断当前的cache是否有值,如果没有进入,我们第一次调方法肯定没有值。
  • if (!capacity) capacity = INIT_CACHE_SIZE;我们第一次调用capacity肯定为空,所以给capacity赋个零时值为4,INIT_CACHE_SIZE = (1 << INIT_CACHE_SIZE_LOG2),是1左移2位,就是4.
  • reallocate(oldCapacity, capacity, /* freeOld */false);reallocate方法内部是初始化cache_t,我们这里是开辟4个空间大小的cache_t
  • else if (fastpath(newOccupied + CACHE_END_MARKER <= capacity / 4*3)) {如果newOccupied+1小于等于开辟空间大小的3/4时,就进入,否则就进入下面。我们这里是进去,
  • bucket_t *b = buckets();当前的cache_t的sel以及imp对应的哈希表给b
  • mask_t m = capacity - 1;是当前的m等于开辟的内存-1.
  • mask_t begin = cache_hash(sel, m);是拿当前的m跟方法名通过哈希算法得到下标,cache_hash方法:return (mask_t)(uintptr_t)sel & mask;就是方法名跟当前内存大小取与
  • 后面集中说下,把当前方法要存的下标给i,进入循环,这个bucket_t集合取下标i的sel,如果不存在直接将方法名sel以及imp存在bucket_t,跳出循环,如果等于sel则返回结束,如果存在切不等于,那就将算的下标+1在进行哈希运算,得到新的下标,继续比,知道不存在,存入返回结束

我们刚有个判断,如果小于等于开辟空间大小的3/4,就到下面进行存,如果大于会走下面的方法

  • capacity = capacity ? capacity * 2 : INIT_CACHE_SIZE;是如果大于开辟内存的3/4,capacity如果存在就开辟之前空间的2倍,如果不存在就开辟4个字节大小
  • 后面是给capacity一个最大值,如果大于最大值就取最大值,最大值是1左移16位
  • 开辟空间,但跟之前的cache_t为空不同,它会走cache_collect_free方法,这方法是把老的内存方法存到garbage_refs里

上面就是方法调用时如何插入到cache_t中。实际操作发现,如果cache_t存在该方法,再次调用该方法,是不走cache_t::insert方法的

补充内容

后面我有研究了cache_t的方法,现将研究内容写一下,不对的地方希望大家指正。

cache_t判断

isConstantEmptyCache方法理解

第653行,这个判断通过下面的注释我们知道如果cache_t是只读属性,就替换成新的。原因也很简单,因为下面要进行方法写入,这里的cache_t只读明显是不行的。我们需要去看看这个判空是怎么操作的。我们进入方法

之前说了occupied()如果是新的,那么默认值为0,如果不是新的(里面有缓存)那么就不为0,所以如果cache_t不是新的,就返回false。

但是我们还是去看下emptyBucketsForCapacity方法

  • 518-522行:就是上锁
  • 524行:获取当前cache_t的大小
  • 527-529行:大小小于EMPTY_BYTES直接返回新的Buckets(cache_t就是bucket的集合)
  • 走到532行说明,大小不小于EMPTY_BYTES此时就是在堆区分配一个buckets。533行就是设置默认值为0。
  • 535行就是让index经过一系列的计算(里面是个指数函数)
  • 537-554行就是初始化一个等大的空间,将newBuckets存储进emptyBucketsList,把中间空的数组填满,便于hash key 落在之间的对象获取bucket_t数组。
  • 返回emptyBucketsList下标为1的元素。 上面判断在653行如果不是新的cache_t,基本上是不会走到655行的。

cache_collect方法理解

上面讲到,如果调用了reallocate方法,oldCapacity旧值存在(不是新值)就会调用cache_collect_free方法,下面我们看下cache_collect_free方法,下图

  • 981-984行:还是加锁
  • 986行:PrintCaches是否开启方法写入
  • 988行:就是对garbage进行扩容
  • 989行:就是将老的缓存大小记录下来(累加到garbage_byte_size)
  • 990行:就是将缓存值存到garbage_refs中
  • 991行:调用cache_collect传入false

下面我们看下cache_collect方法 说明:折叠的方法是不用看的,PrintCaches是是否开启日志打印,我们没开启,所以不用看(1023,1046行)。1023行,因为我们刚传的是false,所以判断会走1013行

  • 1001-1005行:上锁
  • 1008-1010行:就是判断garbage是不是满了,如果未满且传值为false,直接返回。如果满了就继续走
  • 1012-1022行:如果为false,就判断是否有在查询缓存,如果有就返回,没有继续
  • 1037-1041行:清除garbage将garbage存的数据释放
  • 1047-1048行:都置为0. 什么时候会清garbage,发现只有刷新所有方法缓存才会清。目前只有调用instrumentObjcMessageSends置为YES。 以上就补充内容

下面我们运行去打印cache_t的内容

打印cache_t

打印过程及分析

我之前文章写过如何打印class_data_bits_t(传送门),打印cache_t也是一样的方法 我们运行代码,打断点 因为cache_t在class中是偏移了16字节,故需在class首地址偏移16 我们打印*1取出1取出1地址下的信息, 按着class_data_bits_t,类推,cache_t里包含_buckets,_mask(因为实在mac上运行), 而_buckets里面又包含imp,sel,打印应该如下 我们发现不对,这是为什么?我们在找class_data_bits_t,先调用data()方法获取都bits.data(),返回的是class_rw_t类型的对象,接着我们调用的获取属性方法properties(),获取方法列表方法methods(),而不是直接拿的属性。那cache_t有没有这些方法呢?我们查看cache_t源码 上图红框所示,获取bucket_t的方法,第一个是返回empty的Buckets。知道这些,我们在打印 在打印sel的时候又不对了,我们再去bucket_t方法里寻找,发现下图 sel()是获取SEL方法,imp(Class cls)是获取imp方法,所以我们._sel是不对的。我们按方法打印 打印出来我们想要的方法了。imp(pClass),是因为imp是需要跟class参数的。

通过上面的打印我们总结如下

  • 结构体属性的指针打印,可以通过偏移量进行
  • 获取属性,优先使用方法,查看整个方法,看看有没有返回该属性的方法,有的话优先使用
  • *1这种是拿到1这种是拿到1地址N内存储的内容,它的内容包含这块内容的指针地址,需要拿地址才能获取到它的内容

如何打印下个方法呢?上面我们分析cache_t的存入,方法是转成bucket_t类型通过哈希算法存到cache_t的,所以他们下标是不连续的,是根据哈希算法得到的值,我们上面已经获取到一个likeFood的bucket_t,那么homeAddress的bucket_t是不是在下一位,试一下 拿到homeAddress了

上面我们探究的是_mask为3,_occupied为2的情况,我们看下_mask为7,_occupied为2的情况,打印出现有意思的情况,打印比较长 第一次打印什么都没有,该bucket_t为空,不存在值 打印有值,把toWorkTime打印出来了。,继续下一位 打印也是什么都没有,我们的toSleepTime还没找到,继续平移下一位 依然什么都没有,继续下一位 这次打印,我们找到了toSleepTime,我们继续打印完,看看有没有我们前面调用的likeFood方法。 到此打印完了,我们移动了7次,内存打印完,并没有发现之前调用的likeFood以及homeAddress方法。总结如下:

  • 方法位置是无序的,是根据哈希算法算的位置,所以方法没有前后之分,算的是什么位置,就在什么位置。
  • 内存不够的时候,开辟新内存,会将原来的存储内容清空

上面讲cache_t的存储过程,打印也印证了我们的分析。

最后

传一张cache_t的流程图 cache_t的过程分析完了