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

        在上个章节,我们学习了对象的本质,对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算法计算位置查询空插槽进行插入的,并且在每次扩容的时候会将之前存储的方法全部清空。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 196,264评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,549评论 2 373
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 143,389评论 0 325
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,616评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,461评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,351评论 1 273
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,776评论 3 387
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,414评论 0 255
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,722评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,760评论 2 314
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,537评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,381评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,787评论 3 300
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,030评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,304评论 1 252
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,734评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,943评论 2 336

推荐阅读更多精彩内容