iOS 底层探索06——iOS方法缓存(上)

515 阅读6分钟

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

前言

经过前面的探究,我们对class中的bits中的信息已经有所了解,但是class中还有一个专门缓存方法cache, 这里一起对cache进行探究下;

Class探究注意点

数组取值方式

  1. 在取数组中对象值的时候我们可以通过C++提供的根据数组下标取值的方法p $n.get(0)
  2. 也可以通过内存平移的方式p &$n + 1$$n是数组起始的地址,此时+1会自动根据当前数组中数据的类型进行地址偏移;

001.jpg

M1电脑查看method

  1. M1电脑因为架构原因在打印method的时候需要使用small().getDescription()进行打印

Class对象的isa

  1. 为什么classisa 获取 MetaClass的时候不需要&isa_Mask,或者说&了之后也没用?
  2. instance对象的isa里面存储了是否正在释放是否加入到弱引用等信息,剥离掉这部分信息之后才是Class,所以需要&isa_Mask进行剥离;
  3. class因为整个app生命周期内都只有一份,不需要像instance对象那样保存很多其他信息在isa中,classisa只存储了MetaClass,所以classisa不需要&isa_Mask,即便&isa_Mask它的值也不会有变化;

iOS常见架构信息

  1. 真机arm64;模拟器:i386,mac:x86_64;
  2. 常见数据类型 LP64.png

哈希表

  1. 数组是从头到位固定长度的一段连续空间,根据下标取值很方便,但是增删不方便,要循环便利慢慢找空位置,需要一个一个遍历才能知道是否对应上;
  2. 链表是通过前后节点连接起来的一种数据,增删数据很方便,但是查找数据不方便,需要一个一个遍历数据;
  3. hash链表是结合了数组链表的优势,方便增删的同时又方便查找;
  4. 根据哈希函数可以很容易的找到当前的数据存储的位置,当多个数据得到的同一个存储位置时就发生了哈希冲突,此时需要再次对输入数据计算哈希值作为存储的下标;哈希列表存入的位置不是按照顺序的,它必定会浪费一部分空间,但是带来的是时间效率的提升;

缓存Cache_t

Cache_t基础数据结构

  1. 由于cache_tclass中位于第2个位置,它的前面存放着isasuperclass 002.jpg
  2. 我们可以通过class对象地址偏移16个字节的方式获取cache_t的地址,然后强转为cache_t* 并打印,里面的信息如下
(lldb) p/x pClass //获取GCPerson类
(Class) $0 = 0x0000000100004520 GCPerson 
(lldb) p (cache_t *)0x0000000100004530 //0x0000000100004520 + 0x10
(cache_t *) $1 = 0x0000000100004530
(lldb) p *$1// 取$1的地址进行打印
(cache_t) $2 = {
  _bucketsAndMaybeMask = {
    std::__1::atomic<unsigned long> = {
      Value = 4298421120
    }
  }
   = {
     = {
      _maybeMask = {
        std::__1::atomic<unsigned int> = {
          Value = 0
        }
      }
      _flags = 32808
      _occupied = 0
    }
    _originalPreoptCache = {
      std::__1::atomic<preopt_cache_t *> = {
        Value = 0x0000802800000000
      }
    }
  }
}
(lldb) 
  1. 进入cache_t的定义中发现cache_t中的方法大部分都是围绕bucket_t这个类型进行操作,例如insert()emptyBuckets(),说明cache_t中的核心数据类型是bucket_t
    static bucket_t *emptyBuckets();
    static bucket_t *allocateBuckets(mask_t newCapacity);
    static bucket_t *emptyBucketsForCapacity(mask_t capacity, bool allocate = true);
    void cache_t::insert(SEL sel, IMP imp, id receiver)
}
  1. bucket_t中主要存放SELIMP,如果说bucket_t中的selimp对应书的目录和内容,那么buctets则相当于一本书,书中有很多目录和内容 003.jpg

LLDB调试验证Cache_t结构

验证步骤

  1. 首先打印出P $3._maybeMask;
  2. 直接打印数据失败,寻找结构体提供的获取数据的方法struct bucket_t *buckets() const;
  3. 发现2打印为空,猜测是因为没有调用方法,所以没有任何缓存,尝试用lldb调用方法[p saysomething],再次打印buckets()[1]发现有值了,但是不一定每次都有值,主要原因是buckets是用哈希表进行存储的,存放的下标是经过哈希函数计算后分配的,不是按照顺序从0开始存放;
  4. 哈希冲突发生时,会结合拉链法二次哈希哈希后下标减1等操作,来重新获取下标;
  5. 打印buckets()[1] 虽然有值,但不是我们需要的,经过分析发现bucket_t中提供了sel()和imp()这两个方法,尝试使用方法打印 p $13.sel() p $13.imp(nil,pClass)最终成功; 具体调试代码如下
//源码 
GCPerson *p  = [GCPerson alloc];
Class pClass = [GCPerson class];
NSLog(@"%@",pClass);

//LLDB调试
(lldb) po [p saySomething]//0、先调用方法让缓存有数据
2021-06-26 02:49:13.859894+0800 KCObjcBuild[71828:6438662] -[GCPerson saySomething]
(lldb) p/x pClass //1、打印class
(Class) $0 = 0x0000000100004520 GCPerson
(lldb) p (cache_t *)0x0000000100004530//2、 class 地址偏移获取cache地址
(cache_t *) $1 = 0x0000000100004530
(lldb) p $1->buckets()//3、 获取cache中buckets地址
(bucket_t *) $2 = 0x000000010065cf90
(lldb) p $2[0]//尝试打印哈希表中的bucket数据失败了
(bucket_t) $3 = {
  _sel = {
    std::__1::atomic<objc_selector *> = (null) {
      Value = (null)
    }
  }
  _imp = {
    std::__1::atomic<unsigned long> = {
      Value = 0
    }
  }
}
(lldb) p $2[1]//尝试打印哈希表中的bucket数据又失败了
(bucket_t) $4 = {
  _sel = {
    std::__1::atomic<objc_selector *> = (null) {
      Value = (null)
    }
  }
  _imp = {
    std::__1::atomic<unsigned long> = {
      Value = 0
    }
  }
}
(lldb) p $2[2]////尝试打印哈希表中的bucket数据成功了
(bucket_t) $5 = {
  _sel = {
    std::__1::atomic<objc_selector *> = "" {
      Value = ""
    }
  }
  _imp = {
    std::__1::atomic<unsigned long> = {
      Value = 31024
    }
  }
}
(lldb) p $5.sel()//获取bucket中的sel
(SEL) $6 = "saySomething"
(lldb) p $5.imp(nil,pClass)//获取bucket中的imp
(IMP) $7 = 0x0000000100003c10 (KCObjcBuild`-[GCPerson saySomething])
(lldb) 

仿写Cache_t

  1. 通过源码和LLDB结合调试、我们可以分析出来cache_tbucketsbucket之间的关系;

cache_t.png

  1. 某些场景下我们源码无法调试、LLDB调试不够方便,我们还可以通过仿写系统cache_t bucketsbucket的数据结构,进行小规模取样,在无源码环境下进行调试;
  2. 仿写系统的数据结构时,各个数据的位置内存占用size一定要和objc的源码里的结构对应上,否则获取的数据将无法于objc的源码中数据进行匹配; 4.仿写代码的原理是通过强制类型转换,把数据转换成我们需要的形式,实际上产生这些数据的还是被仿写的代码,仿写后只是方便我们观察调试;

最终的仿写代码如下

typedef uint32_t mask_t;  // x86_64 & arm64 asm are less efficient with 16-bits

struct gc_bucket_t {//仿写objc源码的bucket
    SEL _sel;
    IMP _imp;
};
struct gc_cache_t {//仿写objc源码的cache_t
    struct gc_bucket_t *_bukets; // 8
    mask_t    _maybeMask; // 4
    uint16_t  _flags;  // 2
    uint16_t  _occupied; // 2
};

struct gc_class_data_bits_t {//仿写objc源码的class_data_bits_t
    uintptr_t bits;
};

// cache class
struct gc_objc_class {//仿写objc源码的objc_class
    Class isa;
    Class superclass;
    struct gc_cache_t cache;             // formerly cache pointer and vtable
    struct gc_class_data_bits_t bits;
};

调试仿写的cache_t

  1. 准备好仿写代码之后,我们添加一些调试代码对仿写的cache_t进行分析,调试代码如下
        GCPerson *p  = [GCPerson alloc];
        Class pClass = p.class;  // objc_clas
        [p say1];
        [p say2];
        [p say3];
        [p say4];
        [p say1];
        [p say2];
        [pClass sayHappy];
        struct gc_objc_class *gc_class = (__bridge struct gc_objc_class *)(pClass);
        NSLog(@"%hu - %u",gc_class->cache._occupied,gc_class->cache._maybeMask);
        for (mask_t i = 0; i<gc_class->cache._maybeMask; i++) {
            struct gc_bucket_t bucket = gc_class->cache._bukets[i];
            NSLog(@"%@ - %pf",NSStringFromSelector(bucket._sel),bucket._imp);
        }
  1. 通过调试仿写代码,依次打印出调用不同方法的时候_occupied_maybeMaskbucket._selbucket._imp的数据;
  2. 根据仿写猜测出_occupied:已占用的缓存个数;_maybeMask:当前的缓存空间size-1
  3. 父类的方法也会进行缓存;

缓存插入过程分析;

  1. explicit_atomic<uintptr_t> _bucketsAndMaybeMask;是无符号长整型,像isa一样不同的位数存储不同的信息,主要有_bucketsMaybeMask
  2. _bucketsAndMaybeMask里面的数据,我们猜测是通过void insert(SEL sel, IMP imp, id receiver)方法进行插入的;
  3. 为什么扩容不能追加到后面?因为原来的缓存表是连续的空间,追加是新开辟的没办法再次加到原来的后面了,不再是连续的空间;
  4. 为什么不把原来的数据重新存放到扩容后的空间?因为数组平移很耗内存,查找方法缓存调用及其频繁,做这样的操作会产生很大的性能问题;

insert方法源码分析

void cache_t::insert(SEL sel, IMP imp, id receiver)
{
    mask_t newOccupied = occupied() + 1; // 1+1 //进入之后先将occupied+1
    unsigned oldCapacity = capacity(), capacity = oldCapacity;
    if (slowpath(isConstantEmptyCache())) {//缓存为空时进入
        if (!capacity) capacity = INIT_CACHE_SIZE;//4 1左移两位=4;
        reallocate(oldCapacity, capacity, /* freeOld */false);//根据旧容量和新容量创建筒子,空筒子不需要清空
    }
    //缓存不为空时进入
    //虽然还没插入,newOccupied在此时已经加过1了
    //newOccupied + 1 <= 容量的3/4 则不处理
    else if (fastpath(newOccupied + CACHE_END_MARKER <= cache_fill_ratio(capacity))) {
        // Cache is less than 3/4 or 7/8 full. Use it as-is.
    }
    //如果newOccupied + 1 > 容量的3/4 则扩容
    else {// 4*2 = 8
        //重新分配容量
        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; // 容量-1:4-1=3
    mask_t begin = cache_hash(sel, m);//根据容量和sel通过hash来 获取插入的开始位置;从开始位置去找空间插入,这里并不能直接获取要插入的位置
    mask_t i = begin;
    
    do {
        if (fastpath(b[i].sel() == 0)) {//如果起始位置为空则直接插入;
            incrementOccupied();//_occupied加1;
            b[i].set<Atomic, Encoded>(b, sel, imp, cls());//在i的位置插入这个缓存
            return;
        }
        if (b[i].sel() == sel) {//如果起始位置已存入方法,则直接返回
            return;
        }
    } while (fastpath((i = cache_next(i, m)) != begin));//再次进行哈希,如果再次哈希后的开始位置!=当前的开始位置则,则继续插入,如果再次哈希和第一次哈希一样,就结束插入;

    bad_cache(receiver, (SEL)sel);//再次哈希的起始值和首次哈希一致时,按照坏缓存处理;
}
  1. reallocatesetBucketsAndMask 源码分析
void cache_t::reallocate(mask_t oldCapacity, mask_t newCapacity, bool freeOld)
{
    bucket_t *oldBuckets = buckets();//获取原有的筒子
    bucket_t *newBuckets = allocateBuckets(newCapacity);//开辟新筒子
    setBucketsAndMask(newBuckets, newCapacity - 1);//插入空筒子和(新的容量-1)
    if (freeOld) {//释放旧筒子
        collect_free(oldBuckets, oldCapacity);
    }
}
void cache_t::setBucketsAndMask(struct bucket_t *newBuckets, mask_t newMask)
{
    _bucketsAndMaybeMask.store((uintptr_t)newBuckets, memory_order_release);//储存newBuckets 和 memory_order_release
    _maybeMask.store(newMask, memory_order_release);
    _occupied = 0;//设置 _occupied = 0

}

缓存插入总结

  1. 首次插入会直接开辟筒子,分配容量,设置占用;
  2. 进入insert会先增加occupied,用occupied+1 <= 容量的3/4,如果>3/4就扩容为原来的2倍
  3. 插入时根据hash算法找出插入的起始位置,然后循环计算二次哈希,直到二次哈希首次哈希一致时,进行坏缓存处理;

缓存流程图

cache insert流程.png 看着这张图我只想问一句,还有谁