前面的文章我们详细探讨了OC对象isa中isa、superclass以及bits字段的作用,因此我们现在就来探究以下cache字段的作用。
1. cache字段源码分析
首先,先来看看objc_class结构体中cache字段的数据类型,如下所示:
struct objc_class : objc_object {
objc_class(const objc_class&) = delete;
objc_class(objc_class&&) = delete;
void operator=(const objc_class&) = delete;
void operator=(objc_class&&) = delete;
// Class ISA;
Class superclass;
cache_t cache; // formerly cache pointer and vtable
class_data_bits_t bits; // class_rw_t * plus custom rr/alloc flags
...
...
...
}
我们发现其实cache是cache_t类型的数据类型,因此我们再来查看cache_t这个结构体类型,如下所示:
struct cache_t {
private:
explicit_atomic<uintptr_t> _bucketsAndMaybeMask; // 装载bucket的存储空间的首地址
union { //共用体,下面的struct与_originalPreoptCache互斥
struct {
explicit_atomic<mask_t> _maybeMask; // 装载bucket开辟的最大容量-1
#if __LP64__ //在Linux系统或Mac OS系统中,__LP64__这个宏就为真
uint16_t _flags;
#endif
uint16_t _occupied; //当前存储bucket的个数
};
explicit_atomic<preopt_cache_t *> _originalPreoptCache; // 8
};
...
...
...
//下面是使用的一些重要方法
mask_t mask() const;
//bucket占用数量加一
void incrementOccupied();
void setBucketsAndMask(struct bucket_t *newBuckets, mask_t newMask);
//清空所有的bucket
static bucket_t *emptyBuckets();
static bucket_t *allocateBuckets(mask_t newCapacity);
static bucket_t *emptyBucketsForCapacity(mask_t capacity, bool allocate = true);
static struct bucket_t * endMarker(struct bucket_t *b, uint32_t cap);
//获取开辟的bucket的空间数量
unsigned capacity() const;
//获取buckets的方法
struct bucket_t *buckets() const;
//获取bucket实际的占用数量
mask_t occupied() const;
//插入,sel(方法编号),imp(函数实现地址),receiver(方法接收者)
void insert(SEL sel, IMP imp, id receiver);
...
...
...
}
其中,__LP64__宏的定义可以查看下表
经过对cache_t结构体的探究,发现其中有很多是关于bucket_t这种数据结构的操作,因此我们再来查看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
...
...
...
//重要方法
inline SEL sel() const { return _sel.load(memory_order_relaxed); }
inline IMP imp(UNUSED_WITHOUT_PTRAUTH bucket_t *base, Class cls) const {
uintptr_t imp = _imp.load(memory_order_relaxed);
if (!imp) return nil;
#if CACHE_IMP_ENCODING == CACHE_IMP_ENCODING_PTRAUTH
SEL sel = _sel.load(memory_order_relaxed);
return (IMP)
ptrauth_auth_and_resign((const void *)imp,
ptrauth_key_process_dependent_code,
modifierForSEL(base, sel, cls),
ptrauth_key_function_pointer, 0);
#elif CACHE_IMP_ENCODING == CACHE_IMP_ENCODING_ISA_XOR
return (IMP)(imp ^ (uintptr_t)cls);
#elif CACHE_IMP_ENCODING == CACHE_IMP_ENCODING_NONE
return (IMP)imp;
#else
#error Unknown method cache IMP encoding.
#endif
}
}
看到这里我们大概能知道其实cache这个字段其实就是对方法的缓存,因此我们就是来探究一下cache字段是如何对方法进行缓存的。
2. cache字段探究
2.1 使用LLDB探究
首先在可运行的源码工程中编写如下代码:
@interface Person : NSObject
- (void)sayHi;
@end
@implementation Person
- (void)sayHi {
NSLog(@"hi!");
}
@end
int main(int argc, const char * argv[]) {
@autoreleasepool {
// class_data_bits_t
Person *p = [Person alloc];
NSLog(@"%@",p);
}
return 0;
}
然后在NSLog函数调用那一行打上断点,运行程序,在LLDB中进行如下调试输出:
//1. 打印p对象在内存中堆空间分配的地址
(lldb) p/x p
(Person *) $0 = 0x0000000101204ab0
//2. 打印输出p对象堆空间的数据
(lldb) x/4gx $0
0x101204ab0: 0x011d800100008491 0x0000000000000000
0x101204ac0: 0x0000000000000000 0xf33a90460c6508ff
//3.通过isa获取Person类的地址(也就是objc_class结构体变量在堆中分配的内存地址,注意:ISA_MASK必须根据你程序运行的平台进行选择)
(lldb) p/x 0x011d800100008491 & 0x00007ffffffffff8ULL
(unsigned long long) $1 = 0x0000000100008490
//4. 通过地址平移(16字节)打印objc_class结构体变量的成员变量cache_t的堆内存地址
(lldb) p/x (cache_t *)0x00000001000084a0
(cache_t *) $2 = 0x00000001000084a0
//5.输出cache_t这个结构体变量的数据
(lldb) p/x *$2
(cache_t) $3 = {
_bucketsAndMaybeMask = {
std::__1::atomic<unsigned long> = {
Value = 0x00000001003643a0
}
}
= {
= {
_maybeMask = {
std::__1::atomic<unsigned int> = {
Value = 0x00000000
}
}
_flags = 0x8010
_occupied = 0x0000
}
_originalPreoptCache = {
std::__1::atomic<preopt_cache_t *> = {
Value = 0x0000801000000000
}
}
}
}
\\6.接着尝试输出这些字段的Value值,结果都无法打印出信息
(lldb) p/x $3._bucketsAndMaybeMask
(explicit_atomic<unsigned long>) $4 = {
std::__1::atomic<unsigned long> = {
Value = 0x00000001003643a0
}
}
(lldb) p/x $4.Value
error: <user expression 5>:1:4: no member named 'Value' in 'explicit_atomic<unsigned long>'
$4.Value
~~ ^
(lldb) p/x $3._originalPreoptCache
(explicit_atomic<preopt_cache_t *>) $5 = {
std::__1::atomic<preopt_cache_t *> = {
Value = 0x0000801000000000
}
}
(lldb) p/x $5.Value
error: <user expression 7>:1:4: no member named 'Value' in 'explicit_atomic<preopt_cache_t *>'
$5.Value
~~ ^
(lldb) p/x $3._maybeMask
(explicit_atomic<unsigned int>) $6 = {
std::__1::atomic<unsigned int> = {
Value = 0x00000000
}
}
(lldb) p/x $6.Value
error: <user expression 9>:1:4: no member named 'Value' in 'explicit_atomic<unsigned int>'
$6.Value
~~ ^
//7.既然输出不了我们想要的信息,我们就猜测可能需要调用cache_t结构体中某些函数来获取buckets,在之前的源码探究中,我们发现了buckets方法:
struct bucket_t *buckets() const;
//8.查看这个方法的返回值,我们猜测大概可以通过这个方法可以获取到缓存的方法的sel以及imp,在lldb中执行这个方法,拿到这个方法的返回值
(lldb) p/x $3.buckets()
(bucket_t *) $8 = 0x00000001003643a0
//9.$8是一个存储了许多bucket_t变量的存储空间的首地址,因此我们通过地址偏移输出第一个bucket_t变量的值,发现第一个bucket_t变量的sel与imp都为nil
(lldb) p/x $8[0]
(bucket_t) $9 = {
_sel = {
std::__1::atomic<objc_selector *> = (null) {
Value = nil
}
}
_imp = {
std::__1::atomic<unsigned long> = {
Value = 0x0000000000000000
}
}
}
//10.这是因为我们还未调用这个Person类中的任何实例方法,因此方法还未缓存到成员变量cache中,所以我们调用一下Person中的实例方法,再来重新查看一下cache中的数据
(lldb) po [p sayHi]
2021-06-23 18:54:17.981387+0800 KCObjcBuild[27259:2783273] hi!
(lldb) p/x $3
(lldb) p/x [Person class]
(Class) $10 = 0x0000000100008490 Person
(lldb) p/x (cache_t *)0x00000001000084a0
(cache_t *) $11 = 0x00000001000084a0
(lldb) p/x *$11
(cache_t) $12 = {
_bucketsAndMaybeMask = {
std::__1::atomic<unsigned long> = {
Value = 0x0000000101205b40
}
}
= {
= {
_maybeMask = {
std::__1::atomic<unsigned int> = {
Value = 0x00000007
}
}
_flags = 0x8010
_occupied = 0x0001
}
_originalPreoptCache = {
std::__1::atomic<preopt_cache_t *> = {
Value = 0x0001801000000007
}
}
}
}
//11.可以发现cache中的成员变量数据发生了变化(_occupied由0变成了1,_maybeMask字段Value由0x00000000变成了0x00000007,_originalPreoptCache与_bucketsAndMaybeMask的value也发生了相应的变化),我们再来打印一下buckets中的值。
(lldb) p/x $12.buckets()
(bucket_t *) $13 = 0x0000000101205b40
(lldb) p/x $13[0]
(bucket_t) $14 = {
_sel = {
std::__1::atomic<objc_selector *> = "" {
Value = 0x0000000100003e08 ""
}
}
_imp = {
std::__1::atomic<unsigned long> = {
Value = 0x000000000000bc20
}
}
}
//12.打印buckets中首个bucket_t变量的sel与imp的值,sel()与imp(nil, [Person class])都是结构体bucket_t中定义的函数。
(lldb) p/x $14.sel()
(SEL) $15 = 0x0000000100003e08 "sayHi"
(lldb) p/x $14.imp(nil, [Person class])
(IMP) $16 = 0x00000001000038b0 (KCObjcBuild`-[Person sayHi] at main.m:39)
2.2 使用自定义数据类型探究
在以上可运行的源码程序中,我们使用lldb调试对源码中objc_class结构体的各个字段有了深入的了解,但是如果我们下载的源码不能运行,使用不了lldb进行源码调试,我们该如何进行调试呢,其实除了使用lldb进行源码调试之外,我们还可以定义与源码程序中一样的数据结构来探究这些字段的作用。
首先我们创建一个工程,然后在main文件中仿照源码中的数据结构定义如下的结构体:
struct sgy_bucket_t {
SEL _sel;
IMP _imp;
};
struct sgy_cache_t {
struct sgy_bucket_t *buckets; // 8
uint32_t _maybeMask; // 4
uint16_t _flags; // 2
uint16_t _occupied; // 2
};
struct sgy_class_data_bits_t {
uintptr_t bits;
};
struct sgy_objc_class {
Class ISA;
Class superclass;
struct sgy_cache_t cache; // formerly cache pointer and vtable
struct sgy_class_data_bits_t bits; // class_rw_t * plus custom rr/alloc flags
};
在main函数中定义Person类,并在main函数中编写如下代码,运行程序。
struct sgy_bucket_t {
SEL _sel; //方法名,唯一
IMP _imp; //方法实现的函数地址
};
struct sgy_cache_t {
struct sgy_bucket_t *buckets; // 存储的所有的buckets的首地址,通过地址偏移获取到相应的bucket
uint32_t _maybeMask; // bucket最大容量
uint16_t _flags; // 标识
uint16_t _occupied; // bucket实际占用容量
};
struct sgy_class_data_bits_t {
uintptr_t bits;
};
struct sgy_objc_class {
Class ISA; //ISA
Class superclass; //超类
struct sgy_cache_t cache; //缓存
struct sgy_class_data_bits_t bits; //类数据(成员变量、方法、元类中有类方法)
};
@interface Person : NSObject
- (void)sayHi;
@end
@implementation Person
- (void)sayHi {
NSLog(@"hi!");
}
@end
int main(int argc, const char * argv[]) {
@autoreleasepool {
Person *p = [Person alloc];
struct sgy_objc_class *cls = (__bridge struct sgy_objc_class *)([Person class]);
//调用sayHi
[p sayHi];
NSLog(@"%hu ---- %u", cls->cache._occupied, cls->cache._maybeMask);
struct sgy_bucket_t *buckets = cls->cache.buckets;
//打印buckets中所有bucket的sel以及imp信息
for (int i = 0; i < (cls->cache._maybeMask); i++) {
NSLog(@"sel = %@, imp = %p", NSStringFromSelector(buckets[i]._sel), buckets[i]._imp);
}
}
return 0;
}
运行程序,查看程序运行打印信息,如下所示:
2021-06-25 10:05:29.981153+0800 OBJC源码调试[32446:3445346] hi!
2021-06-25 10:05:29.981525+0800 OBJC源码调试[32446:3445346] 1 ---- 3
2021-06-25 10:05:29.981572+0800 OBJC源码调试[32446:3445346] sel = (null), imp = 0x0
2021-06-25 10:05:29.981599+0800 OBJC源码调试[32446:3445346] sel = (null), imp = 0x0
2021-06-25 10:05:29.981662+0800 OBJC源码调试[32446:3445346] sel = sayHi, imp = 0xbcb8
分析程序运行结果,我们发现sayHi这个函数并不是以数组存储的方式,顺序存储在为buckets开辟的存储空间中,这是为什么呢?因为如果以数组的方式存储,bucket_t变量的存储(因为对于cache方法的缓存需求来说,每个bucket的缓存只能有一份,因此在存储的时候,需要遍历查找是否有存储过这个bucket)、插入、删除、查找就比较麻烦,因为需要进行遍历,时间复杂度为O(n),这对底层中类的操作来说,性能就比较低了,但是如果采用链表的方式存储,虽然插入、删除、存储操作的时间复杂度为O(1),但是也是需要进行遍历先查找位置的,这样的话时间复杂度都为O(n),因此最好的方式是采用hash表这种数据结构进行操作,这样查询、存储、删除等操作的时间复杂度都为O(n)了,性能就得到了相当大的提升,为了验证这一猜想,我们在Person类中多定义几个方法,调用这些方法之后,我们再来看看buckets中数据是怎样的,代码如下所示:
@interface Person : NSObject
- (void)sayHi;
- (void)sayHi2;
- (void)sayHi3;
- (void)sayHi4;
- (void)sayHi5;
@end
@implementation Person
- (void)sayHi {
NSLog(@"hi! %s", __FUNCTION__);
}
- (void)sayHi2 {
NSLog(@"hi2! %s", __FUNCTION__);
}
- (void)sayHi3 {
NSLog(@"hi3! %s", __FUNCTION__);
}
- (void)sayHi4 {
NSLog(@"hi4! %s", __FUNCTION__);
}
- (void)sayHi5 {
NSLog(@"hi5! %s", __FUNCTION__);
}
//main函数中方法调用
int main(int argc, const char * argv[]) {
@autoreleasepool {
Person *p = [Person alloc];
struct sgy_objc_class *cls = (__bridge struct sgy_objc_class *)([Person class]);
//调用sayHi
[p sayHi];
[p sayHi2];
[p sayHi3];
[p sayHi4];
[p sayHi5];
NSLog(@"%hu ---- %u", cls->cache._occupied, cls->cache._maybeMask);
struct sgy_bucket_t *buckets = cls->cache.buckets;
//打印buckets中所有bucket的sel以及imp信息
for (int i = 0; i < (cls->cache._maybeMask); i++) {
NSLog(@"sel = %@, imp = %p", NSStringFromSelector(buckets[i]._sel), buckets[i]._imp);
}
}
return 0;
}
运行程序,查看执行结果,如下所示:
2021-06-25 11:52:16.453344+0800 OBJC源码调试[33139:3508642] hi! -[Person sayHi]
2021-06-25 11:52:16.453811+0800 OBJC源码调试[33139:3508642] hi2! -[Person sayHi2]
2021-06-25 11:52:16.453859+0800 OBJC源码调试[33139:3508642] hi3! -[Person sayHi3]
2021-06-25 11:52:16.453889+0800 OBJC源码调试[33139:3508642] hi4! -[Person sayHi4]
2021-06-25 11:52:16.453918+0800 OBJC源码调试[33139:3508642] hi5! -[Person sayHi5]
2021-06-25 11:52:16.453964+0800 OBJC源码调试[33139:3508642] 3 ---- 7
2021-06-25 11:52:16.454121+0800 OBJC源码调试[33139:3508642] sel = sayHi5, imp = 0xbd18
2021-06-25 11:52:16.454181+0800 OBJC源码调试[33139:3508642] sel = sayHi4, imp = 0xbd28
2021-06-25 11:52:16.454213+0800 OBJC源码调试[33139:3508642] sel = sayHi3, imp = 0xbdf8
2021-06-25 11:52:16.454240+0800 OBJC源码调试[33139:3508642] sel = (null), imp = 0x0
2021-06-25 11:52:16.454266+0800 OBJC源码调试[33139:3508642] sel = (null), imp = 0x0
2021-06-25 11:52:16.454318+0800 OBJC源码调试[33139:3508642] sel = (null), imp = 0x0
2021-06-25 11:52:16.454373+0800 OBJC源码调试[33139:3508642] sel = (null), imp = 0x0
运行结果分析:我们发现,_maybeMask这个成员变量由原来的3变成了7,_occupied由1变成了3,遍历buckets中的bucket_t变量的sel以及imp,看起来像是以hash表结构存储数据的。
2.3 使用LLDB时调用实例方法之后_maybeMask为何变成了7
经过这两种方式的探究,我们发现这两种方式同样的只是调用了一个实例方法,在源码中调用实例方法后_maybeMask变成了3,而使用LLDB时调用实例方法之后_maybeMask为何变成了7呢?我们猜测LLDB在调用实例方法sayHi之前可能调用了多个实例方法,导致其扩容了,接下来我们就来进行验证。
首先,我们猜测LLDB在调用实例方法sayHi之前可能调用了多个实例方法,那么就可能会调用insert函数,插入新的bucket_t类型变量到buckets,所以我们首先在insert方法中打印输出一些方法调用信息,如下图所示:
在main函数如下位置打上断点
编译运行程序,清空控制台输出信息,使用LLDB命令调用p的sayHi方法,如下所示:
我们首先看到第三行输出信息是关于sayHi方法调用的,receiver指针地址为0x10060e0b0,也就是p这个对象的地址,但是我们也发现第一行第二行输出信息中recevier也是0x10060e0b0,那么说明respondsToSelector与class方法也是p对象调用的,那么这两个方法的sel与imp应该也是在buckets中做了缓存处理,但是即便如此,这也才3个方法,并没有超过容量的3/4,但是,在调用了sayHi之后,明显扩容了,这又是为什么呢?那么我们再在insert函数中编写如下代码:
编译运行程序,执行到断点时,输入以下命令,查看打印信息:
这就是在插入sayHi方法之前buckets中的所有bucket信息,注意第四行输出信息,第四行存储的bucket信息中sel实际上为空字符串,而imp则存储的是buckets的起始地址,所有在buckets中插入sayHi方法的sel与imp时,就会判断当前的容量是否大于等于总容量的3/4,当大于等于3/4时,就会执行下面红框中的代码,如下图所示:
而在reallocate函数中,有调用了如下的函数:
我们再来查看allocateBuckets函数
这个函数中调用了endMarker函数来获取buckets最后一个bucket的起始地址,如下图所示:
然后在allocateBuckets这个函数中将最后一个bucket的值根据架构的不同进行了赋值,在arm架构中sel设置为1,imp设置为newBuckets起始地址-1,而在其他架构中sel设置为1,imp设置为了newBuckets的起始地址。
那么苹果开发者为什么如此设计呢?为什么要在每次开辟或重新开辟buckets的时候都要如此设置最后一个bucket的sel与imp的值呢?根据注释我们可以知道原来是为了在objc_msgSend中保存一条指令,以便在objc_msgSend中使用,这个我们之后在探讨objc_msgSend的时候再详细说明。