前言
IOS 底层原理之对象的本质&isa关联类 和 IOS底层原理之类结构分析 分别分析了isa和bits。类里面的成员变量还有superclass和cache,今天就来探究下cache的底层原理。说实话内心觉着cache没什么好探索的不就是个缓存嘛。我承认我飘了,我向cache道歉,个人觉着cache的底层探索是比较复杂的,里面有许多苹果底层代码的设计思路
准备工作
- 速效救心丸
- 枸杞茶
- objc4-818.2 源码
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_t占8字节和isa_t中的bits类似,也是一个指针类型里面存放地址联合体里有一个结构体和一个结构体指针_originalPreoptCache结构体中有三个成员变量_maybeMask,_flags,_occupied。__LP64__指的是Unix和Unix类系统(Linx和macOS)_originalPreoptCache和结构体是互斥的,_originalPreoptCache初始时候的缓存,现在探究类中的缓存,这个变量基本不会用到cache_t提供了公用的方法去获取值,以及根据不同的架构系统去获取mask和buckets的掩码
在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和_imp,cache里面缓存的应该是方法
cache_t 整体结构图
lldb调试验证
首先创建LWPerson类,自定义一些实例方法,在main函数中创建LWPerson的实例化对象,然后进行lldb调试
cache的变量的地址,需要首地址偏移16字节即0x10,cache的地址首地址+0x10cache_t中的方法buckets()指向的是一块内存的首地址,也是第一个bucket的地址p/x $3.buckets()[indx]的方式打印内存中其余的bucket发现_sel和impLWPerson对象没有调用对象方法,buckets中没有缓存方法的数据
在lldb中调用对象方法,[p sayHello]继续lldb调试
- 调用
sayHello后,_mayMask和occupied被赋值,这两个变量应该和缓存是有关系 bucket_t结构提供了sel()和imp(nil,pClass)方法sayhello方法的sel和imp,存在bucket中,存在cache中
总结
通过lldb调试,结合源码。cache中存的是方法,方法的sel和imp存在bucket。lldb调试是比较麻烦的比如调用方法后,需要重新获取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_class的Class ISA是被注释的,因为objc_class是继承objc_object,她可以继承objc_object的Class ISA,自定义的结构体lw_objc_class要手动添加Class ISA,不然代码转换会转换错误- 自己定义的结构体越简便越好,只要能显示出主要的信息就可以
在添加sayHello3,sayHello4和sayHello5方法,看下打印结果
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是什么?怎么还在变化呢?sayHello1和sayHello2方法怎么消失了?是谁施了魔法吗?cache存储的位置怎么是乱序的呢?比如sayHello2在sayHello1前面,sayHello3前面的位置是空的
带着这些疑问继续探讨cache_t,下一步怎么走呢?想要知道_occupied和_maybeMask是什么?只有去看源码,看看在什么地方赋值的。我们要缓存方法,首先就要是怎么把方法插入到buket中的。带着这个思路让我们遨游
cache_t源码中
cache_t源码探究
首先找到方法缓存的入口
insert(SEL sel, IMP imp, id receiver)里面有参数sel和imp,这不就是我们熟悉的方法嘛。而且还有方法名insert,看看它的具体实现,由于insert内的代码过多我们分步骤说明
计算当前所占容量
occupied()获取当前所占的容量,其实就是告诉你缓存中有几个bucket了newOccupied = occupied() + 1,表示你是第几个进来缓存的oldCapacity目的是为了重新扩容的时候释放旧的内存
开辟容量
- 只有第一次缓存方法的时,才会去开辟容量默认开辟容量是
capacity = INIT_CACHE_SIZE即capacity = 4就是4个bucket的内存大小 reallocate(oldCapacity, capacity, /* freeOld */false)开辟内存,freeOld变量控制是否释放旧的内存
reallocate方法探究
reallocate 方法主要做三件事
allocateBuckets开辟内存setBucketsAndMask设置mask和buckets的值collect_free是否释放旧的内存,由freeOld控制
allocateBuckets方法探究
allocateBuckets 方法主要做两件事
calloc(bytesForCapacity(newCapacity), 1)开辟newCapacity * bucket_t大小的内存end->set将开辟内存的最后一个位置存入sel=1,imp=第一个buket位置的地址
setBucketsAndMask方法探究
setBucketsAndMask主要根据不同的架构系统向_bucketsAndMaybeMask 和 _maybeMask写入数据
collect_free方法探究
collect_free主要是清空数据,回收内存
setBucketsAndMask方法探究
容量小于3/4
- 当需要缓存的方法所占的容量总容量
3/4是就会直接走缓存流程 - 苹果的设计思想,探究了很多底层就会发现,苹果做什么事情都会留有余地。一方面可能为了日后的优化或者扩展,另一方面可能是为了安全,内存对齐也是这样
容量存满
- 苹果提供变量,很人性化,如果你需要把缓存的容量存满,默认是不存满的
- 个人建议不要存满,就按照默认的来,如果存满有可能出现其它的问题,很难去排查
容量超过3/4
- 容量超过
3/4,系统此时会进行两倍扩容,扩容的最大容量不会超过mask的最大值2^15 - 扩容的时候会进行一步重要的操作,开辟新的内存,释放回收旧的内存,此时的
freeOld = true
缓存方法
- 首先拿到
bucket()指向开辟这块内存首地址,也就是第一个bucket的地址,bucket()既不是数组也不是链表,只是一块连续的内存 hash函数根据缓存sel和mask,计算出hash下标。为什么需要mask呢?mask的实际作用是告诉系统你只能存前capacity - 1中的位置,比如capacity = 4时,缓存的方法只能存前面3个空位- 开始缓存,当前的位置没有数据,就缓存该方法。如果该位置有方法且和你的方法一样的,说明该方法缓存过了,直接
return。如果存在hash冲突,下标一样,sel不一样,此时会进行再次hash,冲突解决继续缓存
cache_hash 和 cache_next
cache_hash主要是生成hash下标,cache_next主要是解决hash冲突
缓存写入方法set
set把sel和imp写入bucket,开始缓存方法
incrementOccupied
_occupied自动加1,_occupied表示内存中已经存储缓存方法的的个数
insert调用流程
前面探究了insert方法里面具体实现了什么,下面探究的是调用一个实例方法怎么就调用了cache里面的insert方法呢?首先在insert方法中打个断点,然后运行源码
左边的堆栈信息显示调用insert方法流程: _objc_msgSend_uncached --> lookUpImpOrForward --> log_and_fill_cache --> cache_t::insert
堆栈信息只显示到_objc_msgSend_uncached,但是我们是调用了 [p sayHello1] 也就是实例方法最后调用了cache_t::insert。现在我们知道了部分流程_objc_msgSend_uncached 到 cache_t::insert过程。[p sayHello1] 到 _objc_msgSend_uncached 这个过程并不清楚。怎么探究,遇事不决看汇编
[p sayHello1]底层实现的是objc_msgSend方法,虽然不知道这个方法的作用,不妨碍我们进入方法看看
objc_msgSend方法里面_objc_msgSend_uncached,豁然开朗,守得云开见月明啊。至此整个流程串联起来了- 调用
insert方法流程:[p sayHello1]底层实现objc_msgSend-->_objc_msgSend_uncached-->lookUpImpOrForward-->log_and_fill_cache-->cache_t::insert
insert调用流程图
cache_t 原理分析图
总结
cache_t 中各个变量的含义
_bucketsAndMaybeMask存储buckets和msak(真机),macOS或者模拟器存储buckets_maybeMask是指掩码数据,用于在哈希算法或者哈希冲突算法中哈希下标_maybeMask=capacity -1_occupied会随着缓存的个数增加,扩容是_occupied=0- 数据丢失是因为
扩容的时候旧的内存回收了数据全部清除 cache存储bucket的位置乱序,因为位置是hash根据你的sel和mask生成所以不固定
总结
探索的过程真的是痛并快乐着,虽然探索的过程比较枯燥乏味,但是坚持下来就会有收获。原以为cache的底层很简单,但事实很残酷,cache确实很给力。
补充
cache_t中insert方法详解
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?首先在源码中打印出
sel和imp源码如下
在lldb调用实例方法,查看lldb的信息
在调用 sayHello1方法,此时还没有缓存该方法,但是之前已经开辟了一次内存,里面3个bucket有值
- 缓存的方法有
NSObject的respondsToSelector方法,NSObject的class方法,还有一个未知的方法 - 最后一个未知的方法
sel=0x1,即sel=1这个熟悉不,在上面探究allocateBuckets方法的时,有一行代码重点探究过end->set<NotAtomic, Raw>(newBuckets, (SEL)(uintptr_t)1, (IMP)newBuckets, nil)。最后一个bucket默认存放了sel=1和imp=开辟内存的首地址也就是buckets()的地址。注意此时set的方法的最后一个参数cls传的是nil相当于没有进行对imp编码
验证 sel=0x1的imp是否是buckets()的地址,在源码中把buckets()的地址打印出来代码如下
lldb分析sel=0x1的imp的地址和buckets()的地址是一样的sel=0x1的imp为什么要和class进行异或运算。后面有分析和探究
总结
lldb调试_maybeMask=7原因:sayHello1调用时,已经缓存了2个方法respondsToSelector和class。sel=0x1的这个方法,开辟内存的时候就存储了默认的。所以当调用sayHello1时正好是第3个缓存,超过当前容量的3/4,所以去扩容,把respondsToSelector和class方法缓存清除。把sayHello1放入缓存中。所以lldb呈现给我们的就是_maybeMask=7,occupied=1- 调用
respondsToSelector和class原因:猜测lldb的环境和运行的环境不一样。第一次[LWPerson alloc]是会初始化NSObject即[NSObject alloc],所以respondsToSelector和class会缓存到NSObject类中。lldb环境不会初始化NSObject即[NSObject alloc],调用实例方法,它会到NSObject中去找,然后缓存到LWPerson类中
imp编码解码
bucket中的的imp地址,存储的是经过编码以后强转成uintptr_t类型数据,解码是会还原成原来的imp
imp编码
b[i].set<Atomic, Encoded>(b, sel, imp, cls())缓存sel,imp。set方法内会调用encodeImp。encodeImp方法会对imp进行编码(uintptr_t)newImp ^ (uintptr_t)cls即异或运算bucket里面的imp是否进行编解码,除了外部变量控制以外,主要是看bucket_t::set(bucket_t *base, SEL newSel, IMP newImp, Class cls))的cls参数是否为nil。cls有值imp进行编码,cls没有值imp相当于没编码。所以缓存开辟内存的最后一个bucket调用set方法时cls=nil编码以后得到的还是原来的imp相当于没编码
imp解码
imp解码的方式即异或运算和imp编码的异或运算是一样的- 上面
lldb调试出现7中打印信息调用imp(nil, cls()),imp(nil, cls())对最后一个bucket的imp进行一次异或运算,所以想要恢复imp的原来的地址,需要手动进行一次异或运算
异或运算
异或运算:参与运算的两个值,如果两个相应位相同,则结果为0,否则为1
int main(int argc, const char * argv[]) {
@autoreleasepool {
int a= 5 ; int b = 10; int c = 0;
}
return 0;
}
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