在上个章节,我们学习了对象的本质,对isa有了一个笼统的概念,了解到对象的本质其实就是一个包含了变量和isa指针的结构体。并且可以通过实例对象的isa获取到类对象,然后通过类对象的isa获取到元类的对象。但是我们并不清楚类对象中的具体结构,我们定义的成员变量,属性,协议,实例方法、类方法都保存在哪?在这个章节中,我们会对这些进行详细的分析。
在分析对象本质的时候,我们知道OC底层其实是用C++编写的,所以用下面命令将main.m转换成了c++代码:
xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o main__.cpp
我们打开这个文件看看能不能找到一些有用的信息。
查找信息肯定要有针对性才有结果,我们要分析类的本质,应该从Class这个类型出发,我们全局搜索一下看看有没有针对Class类型的定义。
如果有读者在Xcode中通过runtime.h进入到头文件中应该也会发现下面的这个定义
那么这个objc_class结构体内部包含了哪些成员呢?全局搜索一下,没有发现定义,不过已经有眉目就好办了,打开objc源码,全局搜索objc_class。
不过找到的信息似乎已经过期了,OBJC2_UNAVAILABLE表示在OBJC2下已经失效了,并且最后一行的注释也说明这个结构体被Class替代了,但是Class不是本来就是struct objc_class的重定义吗?既然是过期,就应该会有新的定义,我们修改一下搜索关键词,将关键词objc_class换成struct objc_class,得到如下结果
可以看到 objc-runtime 分为 new 和 old 2个头文件,我们要找的肯定是最新的,所以在new 头文件中找到了上图的objc_class定义,objc_class继承自objc_object(这是C++的写法,C++中类和结构体都是可以继承的),那么objc_object又是个什么结构呢?
objc_object 内部其实只有一个成员,isa。剩余的都是结构体内部的函数。
从宏观来看,我们可以知道类的本质是objc_class结构体,里面包含了以下4个成员以及大量的函数:
1、isa_t isa;
2、Class superclass;
3、cache_t cache;
4、class_data_bits_t bits;
。。。。。。。。其它函数
isa在上一节已经分析过了,不再重复分析。
Class其实就是objc_class *,所以第二个成员又可以写成objc_class * superclass;
那么我们需要分析的就剩下2个了
cache_t cache
class_data_bits_t bits
cache_t cache结构体比较复杂,我们先挑简单的分析,先分析class_data_bits_t bits
class_data_bits_t
首先定义了一个friend objc_class,然后是一个bits 指针
friend代表什么意思呢?
friend关键字用于修饰C++中的有元类,被修饰的的类可以访问当前类的私有成员或者调用私有函数。
大家注意看上图红线的私有函数,getBits、setBits 等在objc_class中是有调用的
上图可以看到objc_calss直接用bits.getBit访问了私有函数,这就是friend修饰后的效果。
说白了,class_data_bits_t其实就是和isa一样的指针,通过bits将各种信息存储到8个字节64位中。可以看到上图 bits.getBit(XX)传入的是一个宏,该宏的实际值是:
#define FAST_HAS_DEFAULT_RR (1UL<<2) 1左移2位
也就是说hasCustomRR() 其实是判断bits 64位中第3个bit位中的值是0还是1。
和isa取值的原理是完全一样的,通过 & 上不同的宏,来获取不同bit位上的值,从而得到相关数据。
那么我们再往下翻,看看bits具体能获取到什么东西
class_rw_t
通过点语法调用了bits的data()函数,来获取一个类型为class_rw_t的结构体。其实这个结构体在之前的文章已经分析过了。
用一幅图来表示class_rw_t、ro_or_rw_ext_t、class_ro_t 这3个结构体的关系更好理解
重点解释下:ro_or_rw_ext_t = objc::PointerUnion<const class_ro_t, class_rw_ext_t, PTRAUTH_STR("class_ro_t"), PTRAUTH_STR("class_rw_ext_t")>;
这是个什么呢?这是一个Union联合体(不懂的读者自行查找资料)。联合的是传入的参数 class_ro_t 或 class_rw_ext_t ,也就是说 rw 结构体中有且仅有一个ro或者一个rwe。如果get_ro_or_rwe()返回的是rwe,则取ro是这样的 ro = rwe -> ro.
总结下:
ro 是体量最小的,而且不可变的只读的,里面存储着类的成员变量,基本的方法、属性、协议等。
rwe 是可变的,可读写的,其中包含了ro,而且还有3个数组用于存储方法、属性、协议。
rw 中包含了rwe或者ro,如果该类有category则rw中存储的则是rwe,因为存储category需要对内存进行写入,不管是取ro还是取rwe都是调用同一个函数get_ro_or_rwe()。如果是取ro,rw中还有一个单独的ro()函数,里面的实现也可以说明rw中存储的要么是rwe要么是ro。
那么什么情况会返回ro,什么情况下会返回rwe呢?
如果有认真阅读过重学iOS系列之APP启动(三)objc(runtime)肯定能回答。
如果类存在category,则需要将category中的所有信息附加到类中,需要对类动态的修改内存,所以这种情况下返回的是rwe;
如果只存在单独的类,则不需要对内存进行修改,则返回的是ro。
Talk is cheap, show me your code.
我们来写代码验证一下,class_rw_t 的结构是否真的如 图中一样。
先在objc源码的main.mm文件中添加一个Student类、category,然后打好断点运行起来。
然后通过lldb打印出数据如下
$0 是Student 类对象的地址,我们再看看类的结构体
想通过 $0->data() 拿到 class_rw_t 结构体,发现行不通。那么我们只能通过内存偏移来获取 bits 的地址,再通过 bits->data() 来拿到class_rw_t数据了。
大家都知道结构体成员在内存中都是紧挨着的,如上图所示:
Student 内存 前8个字节是 isa 指针,后8个字节是 superclass 指针, 然后是cache_t 结构体,只要算出cache_t占用多少字节就可以算出 bits 在内存中的偏移量了。先通过源码看看 cache_t 结构体包含什么成员。
cache_t结构体成员 类型 大小(字节)
_bucketsAndMaybeMask uintptr_t 8字节
_maybeMask mask_t 32bits(4字节)
_flags uint16_t 16bits(2字节)
_occupied uint16_t 16bits(2字节)
由上述计算可以得 cache_t 占用了 16个字节
那么 bits 的偏移量 = 8 + 8 + 16 = 32 字节 = 16进制 0x20
那么我们就可以这样来获取 bits
0x00000001000086d0 是Person class的地址 + 0x20 就是偏移32个字节,这样就拿到了 bits
然后通过 ->data() 拿到class_rw_t
在通过 p *$2 打印出class_rw_t 的内容
再看看class_rw_t 中有什么函数可以获取到类的相关信息
上图可以知道class_rw_t结构体中有直接获取 方法、属性、协议的成员函数。
3个函数的内部实现非常相似都是通过get_ro_or_rwe() 拿到 ro_or_rw_ext_t 类型的一个结构,ro_or_rw_ext_t 前面分析过是一个联合体,内部存储的是ro 或者 rw,我们也可以通过下面的命令来获取
但是在我们尝试获取他的具体类型class_rw_ext_t 时失败了,目前笔者还未找到方法来打印该联合体的具体类型,有了解的读者可以私信,不胜感激!
既然我们拿不到具体类型,就无法确定 是 ro 还是rwe ,我们再从源码找找是否有其他的函数可以调用
找到可以直接获取class_ro_t 的 ro() 函数,尝试调用一下
打印数据非常完整,我们拿到了 ro
ro 里是否真的保存了之前列举的那些数据呢?我们打印一下看看
那么怎么取ivars中的具体值呢?我们先看看 ivar_list_t 类型定义
发现 property_list_t 和 ivar_list_t 都是继承自同一个类型entsize_list_tt
entsize_list_tt 内部定义了一个成员函数 get(下标)可以获取到 list 中的 Element ,那么我们就可以直接调用 get() 函数来打印成员变量 和 属性
实际上我们只定义了3个成员变量,由于@property 定义的属性系统会自动帮我们生成带下划线的成员变量,所以这里的count 为 6 。
那么我们再看看属性
Student只定义了3个属性,但是这里打印却出现了7个,由此可见,llvm帮我们自动帮我们定义了4个属性:hash、superclass、description、debugDescription。
所以这也是为什么我们可以用点语法获取类的superclass、description等,这2个属性应该是相对来说使用频率高的。
获取方法列表
咦,怎么返回值是 void * ,我们再找找是否有函数可以调用来获取MethodList
找到了,执行下面的命令调用get()函数来获取具体的方法元素
注意 上面 p $6.baseMethodList 和 p $6.baseMethods() 返回的地址是一样的,只有类型是不同的,后面会分析为什么
但是为什么get函数返回的值会是空的,我们看看 method_t 结构体是怎么定义的
从注释可以得知具体的数据被封装到了 struct big {} 中,神仙操作啊
修改命令,用点语法调用big再重新获取
方法列表更离谱,竟然有10个
我们明明才定义了5个方法,2个对象方法,2个类方法,1个协议方法 (包括category)
从上图可以发现,2个类方法不在里面,多出来的方法是 llvm 自动生成的 property 属性 get、set 方法。
并且,注意看p $30->get(3).big()的结果
(method_t::big) $38 = {
name = "categoryFunc"
types = 0x0000000100003d4f "v16@0:8"
imp = 0x0000000100003b30 (KCObjcBuild`-[Student(TestCategory) categoryFunc] at main.m:55)
}
category的对象方法竟然也在 ro 的baseMethod里面,结合$6.baseMethodList 和 p $6.baseMethods() 返回的地址一样,可以分析出类的方法列表有且只有一份存档, ro 和 rw 的方法列表指针都是指向这一份内存地址的。这点和之前版本的 ro 、 rw 的结构是不一样的,要注意!!!
最后我们把协议也打印下看看
最终打印的数据竟然不能看,这里肯定有什么误会,我们再从源码着手,看看是否是类型错误导致的
注意看红线位置的注释,protocol_ref_t 其实是指向protocol_t *的指针。那么我们可以直接强制转换类型,将$49 和 $50 强制转换成 protocol_t * 类型打印
到此,成员变量、属性、方法、协议 都已经验证完毕,最后还剩余2个类方法没有被发现,类方法是保存在类的元类中的,怎么获取元类呢?其实在上一个章节已经教过大家怎么获取元类了,方法和实例对象获取类对象的地址是一样的,通过 isa 指针 & 上一个宏常量Mask,就能得到元类的地址。
$0 = 0x00000001000086d0 是 类对象的地址
大家注意2根绿线的位置,0x00000001000086a8 是 Student 类对象的 isa 指针,与 Mask 进行 & 计算后 获取到 元类对象的地址,0x00000001000086a8,没错,就是 isa的地址。这可能是个巧合?
然后继续使用之前获取类信息的步骤,一步一步获取到元类的 ro
注意绿线的位置,这次笔者是直接使用强制类型转换,将原本是 void * 强制转换成了 method_list_t * , 效果是一样的。
下面的打印和我们在Student类中定义的是一样的,并且这次LLVM没有帮我们生成其他的类方法。
结论 :8.18版本的objc 类的信息都可以通过 class_rw_t 的 ro() 函数来获取到。
彩蛋:在阅读objc_class 结构体大量的成员函数中发现了3个有意思的函数实现,相信大家看到了也会很吃惊!
getMeta() 获取元类, 判断本身是否是元类,如果是,则返回自己,否则调用ISA()函数进行Mask计算。
证明了元类是类对象通过 isa 计算出来的。
isRootClass() 内部直接判断 当前类的superclass 是否为nil
证明了根类NSObjcet的父类 为nil。
isRootMetaclass 判断通过 isa 计算得到的地址是否就是自己
证明 根元类的 isa 指针其实指向的就是自己
结合下图,会对 类 、 元类、isa 、superclass 之间的关联 有更深刻的理解
还没结束呢,我们还有 catch_t 这个结构体没有分析呢!!!
catch_t
下面分析 catch_t ,顾名思义,从命名来看,catch_t应该是跟缓存有关的,那么缓存了类的什么信息呢?先从 catch_t 结构体的源码找找线索
从上图来看(注意看绿线划的2个函数的参数),catch_t 内部有这么一个函数
void insert(SEL sel, IMP imp, id receiver)
我们可以大胆的猜想这个函数应该是将缓存的信息插入到容器中,那么再看具体的参数
SEL sel 这不就是方法的符号信息么
IMP imp 方法的实现,或者说imp里存储着方法执行的首地址
id receiver 接收者,其实就是调用方法的对象
大家再看另外一个函数 cache_getImp(cls(), sel) == imp,该函数的定义就在 cache_t 的上面,从注释可以知道 cache_getImp 就是从缓存中查找方法的 imp
从cache中拿到imp,由此可以大概的确定 catch_t 应该就是缓存类的方法的,那么具体缓存的是什么方法呢?什么时候会触发 insert 的调用呢?
实战打印catch_t
我们用打印 bits 的方式来打印一下 catch_t ,至于为什么要用偏移量的方式打印,是因为正常打印会报错失败,和直接打印bits一样。
在此之前,既然跟方法有关,那么我们就先在Student类添加几个对象方法,如下
修改main函数内部代码如下,打上断点然后运行工程
注意:
1、Student 类只 alloc 申请了内存空间,没有调用init方法。
2、student.name 是会触发 属性的 setName 方法调用的。
也就是说,目前 student 对象没有调用任何一个方法。
运行结果如上图,在没有调用方法的情况下,_occupied 和 _maybeMask 的值都为0
过掉断点,执行 student.name=@"ZYYC" 语句,这句代码会调用 setName 方法
可以发现划线的_maybeMask由之前的0变为了3,_occupied由原来的0变为了1。这中间的变化就是调用了一个方法。我们继续过断点
_maybeMask 的值不变,_occupied 又加 1 了,再继续过断点
_maybeMask 竟然直接变成7了, _occupied 竟然变成了1,这很不符合我们的预期,说明有问题,我们再继续过断点看看会发生什么其他的变化
_maybeMask 还是7 , _occupied 又增加了 1 。 值的变化不是很有规律,从调用一次方法变化一次的情况,_maybeMask 和 _occupied 的变化可能和 insert()函数有关,我们查看下 insert() 源码实现逻辑,源码比较多,挑重点截下来分析
简述下 insert() 的 流程 :
1、拿到_occupied,并且将值加 1,验证了调用一次方法值会加 1 的变化。
2、拿到旧的容量oldCapacity,并且赋值到新创建的capacity 中。
3、判断缓存是否为空,如果为空,则调用reallocate进行初始化操作,申请空间,稍后分析reallocate函数的具体实现。在调用reallocate之前,会对capacity的值做判断,如果没有值,则赋值为默认值 4 。
4、判断是否达到容量的 3/4 或者 7/8,没有达到则什么都不做
5、判断是否支持装满容量,如果支持,并且容量还未装满,则什么都不做
6、上述3、4、5 步骤都判断失败的话,则进行扩容操作,扩充的容量为之前的 2 倍,扩容之前判断是否扩充的容量超过了最大值 2 的 16 次方,如果超过了,则赋值为最大值。最后调用reallocate进行扩容。
7、mask 的值 为容量 capacity - 1,因为 capacity默认为4,所以_maybeMask第一次的值为 3 。并且扩容后容量为 4*2 = 8,_maybeMask = 7。
但是上述流程并没有解释 为什么 _occupied 的值会在扩容的时候重新赋值为 1 。
我们看看 reallocate 的实现
buckets() 其实就是返回通过 _bucketsAndMaybeMask 拿到地址,然后再做一次 & 计算得到的地址指针。 _bucketsAndMaybeMask 是cache_t 的第一个成员,不知道大家还记得吗?
没有发现 _occupied 相关代码,继续进入 setBucketsAndMask 函数内部
发现了 在新建的时候以及扩容的时候 _occupied 的值会被重新初始化为 0 。
并且上面的注释也透露了一点 objc_msgSend 的秘密。
我们都知道OC的方法调用底层其实就是 objc_msgSend 函数的调用,可见objc_msgSend 在查找方法的时候会先到cache_t 缓存中查询,如果查询到的话就直接调用,大大的降低了方法查询的时间。
从上述代码可以得出我们的方法缓存存储的容器其实就是 buckets()
从 bucket_t 的结构体成员 也验证了存储的确实就是 方法符号 和 方法地址。
有创建就有回收,我们看看官方是怎么回收缓存内存的
调用 _garbage_make_room 创建了一个垃圾场,用于存放需要回收的缓存数据
garbage_refs 是垃圾场的存储容器
真正回收内存的函数是 cache_t::collectNolock(false) 这句
先看看 _garbage_make_room 里面是怎么创建垃圾场的
如果是第一次进入就申请内存,创建容器空间。否则判断容器是否满,满了就扩容为 2 倍。
然后看看 collectNolock 的内部实现
看划绿线的位置,判断 需要回收的垃圾大小是否达到一个阈值,如果没有达到阈值则不会回收,直接return了
绿圈内部才是真正进行内存回收的逻辑,调用了非常熟悉的 free 函数进行内存回收。
扩容这部分代码分析完毕,继续分析 insert 后半部分插入的逻辑
上述代码进行真正的插入逻辑,该逻辑会进行检查防止重复缓存。
再回过头来分析下 isConstantEmptyCache 内部是怎么判断为空的
内部调用了emptyBucketsForCapacity ,注意第二个参数传入了 false
emptyBucketsList 是一个二维数组,所有空的Buckets都是从这个二维数组中取的。
源码分析完毕,上述打印的结果得到验证,又到了实战环节了,我们这次把缓存的方法符号给打印出来。
之前断点运行到了下图所示位置
目前 _occupied = 2 , 说明缓存中有2个方法,以上图断点的位置,应该存储的是 instenceFunc2 和 instenceFunc3 。
打印第一个缓存的方法符号如下,正是 instenceFunc3
继续打印
咦,怎么是nil,有点奇怪哦,继续打印
找到了 instenceFunc2
笔者将剩余的位置打印出来了,都是nil,就不再将截图传上来。
结论: cache_t 会将对象调用的方法进行缓存, 缓存的方法不是按照顺序存储的,是以hash算法计算位置查询空插槽进行插入的,并且在每次扩容的时候会将之前存储的方法全部清空。