「这是我参与2022首次更文挑战的第1天,活动详情查看:2022首次更文挑战」。
通过之前对于Alloc底层的探索总结,过程中也对源码进行了一些简单的试探,本篇将对OC中的
类
作一些研究、记录。(全篇较长,总约6728字)
本文重点:
- NSObject和Class在底层的具体类型、及继承关系
- objc_class数据结构分析
- isa数据结构及作用分析
- superclassAPI分析
- cache_t数据结构分析
- bits数据结构分析
资源准备:
-
全篇动态分析使用的代码:
//Person @interface UIPerson : NSObject - (void)say3; @property(atomic, copy)NSString* name; @property(nonatomic, copy)NSString* age; @end @interface UIPerson () - (void)say2; @end @implementation UIPerson - (void)say1{NSLog(@"say1");} // 未声明 - (void)say2{NSLog(@"say2");} // 内部声明 - (void)say3{NSLog(@"say3");} // 外部声明 @end //main //main.m int main(int argc, const char * argv[]) { @autoreleasepool { // insert code here... NSLog(@"I'm coming!"); Class cls = [UIPerson class]; }return 0; } 复制代码
一、Class的数据类型
OC中最常见的就是类,而且所有类的基类都是NSObject。那么NSObject和Class在底层具体是什么类型呢? 先利用Clang命令将准备好的代码转换一下。
clang -rewrite-objc main.m -o outMain.cpp
执行完成后,在新生成的outMain.cpp文件中搜索分析可以得知:NSObject是objc_object结构体
的别名,Class是objc_class
的结构指针。 当然还发现了其他的类型,比如id类型。
通过转换后的代码可以初步理解 为什么id类型万能接收,原来是因为id是objc_object结构体的指针。
现在已知类的底层是结构体objc_class
,那就可以到源码中去搜索objc_class
,去探究其内部究竟是什么样子的数据结构。通过对源码搜索的结果分析,最终终在objc-runtime-new.h
找到了objc_class
结构体的实现,且可以看到这个结构体的继承关系:继承自objc_object。
那这样一来,就弄清楚了NSObject和Class在底层的数据类型,而且通过底层结构体的继承关系,以及其内部成员与其父类中的isa,不禁能想到官方文档中那张经典的isa关系图
,且与OC层面类的继承关系有个关联。
二、isa 指针
1)isa 关系图分析
接着从类的isa开始分析。开篇探索类的底层类型时,已经分析出了objc_class
中的isa来自其父类 (也就是根类)objc_object
,那么要分析isa指针和其作用还得先从这个官方文档中isa关系图开始。
通过这张图是可以初步分析出:在底层是不存在常说的对象方法、类方法的区别的,统统都是对象方法,只不过是通过
isa
对类对象、元类对象查找的层级不同而已。
2)通过API理解isa
想要很好的认识isa、理解isa的作用,可以从OC层面中的API入手,其中对isa
最直接的使用就是[xxx class]
方法,其实现就是通过实例的isa查找到类对象,然后类找到元类、根元类以此类推,那么具体如何通过isa从实例对象找到类对象的?可以通过源码一探究竟。
通过点击可以在NSObject.m
中 找到了[xx class]
的实现:类返回自己,实例通过isa去找到对应的类。静态分析看似好像很容易就找打了答案其实不然,使用动态调试将资源准备中的代码运行起来查看汇编可以看到[xx class]
实际调用已经被LLVM修改成了objc_opt_class
而在objc_opt_class
中已经不区分实例调用还是类调用了,统一是obj -> objc_object::getIsa()
,在getIsa()
中判断了当前类的isa是否是优化指针,不是则使用bits&ISA_MSK,是则通过偏移和&操作来还原。
其中bits&ISA_MSK这点,可以通过p、x/4gx命令来打印验证。按照图片顺序来说,从代码 Class cls = [UIPerson class];
调用开始,到3计算后得到其元类的uintptr_t数据,然后到4中验证。首先po一下,证明确实是其确实是UIPerson
,然后将uintptr_t转换成内存地址,在将UIPerson的class的首地址输出,比较其的isa指针就是图3计算后的结果。
注意一点:
objc_opt_class
中通过getisa
得到cls后做的判断,如果结果是MetaClass,返回的还是当前的调用者obj。
至此,完成了从API入手,分析其底层逻辑以达到对于isa关系图的实践:isaInstance的首地址isa&掩码可以找到类对象首地址,类对象的首地址isa&掩码后可以找到元类,元类可以找到根元类。
注意一点:
在分析验证过程中,不断的进行了控制台输出打印,发现isa到达类时,其isa地址直接就是其元类的地址,元类到根元类亦如此。 出现这种情况的原因,可能是isa不需要优化,不需要存储更多的信息了
3)通过API理解superclass
探究完isa的流程[xxx superclass]
就简单的多了,上手直接使用动态调试来看底层调用的方法。
superclass
是通过objc_msgSend
进行方法调用,继续跟断点会直接定位到了NSObject.m
中,这次竟然一步步都按常理调用了。
所以superclass的方法是去分+-的。如果是instance对象调用则先通过object_getClass->getIsa()
获取到类对象,然后获取类对象objc_class结构体中的superclass即可,类则直接从其结构体中获取即可。
4)isa_t 数据结构
前面已知类的isa来自其父类objc_object
,在定义的地方可以其数据类型是isa_t
,是一个union。这就意味着其成员共享内存空间,这点也能与常说的TaggedPointer从高到低的位标识相对应,这部分位标识成员以宏定义的方式定义在isa.h
中的,并根据架构不同,各个标识位的长短及意义不同。
接着进入isa.h
看看ISA_BITFIELD
的宏定义,其中:
nonpointer
:表示是否是优化指针。0
表示纯isa指针,1
表示优化指针,其中包括对应平台定义的ISA_BITFILED内容,如对象的地址,引用计数、C++标识等并以位域方式存储。has_assoc:
关联对象标志位。0
表示未关联,1
表示关联。has_cxx_dtor
:该对象是否有C ++
或者Objc
的析构函数。如果有,则执行析构函数,没有跳过。shiftcls
:储存类指针的值,开启指针优化的情况下。arm64架构
中33
位用来存储类指针,x86_64
中44
位。magic
:调试器判断当前对象是真的对象还是没有初始化的空间
。weakly_referenced
:是否指向一个ARC
的弱变量。有弱引用需要先释放弱对象,没有则跳过。has_sidetable_rc
:当对象引用计数大于10
时,则需要该变量存储。extra_rc
:该对象的引用计数值。值等于realcount-1
如对象引用计数为10
,此时extra_rc
为9
,如果大于10
,则用到上面的has_sidetable_rc
。isDeallocating
:标志对象是否正在释放。(结构体中的方法)
对于一个类的isa构建、成员初始化赋值是在objc_object::initIsa()
中进行的,在之前探索Alloc底层实现的三步中:
- 计算对象大小
- 开辟对应空间
- 绑定isa
就曾提到过类与isa的绑定。
5)类对象比较的API分析
isa的数据结构、关系图、继承链都已经分析完了,可以开始分析两个比较容易混淆的比较类的API:isKindOfClass
、isMemberOfClass
,先来段代码看看输出。
BOOL re1 = [(id)[NSObject class] isKindOfClass:[NSObject class]]; //y
BOOL re2 = [(id)[NSObject class] isMemberOfClass:[NSObject class]]; //f
BOOL re3 = [(id)[UIPerson class] isKindOfClass:[UIPerson class]]; //f
BOOL re4 = [(id)[UIPerson class] isMemberOfClass:[UIPerson class]]; //f
NSLog(@" re1 :%hhd\n re2 :%hhd\n re3 :%hhd\n re4 :%hhd\n",re1,re2,re3,re4);
BOOL re5 = [(id)[NSObject alloc] isKindOfClass:[NSObject class]]; //y
BOOL re6 = [(id)[NSObject alloc] isMemberOfClass:[NSObject class]]; //y
BOOL re7 = [(id)[UIPerson alloc] isKindOfClass:[UIPerson class]]; //y
BOOL re8 = [(id)[UIPerson alloc] isMemberOfClass:[UIPerson class]]; //y
NSLog(@" re5 :%hhd\n re6 :%hhd\n re7 :%hhd\n re8 :%hhd\n",re5,re6,re7,re8);
复制代码
1. Kind
kind的实现在NSObject.m
中有两处:objc_opt_isKindOfClass
和isKindOfClass
,但是逻辑判断的核心逻辑一样,就是这就这个for循环。
for (Class tcls = cls; tcls; tcls = tcls->getSuperclass()) {
if (tcls == otherClass) return YES;
}
复制代码
通过代码逻辑可以看出,isKindOfClass
是类的superclass继承链这一条的循环比较,归结来说 A isK B ,就是 for(A.isa.superclass) 中有没有B。 对于isK的实现逻辑总结就是:
obj -> obj->getIsa()->getSuperclass() == otherClass
再来分析一下打印结果的re1和re 3:都是cls的比较却只有NSObjcet输出1,因为NSObjcet的元类的superclass正好就是NSObjcet自己,恰好相同。而UIPerson已经是元类继承链在和类UIPerson比较了,永远都不可能相等。re5、re7同理
注意一点:
还有一点没有研究明白:使用动态调试断到isK调用时查看汇编,有时候执行的是NSObject中的+-方法,有时候会> 重定向objc_opt_isKindOfClass()。 这点同样适用于前面探索[xxx class]的API过程。
2. Member
Member的实现相对就简单点了,只比较1次。点击即跳转NSObject.m中的实现,有+-两种方法但是核心逻辑一样:
self->ISA() ==? cls;
归结来说A isM B,就是A.isa() =? B,即A的类或元类是否是B。
再来分析一下打印结果中的re2和re4,它两个实际的比较都是 self与self.isa比较,怎样都不会xiang等的。
三、cache_t cache
1)cache_t 数据结构
cache顾名思义缓存,类中有缓存应该缓存什么?方法?属性? 带疑问先来试着分析一下cache_t
的数据结构,看看是否能找到什么思路,cache\_t
中:一个指针、一个union、一部分private方法、一部分public方法。
从成员来看是分析不出什么思路,那就继续向更深的层次分析。首先搜索_bucketsAndMaybeMask
,通过下面方法的注释可知它是bucket_t
指针。那bucket_t
又是什么结构呢? 点击进入其结构体,一目了然的是按框架区分的SEL和IMP。
分析了一下这个结构体、属性的本质,看来类中的cache存的就是方法,而bucke_t
就是其存储的容器。既然bucket_t
作为容器,必然该有对应的存取方法存在于cache_t
中用于操作,果如其然在结构体中找到了 insert()、buckets()、alloc()、empty() 等几个主要方法。
2)buckets的get()
先从 bucket_t *buckets()
这个get方法开始分析,点进去可以看到其中addr是通过 _bucketsAndMaybeMask.load(memory_order_relaxed) 读出来的,而且在cache_t
的结构体中_bucketsAndMaybeMask
是原子类型,那么读取有load
方法,存储必然也会有store
方法,八成store
是在insert()
中完成的。
知识点补充:
mory_order_relaxed:多线程内存同步中的宽松模式,即只保证当前操作的原子性,不考虑线程同步,其他线程可能读到新值,也可能读到旧值。
其中bucketsMask = ~0ul (0UL:就是无符号长整型的0,~:表示按位取反,即:0xffff。。。)
3)buckets的Insert()
接着分析insert()
,一来是探究其内部结构与逻辑,二来确认一下刚才分析的store()
是否真在其中。点击进入其结构体:
知识点补充:
在while判断条件时使用到了fastpath()
,它是一个宏定义,其意思是告诉编译器当前判断的条件的成功概率很高。
可点击查看宏定义的具体内容:(__builtin_expect(bool(x), 1))
这个指令是gcc引入的,作用是允许程序员将最有可能执行的分支告诉编译器。这个指令的写法为:__builtin_expect(EXP, N)
意思:EXP==N的概率很大。
如图分析,insert()
中主要就是2部分:空间管理、插入缓存,分别来总结这两部分的主要逻辑。
1. 空间管理
- 初始占用:newOcupied = 0+1; 初始空间:capacity = oldCapacity = capacity(0) = 0;
- 75%扩容边界: newOccupied + CACHE_END_MARKER(1) > cache_fill_ratio(capacity) 等价于 _occupied +2 >= (_mybeMaks+1)*3/4
- 除了判断条件外,最重要的方法就是reallocate()
继续深入cache_t::reallocate()
,其内部逻辑主要也是2件事:
- 按newCapacity创建newBuckets,然后在
setBucketsAndMask
中对 _bucketsAndMaybeMask,_maybeMask,_occupied进行赋值。 - 如果是扩容调用则 -> free(oldBuckets)
2. 插入缓存
- 准备插入:取bucket首地址b,最大操作区间m,哈希开始插入位置begin,变动参数i,然后开始do...while循环。
- 循环中也就2件事:插入、已插入。循环的while条件
cache_next
与cache_hash
的哈希算法一致,对变动参数i进行 +- 以进行b[i]的偏移查找。 - 由
cache_hash
的算法可以看出,它的插入是并不是不是0...1..2..顺序插入的。
知识点补充:
cache_hash
中将sel进行了uintptr_t强转,那uintptr_t是个什么数据类型?用来干嘛的呢?首先,
uintptr_t
不在C++中,而是位于C99<stdint.h>
中,作为可选类型,它被定义为“无符号整数类型“。
主要作用:指针的按位操作,在C++中,无法对指针执行按位操作,原因见Why can't you do bitwise operations on pointer in C, and is there a way around this? 因此,为了对指针进行按位操作,需要将指针转换为类型unitpr_t,然后执行按位运算。同时其属性是任何有效的指向void的指针都可以转换为此类型,然后转换回指向void的指针,结果将等于原始指针。
继续来分析循环中的buckett::set()
的逻辑:
首先在参数上传入了bucket*b、SEl、IMP、Cls,这也是缓存中存储所需要的全部信息。其次,尽管最终的存储方式在架构上有所区别,但是2部分中的IMP-Encode是相同的,位于struct bucket_t
中的encodeImp(base, newImp, newSel, cls)
。最终对于 SEL、IMP 的存储在 __arm64__ 上使用了 LDP/STP (架构指令太底了,真不熟),其他情况则使用memory_order_relaxed
(多线程内存同步) 进行存储,存储顺序与前面探索到的struct bucket_t
的sel、imp顺序一致。
4)_maybeMask的变化
值得注意:其中对于_maybeMask的操作,在setBucketsAndMask()时进行了newCapacity -1,在capacity()中 +1 还原了占用。
那为什么_maybeMask = capcity - 1
,或者 为什么这么操作?
在reallocate
的allocateBuckets()
方法中可以找到其中的答案,要在bucket_t末尾插入标记,根据框架不同有所区别。
- arm: newbBucket - 1
- other: newbBucket 首地址
四、calss_data_bits_t bits
在objc_class
中,isa记录元类链
、superclass记录继承链
、cache 顾名思义缓存
、bits存储类的所有数据
。前面的作用及其数据结构都已经分析完了,接着就开始从bits
入手对类的数据结构进行更加深入的探究。
1)Method数据结构
- 在
class_data_bits_t
结构体的方法中,与bits
相关的占多数,多是对bits
的存取计算。 - 结合
objc_class
中的方法,可以分析出class_data_bits_t
中的*data()
及方法是比较关键的,根据其返回的数据类型最终可以定位到class_rw_t
这个数据结构。 - 进入
class_rw_t
后可以观察到其中包含了一个类本身的所有信息,比较好辨识的就是methods()
、properties()
、protocols()
这3个方法,其他的一些较底层如ro_or_rw_ext_t()
、class_ro_t*
、ro
等,稍后再做分析。
- 先从
methods()
开始探究,按照方法的调用及其数据类型一层层深入,可以观察到类存储方法的列表数据类型:method_array_t:list_array_tt <method_t, method_list_t. method_list_t_authed_ptr> {...}
,其包含3个范型且list_array_tt
已经是根类,而且进入list_array_tt
后分析源码也没有梳理出其中的数据结构的存储关系。
- 看来静态分析只能止步于此,那就试试结合动态分析,通过
llvm
将上述分析得出来的bits.data
一层层打印出来,直观的观察其结构。 - 开始打印前,先分析一下
objc_class
中成员大小,然后才能计算出正确的偏移量:其中isa、superclass都是指针8字节,cache_t cache
中包含一个指针一个union总体算下来是16字节,那么要想打印出一个类的bits
,从Class首地址向后偏移32个字节即可。
尽管静态分析时偏移算的正确了,但是其中数据结构分析不那么透测,打印过程还是进行了不少尝试才将最总想要的结果打出来。也理出了其中数据结构的关系:method_array_t
中包含 method_list_t
,然后再下层是 method_t
。
分步测试的命令和过程已经合并省略,如查看过程分步打印只需要将命令拆分即可:
p ((class_rw_t *)((class_data_bits_t *)Class_Index+32)->data())->methods().list.ptr->get(0).big()
2)Propert 和 Protocol数据结构
类的属性、协议与methods()
相同,都是从class_rw_t
中的方法取出来的,储存类型也都是list_array_tt
的对应的子类propert_array_t
和protocol_array_t
。所以以对于它们两的前几步都是相同,差别就集中在了各自的存储的基本数据结构(如method_t
)上,所以根据本身的基本数据结构,去获取属性的内容即可,properties
去掉big()
即可。
p ((class_rw_t *)((class_data_bits_t *)Class-Index+32)->data())->properties().list.ptr->get(0)
而协议的读取过程与方法、属性有一定的区别。读取到ptr
时的数据结类型是protocol_list_t*
, 但是按上面的方式打印会报错没有get()
方法:
no member named 'get' in 'protocol_list_t'
,都是同一个父类List,却没有get()
那只能是去分析看看protocol_list_t
结构体中它本身是怎么读取的,具体用了什么方法。
经过反复分析源码,注意到其中的的iterator xx
方法和开始的list[0]
并不断的尝试打印这些部分,终于获得到了protocol_list_t
中的内容:
最终是成功调用到protocol_list_t
中的list[0]
才将内容输出,但是源码中xx的数据类型却是protocol_ref_t
,而protocol_ref_t
本质就是指针,只需要强转到protocol_t*
就能拿到想要的内容了。最终打印出来后的内容与结构体protocol_list_t
的内容一致。
结构体protocol_t
继承自objc_object
,其中包含了method_list_t
、property_list_t
等其他的数据结构,这些数据结构只需要按照之前分析方法、属性的方式展开即可获得其中内容。
至此,将探索过程中的分步获取协议protocol_t
的命令合成一下:
// protocol_t内容
p *(protocol_t*)((class_rw_t *)((class_data_bits_t *)Class_Index+32)->data())->protocols().list.ptr.list[0]
// @required 方法
p (*(protocol_t*)((class_rw_t *)((class_data_bits_t *)Class_Index+32)->data())->protocols().list.ptr.list[0]).instanceMethods->get(0).big()
// @optional 方法
p (*(protocol_t*)((class_rw_t *)((class_data_bits_t *)Class_Index+32))->data())->protocols().list.ptr.list[0]).optionalInstanceMethods->get(0).big()
3)bits中的class_rw_t
开始分析
class_rw_t
之前,首先需要声明两个概念dirty-memory(脏内存)
和clean-memory(干净内存
:
脏内存是在进程运行时更改的内存,干净内存是加载后不会更改的内存。脏内存比干净内存昂贵的多,尤其是在iOS中,因为只要进程正在运行,它就必须得保留,而干净内存可以被释放,在需要时总是可以去磁盘中重新加载。而且类只要被使用,类的数据结构就会变“脏”,如创建新的缓存、写入数据的存储等,所以保持干净的数据越多越好,这样可以节省出更多的空间用来存储App中的数据。
接着分析在方法的数据结构
中提到的class_rw_t
及其结构体中那些较底层的字段如:ro_or_rw_ext_t()
、class_ro_t*
、ro
。这些内容归总来说也就是3部分内容class_ro_t
、class_rw_ext
和class_rw_t
。
class_ro_t
是一个指向存储类更多信息的指针,名称中的“ro”是read-only(“rw”同理)的意思,所以当它被加载的时候是clean-memory
。class_rw_t
是进程运行时分配的存储用于读/写类数据的内存,也就是dirty-memory。
class_rw_ext
是将class_rw_t
中的方法、属性、协议等大约 90% 的类从不会在运行时动态修改的数据分离出来,从而让更多的数保持干净以节省空间,所有将class_rw_t
拆分成了2个部分。
class_rw_t
的结构体方法中通过get_ro_or_rwe()
赋值了原子数据类型指针ro_or_rw_ext_t
,并且在获取ro
的时候对类型进行了判断:是class_rw_ext_t
时要多去取一层 class_rw_ext_t->ro()
,而class_ro_t
则直接返回ro
。
上述记录的数据结构,可以在动态调试过程中进行验证:将ro与rw两部分的properties()
数据都打印出来进行比较,测试命令已整合:
p (class_ro_t *)((class_rw_t *)((class_data_bits_t *)Class_Index+32)->data())->ro())->baseProperties->get(0)
p (class_rw_t *)((class_data_bits_t *)Class_Index+32)->data())->properties().list.ptr->get(0)
以上关于class_rw_t
的这部分内容,对于类的数据结构变化来自WWDC2020关于runtime的发展介绍-Advancements in the Objective-C runtime,除了上述变化外,还有 Objective-C 方法列表的更改和tagged pointers的改动。
五、类的属性
1)属性/成员变量
类的成员变量(ivars)是存储在class_ro_t
中,在上小结class_rw_t
拆分优化的图片中也可以看到。 类的属性 = ivar + set() + get()。
2)属性修饰
那么,
get()
、set()
方法是如何找到ivar的?
而且经常还在使用时加上修饰关键字,即使什么都不加也会有默认的strong、atomic
修饰,那么前面的关键字不同会对查找ivar方式有所影响?
对于这2个问题,准备了一小段代码,将代码通过clang转换一下来进行观察比较。
@interface UIPerson : NSObject
@property(nonatomic, copy) NSString* judy_NC;
@property(atomic, copy) NSString* judy_AC;
@property(copy) NSString* judy_3_C;
@property(nonatomic) NSString* judy_N;
@property(atomic) NSString* judy_A;
@end
@implementation UIPerson
@end
复制代码
clang -rewrite-objc main.m -o outMain.cpp
经过比较转换后的cpp代码可以发现:
- 当只有copy修饰属性修饰时set方法会用
objc_setProperty()
方法进行赋值。 - 在copy基础上,如果方法且前面加上
atomic
时get方法会使用objc_getProperty()
方法来取值。 - 而其他关键字修饰时
get、set
均使用self+内存平移直接赋值、取值。
get() | set() | |
---|---|---|
n, c | offset | objc_setProperty() |
a, c | objc_getProperty() | objc_setProperty() |
n(s) | offset | offset |
a(s) | offset | offset |
至此,表格中的总结就是前面2个问题的答案了,但是又一个新的疑问出现了:为什么copy
修饰时要使用objc_setProperty
和objc_getProperty
的方法来进行值的存取呢?
去源码中查找一下这两个方法的实现,通过分析这2个方法的源码,得到的结果是:
get过程中,要对取值的objc\_retain
过程加锁 (spinlock_t)。
set过程中,对赋值的过程同样也要加锁,不仅如此,还要判断copy或mutablecopy从而调用不同的copy方法。
六、总结
至此,基本完成了对类相关的底层探究、分析的记录,从OC层面的类入手,探究完成了底层Class的数据类型、各个数据结构、及类信息的存储等,现在对这些进行一下总结:
- NSObject和Class在底层的数据类型、及继承关系。
- NSObject是objc_object的别名
- Class是objc_class的结构体指针
- objc_class:objc_object{isa_t isa}
- objc_class数据结构分析
isa
、superclass
、cache
、bits
、public方法、private方法
- isa数据结构及作用分析
- 通过
isa
对类对象、元类对象查找。
- 通过
- superclassAPI分析 2. 通过
superclass
对类对象的父类逐级查找。 - cache_t数据结构分析
_bucketsAndMaybeMask
指针、共用体(_maybeMask、_flags、_occupied)、private方法、public方法。- 存储容器是bucket_t
- bits数据结构分析
class_ro_t
、class_rw_ext
和class_rw_t
和属性、协议、方法的获取方法。