IOS 底层原理之cache分析

1,664 阅读14分钟

前言

IOS 底层原理之对象的本质&isa关联类IOS底层原理之类结构分析 分别分析了isabits里面的成员变量还有superclasscache,今天就来探究下cache的底层原理。说实话内心觉着cache没什么好探索的不就是个缓存嘛。我承认我飘了,我向cache道歉,个人觉着cache的底层探索是比较复杂的,里面有许多苹果底层代码的设计思路

准备工作

cache 结构分析

首先探究下cache的类型cache_t,源码中查看cache_t具体类型发现底层也是结构体

cache_t 结构分析

struct cache_t {
private:
    explicit_atomic<uintptr_t> _bucketsAndMaybeMask;
    union {
        struct {
            explicit_atomic<mask_t>    _maybeMask;
#if __LP64__
            uint16_t                   _flags;
#endif
            uint16_t                   _occupied;
        };
        explicit_atomic<preopt_cache_t *> _originalPreoptCache;
    };
   
    /*
     #if defined(__arm64__) && __LP64__
     #if TARGET_OS_OSX || TARGET_OS_SIMULATOR
     // __arm64__的模拟器
     #define CACHE_MASK_STORAGE CACHE_MASK_STORAGE_HIGH_16_BIG_ADDRS
     #else
     //__arm64__的真机
     #define CACHE_MASK_STORAGE CACHE_MASK_STORAGE_HIGH_16
     #endif
     #elif defined(__arm64__) && !__LP64__
     //32位 真机
     #define CACHE_MASK_STORAGE CACHE_MASK_STORAGE_LOW_4
     #else
     //macOS 模拟器
     #define CACHE_MASK_STORAGE CACHE_MASK_STORAGE_OUTLINED
     #endif
     ******  中间是不同的架构之间的判断 主要是用来不同类型 mask 和 buckets 的掩码
    */
    
    public:
    void incrementOccupied();
    void setBucketsAndMask(struct bucket_t *newBuckets, mask_t newMask);
    void reallocate(mask_t oldCapacity, mask_t newCapacity, bool freeOld);
    unsigned capacity() const;
    struct bucket_t *buckets() const;
    Class cls() const;
    void insert(SEL sel, IMP imp, id receiver);
    
    // 下面是基本上都是其他的方法的方法
 
};
  • _bucketsAndMaybeMask变量uintptr_t8字节isa_t中的bits类似,也是一个指针类型里面存放地址
  • 联合体里有一个结构体和一个结构体指针_originalPreoptCache
  • 结构体中有三个成员变量 _maybeMask_flags_occupied__LP64__指的是UnixUnix类系统(LinxmacOS
  • _originalPreoptCache和结构体是互斥的,_originalPreoptCache初始时候的缓存,现在探究类中的缓存,这个变量基本不会用到
  • cache_t提供了公用的方法去获取值,以及根据不同的架构系统去获取maskbuckets的掩码

cache_t看到了buckets(),这个类似于class_data_bits_t里面的提供的methods(),都是通过方法获取值。查看bucket_t源码

struct bucket_t {
private:
    // IMP-first is better for arm64e ptrauth and no worse for arm64.
    // SEL-first is better for armv7* and i386 and x86_64.
#if __arm64__ //真机
    explicit_atomic<uintptr_t> _imp;
    explicit_atomic<SEL> _sel;
#else
    explicit_atomic<SEL> _sel;
    explicit_atomic<uintptr_t> _imp;
#endif
  ....
  //下面是方法省略
};
  • bucket_t区分真机其它,但是变量没变都是_sel_imp只不过顺序不一样
  • bucket_t里面存的是_sel_impcache里面缓存的应该是方法

cache_t 整体结构图

image.png

lldb调试验证

首先创建LWPerson类,自定义一些实例方法,在main函数中创建LWPerson的实例化对象,然后进行lldb调试

image.png

  • cache的变量的地址,需要首地址偏移16字节0x10cache的地址首地址+0x10
  • cache_t中的方法buckets()指向的是一块内存的首地址,也是第一个bucket的地址
  • p/x $3.buckets()[indx]的方式打印内存中其余的bucket发现_selimp
  • LWPerson对象没有调用对象方法,buckets中没有缓存方法的数据

lldb中调用对象方法,[p sayHello]继续lldb调试

image.png

  • 调用sayHello后,_mayMaskoccupied被赋值,这两个变量应该和缓存是有关系
  • bucket_t结构提供了sel()imp(nil,pClass)方法
  • sayhello方法的selimp,存在bucket中,存在cache

总结

通过lldb调试,结合源码。cache中存的是方法,方法的selimp存在bucketlldb调试是比较麻烦的比如调用方法后,需要重新获取bukets(),不舒适,不丝滑。有没有一种比较丝滑的方法呢,那是必须有的嘛

代码转换测试

通过lldb调试和源码,基本弄清楚cache_t的结构。我们可以按照cache_t的代码结构模仿写一套,这样就不需要在源码环境下的通过lldb。如果需要调用方法,直接添加代码,重新运行就好,这是我们最熟悉的方式了。

typedef uint32_t mask_t;
struct lw_bucket_t {
    SEL _sel;
    IMP _imp;
};
struct lw_cache_t{
      struct lw_bucket_t * _buckets;
      mask_t      _maybeMask;
      uint16_t    _flags;
      uint16_t    _occupied;
};
 

struct lw_class_data_bits_t{
     uintptr_t bits;
};

 
struct lw_objc_class  {
    Class ISA;
    Class superclass;
    struct lw_cache_t cache;
    struct lw_class_data_bits_t bits;
};
int main(int argc, const char * argv[]) {
    @autoreleasepool {
    LWPerson * p =  [LWPerson alloc];
    [p sayHello1];
    [p sayHello2];
    //[p sayHello3];
    //[p sayHello4];
    //[p sayHello5];
    Class lwClass  = [LWPerson class];
    struct lw_objc_class * lw_class = (__bridge struct lw_objc_class *)(lwClass);
    NSLog(@" - %hu  - %u",lw_class->cache._occupied,lw_class->cache._maybeMask);
    
    for (int i = 0; i < lw_class->cache._maybeMask; i++) {
        struct lw_bucket_t bucket =lw_class->cache._buckets[i];
        NSLog(@"%@ - %p",NSStringFromSelector(bucket._sel),bucket._imp);
      }  
  }
    return 0;
}
2021-06-23 14:51:20.003332+0800 testClass[7899:291790] ---[LWPerson sayHello1]---
2021-06-23 14:51:20.003432+0800 testClass[7899:291790] ---[LWPerson sayHello2]---
2021-06-23 14:51:20.003516+0800 testClass[7899:291790]  - 2  - 3
2021-06-23 14:51:20.003603+0800 testClass[7899:291790] sayHello2 - 0x80b0
2021-06-23 14:51:20.003688+0800 testClass[7899:291790] sayHello1 - 0x8360
2021-06-23 14:51:20.003778+0800 testClass[7899:291790] (null) - 0x0
  • objc_classClass ISA是被注释的,因为objc_class是继承objc_object,她可以继承objc_objectClass ISA,自定义的结构体lw_objc_class要手动添加Class ISA,不然代码转换会转换错误
  • 自己定义的结构体越简便越好,只要能显示出主要的信息就可以

在添加sayHello3sayHello4sayHello5方法,看下打印结果

2021-06-23 14:53:45.514704+0800 testClass[7944:294241] ---[LWPerson sayHello1]---
2021-06-23 14:53:45.514817+0800 testClass[7944:294241] ---[LWPerson sayHello2]---
2021-06-23 14:53:45.514899+0800 testClass[7944:294241] ---[LWPerson sayHello3]---
2021-06-23 14:53:45.514982+0800 testClass[7944:294241] ---[LWPerson sayHello4]---
2021-06-23 14:53:45.515069+0800 testClass[7944:294241] ---[LWPerson sayHello5]---
2021-06-23 14:53:45.515161+0800 testClass[7944:294241]  - 3  - 7
2021-06-23 14:53:45.515235+0800 testClass[7944:294241] (null) - 0x0f
2021-06-23 14:53:45.515316+0800 testClass[7944:294241] sayHello3 - 0x180b8
2021-06-23 14:53:45.515411+0800 testClass[7944:294241] (null) - 0x0f
2021-06-23 14:53:45.515525+0800 testClass[7944:294241] sayHello4 - 0x180e8
2021-06-23 14:53:45.515610+0800 testClass[7944:294241] (null) - 0x0f
2021-06-23 14:53:45.515743+0800 testClass[7944:294241] sayHello5 - 0x180d8
2021-06-23 14:53:45.515827+0800 testClass[7944:294241] (null) - 0x0

我们会产生下面几个疑问

  • _occupied_maybeMask是什么?怎么还在变化呢?
  • sayHello1sayHello2方法怎么消失了?是谁施了魔法吗?
  • cache存储的位置怎么是乱序的呢?比如sayHello2sayHello1前面,sayHello3前面的位置是空的

带着这些疑问继续探讨cache_t,下一步怎么走呢?想要知道_occupied_maybeMask是什么?只有去看源码,看看在什么地方赋值的。我们要缓存方法,首先就要是怎么把方法插入到buket中的。带着这个思路让我们遨游 cache_t源码中

cache_t源码探究

首先找到方法缓存的入口 image.png insert(SEL sel, IMP imp, id receiver)里面有参数selimp,这不就是我们熟悉的方法嘛。而且还有方法名insert,看看它的具体实现,由于insert内的代码过多我们分步骤说明

计算当前所占容量

image.png

  • occupied()获取当前所占的容量,其实就是告诉你缓存中有几个bucket
  • newOccupied = occupied() + 1,表示你是第几个进来缓存的
  • oldCapacity 目的是为了重新扩容的时候释放旧的内存

开辟容量

image.png

  • 只有第一次缓存方法的时,才会去开辟容量默认开辟容量是 capacity = INIT_CACHE_SIZEcapacity = 4 就是4bucket的内存大小
  • reallocate(oldCapacity, capacity, /* freeOld */false)开辟内存,freeOld变量控制是否释放旧的内存

reallocate方法探究

image.png reallocate 方法主要做三件事

  • allocateBuckets开辟内存
  • setBucketsAndMask设置maskbuckets的值
  • collect_free是否释放旧的内存,由freeOld控制

allocateBuckets方法探究

image.png

allocateBuckets 方法主要做两件事

  • calloc(bytesForCapacity(newCapacity), 1)开辟newCapacity * bucket_t 大小的内存
  • end->set将开辟内存的最后一个位置存入sel = 1imp = 第一个buket位置的地址

setBucketsAndMask方法探究

image.png

setBucketsAndMask主要根据不同的架构系统向_bucketsAndMaybeMask_maybeMask写入数据

collect_free方法探究

image.png

collect_free主要是清空数据,回收内存

setBucketsAndMask方法探究

容量小于3/4

image.png

  • 当需要缓存的方法所占的容量总容量3/4是就会直接走缓存流程
  • 苹果的设计思想,探究了很多底层就会发现,苹果做什么事情都会留有余地。一方面可能为了日后的优化或者扩展,另一方面可能是为了安全,内存对齐也是这样

容量存满

image.png

  • 苹果提供变量,很人性化,如果你需要把缓存的容量存满,默认是不存满的
  • 个人建议不要存满,就按照默认的来,如果存满有可能出现其它的问题,很难去排查

容量超过3/4

image.png

  • 容量超过3/4,系统此时会进行两倍扩容,扩容的最大容量不会超过mask的最大值2^15
  • 扩容的时候会进行一步重要的操作,开辟新的内存,释放回收旧的内存,此时的freeOld = true

缓存方法

image.png

  • 首先拿到bucket()指向开辟这块内存首地址,也就是第一个bucket的地址,bucket()既不是数组也不是链表,只是一块连续的内存
  • hash函数根据缓存selmask,计算出hash下标。为什么需要mask呢?mask的实际作用是告诉系统你只能存前capacity - 1中的位置,比如capacity = 4时,缓存的方法只能存前面3个空位
  • 开始缓存,当前的位置没有数据,就缓存该方法。如果该位置有方法且和你的方法一样的,说明该方法缓存过了,直接return。如果存在hash冲突,下标一样,sel不一样,此时会进行再次hash,冲突解决继续缓存

cache_hashcache_next

image.png

cache_hash主要是生成hash下标,cache_next主要是解决hash冲突

缓存写入方法set

image.png

setselimp写入bucket,开始缓存方法

incrementOccupied

image.png

_occupied自动加1_occupied表示内存中已经存储缓存方法的的个数

insert调用流程

前面探究了insert方法里面具体实现了什么,下面探究的是调用一个实例方法怎么就调用了cache里面的insert方法呢?首先在insert方法中打个断点,然后运行源码

image.png

左边的堆栈信息显示调用insert方法流程: _objc_msgSend_uncached --> lookUpImpOrForward --> log_and_fill_cache --> cache_t::insert

堆栈信息只显示到_objc_msgSend_uncached,但是我们是调用了 [p sayHello1] 也就是实例方法最后调用了cache_t::insert。现在我们知道了部分流程_objc_msgSend_uncachedcache_t::insert过程。[p sayHello1]_objc_msgSend_uncached 这个过程并不清楚。怎么探究,遇事不决看汇编

image.png

[p sayHello1]底层实现的是objc_msgSend方法,虽然不知道这个方法的作用,不妨碍我们进入方法看看

image.png

  • objc_msgSend 方法里面 _objc_msgSend_uncached,豁然开朗,守得云开见月明啊。至此整个流程串联起来了
  • 调用insert方法流程:[p sayHello1]底层实现 objc_msgSend --> _objc_msgSend_uncached --> lookUpImpOrForward --> log_and_fill_cache --> cache_t::insert

insert调用流程图

image.png

cache_t 原理分析图

image.png

总结

cache_t 中各个变量的含义

  • _bucketsAndMaybeMask存储bucketsmsak(真机),macOS或者模拟器存储buckets
  • _maybeMask是指掩码数据,用于在哈希算法或者哈希冲突算法中哈希下标 _maybeMask = capacity -1
  • _occupied会随着缓存的个数增加,扩容是_occupied = 0
  • 数据丢失是因为扩容的时候旧的内存回收了数据全部清除
  • cache存储bucket的位置乱序,因为位置是hash根据你的selmask生成所以不固定

总结

探索的过程真的是痛并快乐着,虽然探索的过程比较枯燥乏味,但是坚持下来就会有收获。原以为cache的底层很简单,但事实很残酷,cache确实很给力。

补充

cache_tinsert方法详解

image.png

cache- 父类方法

细心的小伙伴可能发现,在代码转换测试的时候。调用的方法都是当前类的,没有去调用父类的。调用当父类的方法会怎么样呢?

int main(int argc, const char * argv[]) {
    @autoreleasepool {
       LWPerson * p = [LWPerson alloc];
        [p init];
        [p sayAllPerson]; //父类方法
      
        Class  pClass =  [LWPerson class];
        struct LW_objc_class * LW_pClass = (__bridge struct LW_objc_class *)(pClass);
        NSLog(@"%hu - %u",LW_pClass->cache._occupied,LW_pClass->cache._maybeMask);
        for (int i = 0; i < LW_pClass->cache._maybeMask; i++) {
            struct LW_bucket_t bucket =LW_pClass->cache._buckets[i];
            NSLog(@"%@ - %p",NSStringFromSelector(bucket._sel),bucket._imp);
        }
      }
    }
    return 0;
}
2021-06-24 19:27:47.309212+0800 KCObjcBuild[1140:17922] 2 - 3
2021-06-24 19:27:47.309558+0800 KCObjcBuild[1140:17922] init - 0x33dca0
2021-06-24 19:27:47.309859+0800 KCObjcBuild[1140:17922] sayAllPerson - 0x7cd0
2021-06-24 19:27:47.309923+0800 KCObjcBuild[1140:17922] (null) - 0x0
  • NSObject中的方法init和自定义的父类中方法sayAllPerson,都会缓存当前调用它的类中
  • 子类调用父类的方法,父类的方法就缓存到子类中,以便子类下次调用查找方法更快捷

lldb调试出现7

lldb调试中,调用一个实例方法,但是_maybeMask = 7。在代码模拟转换的方式中同样调用一个方法_maybeMask = 3。在lldb为什么显示7?首先在源码中打印出 selimp源码如下

image.png

lldb调用实例方法,查看lldb的信息

image.png

在调用 sayHello1方法,此时还没有缓存该方法,但是之前已经开辟了一次内存,里面3bucket有值

  • 缓存的方法有NSObjectrespondsToSelector方法,NSObjectclass方法,还有一个未知的方法
  • 最后一个未知的方法 sel=0x1,即sel = 1这个熟悉不,在上面探究allocateBuckets方法的时,有一行代码重点探究过end->set<NotAtomic, Raw>(newBuckets, (SEL)(uintptr_t)1, (IMP)newBuckets, nil)。最后一个bucket默认存放了sel=1imp = 开辟内存的首地址也就是buckets()的地址。注意此时set的方法的最后一个参数cls传的是nil相当于没有进行对imp编码

验证 sel=0x1imp是否是buckets()的地址,在源码中把buckets()的地址打印出来代码如下

image.png

image.png

  • lldb分析 sel=0x1imp的地址和buckets()的地址是一样的
  • sel=0x1imp为什么要和class进行异或运算。后面有分析和探究

总结

  • lldb调试_maybeMask = 7原因:sayHello1调用时,已经缓存了2个方法respondsToSelectorclasssel=0x1的这个方法,开辟内存的时候就存储了默认的。所以当调用sayHello1时正好是第3个缓存,超过当前容量的3/4,所以去扩容,把respondsToSelectorclass方法缓存清除。把sayHello1放入缓存中。所以lldb呈现给我们的就是 _maybeMask = 7occupied = 1
  • 调用respondsToSelectorclass原因:猜测lldb的环境和运行的环境不一样。第一次[LWPerson alloc]是会初始化NSObject[NSObject alloc],所以respondsToSelectorclass会缓存到NSObject类中。lldb环境不会初始化NSObject[NSObject alloc],调用实例方法,它会到NSObject中去找,然后缓存到LWPerson类中

imp编码解码

bucket中的的imp地址,存储的是经过编码以后强转成uintptr_t类型数据,解码是会还原成原来的imp

imp编码

image.png

  • b[i].set<Atomic, Encoded>(b, sel, imp, cls()) 缓存selimpset方法内会调用encodeImpencodeImp方法会对imp进行编码(uintptr_t)newImp ^ (uintptr_t)cls异或运算
  • bucket里面的imp是否进行编解码,除了外部变量控制以外,主要是看bucket_t::set(bucket_t *base, SEL newSel, IMP newImp, Class cls))cls参数是否为nilcls有值imp进行编码,cls没有值imp相当于没编码。所以缓存开辟内存的最后一个bucket调用set方法时cls = nil编码以后得到的还是原来的imp相当于没编码

imp解码

image.png

  • imp解码的方式即异或运算imp编码的异或运算是一样的
  • 上面lldb调试出现7中打印信息调用imp(nil, cls())imp(nil, cls())对最后一个bucketimp进行一次异或运算,所以想要恢复imp的原来的地址,需要手动进行一次异或运算

异或运算

异或运算:参与运算的两个值,如果两个相应位相同,则结果为0,否则为1

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        int a= 5 ;  int  b = 10;  int c = 0;
    }
    return 0;
}

image.png

image.png

c = a^b,如果想还原a的值 a = c^b或者 a = a^b^b。如果想还原b的值b = c^a或者 b = a^b^a

异或运算详解

a = 5            0000 0101
b = 10           0000 1010
c = a ^ b        0000 0101 ^ 0000 1010  =  0000 1111  = 15
a = c ^ b        0000 1111 ^ 0000 1010  =  0000 0101  = 5