美文网首页
重学iOS系列之底层基础(二)类的本质

重学iOS系列之底层基础(二)类的本质

作者: 佛系编程 | 来源:发表于2021-12-08 02:09 被阅读0次

            在上个章节,我们学习了对象的本质,对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的结构体。其实这个结构体在之前的文章已经分析过了。

    重学iOS系列之APP启动(三)objc(runtime)

    用一幅图来表示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算法计算位置查询空插槽进行插入的,并且在每次扩容的时候会将之前存储的方法全部清空。

    相关文章

      网友评论

          本文标题:重学iOS系列之底层基础(二)类的本质

          本文链接:https://www.haomeiwen.com/subject/zqxgxrtx.html