美文网首页
重学iOS系列之底层基础(一)OC对象的本质

重学iOS系列之底层基础(一)OC对象的本质

作者: 佛系编程 | 来源:发表于2021-12-04 00:31 被阅读0次

    导读

            在开发过程中,是否有疑惑过,我们创建的OC对象本质到底是什么?实例对象在内存中是怎么存储的?对象在程序中到底占用了多少内存?传说中的isa里面到底存储了什么东西?笔者将会在本章节带领各位读者通过源码分析、实战演练对上述问题进行详细的解答。

    我们平时编写的OC代码,其实在底层都是用c\c++实现的,在上个APP启动章节源码分析的时候就已经验证过。所以OC的面向对象都是基于c\c++的数据结构实现的。那么想要了解对象的本质,就需要将OC代码编译成C++代码。

    1、OC对象本质到底是什么?

    我们先来看一段代码,此段代码定义了一个继承自NSObject的类Person,并且含有2个成员变量,一个对象方法。

    在终端上 cd 到当前 main.m 文件所在目录,然后执行下列命令:

    xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o main__.cpp

    然后打开main__.cpp,搜索int main

    从上图,肯定看到我们编写的Person类的本质其实是一个结构体,OC的对象方法本质也是一个c函数。

    注意看下图

    转换后的Person_IMPL结构体里多了一个NSObject_IMPL类型的结构体成员NSObject_IVARS,那么NSObject_IMPL又是什么东西呢?

    NSObject_IMPL结构非常简单,只有一个成员Class类型的  isa。

    Class其实是一个指向结构体的指针。

    将NSObject_IMPL带入到Person_IMPL结构体中展开,可以得出Person类的本质其实就是:

    struct Person_IMPL {

            Class isa;

            int _age;

            NSString *_name;

    }

    由此可以得出一个答案:OC对象的本质其实就是一个结构体

    2、实例对象在内存中是怎么存储的?

    我们先将之前的Person代码改动一下,改完之后是这样的:

    然后再在main里给实例对象赋值,并且打个断点运行起来:

    最后在lldb中用x,p和po这些指令来查看实例对象的内存数据。

    解释下p,po,x/8gx的意思。p是expression - 的别名,该命令允许执行指令后面跟随的代码、表达式、以及内存地址等,并且会返回执行结果(包括值的类型以及结果的引用名$0、$1)。 po是expression -O — 的别名,po只会输出对应的值。x/8gx 对象 表示输出8个16进制的8字节地址空间(x表示16进制,8表示8个,g表示字节为单位,等同于x/8xg 对象)

    如上图所示,实例对象 z 指针指向的首地址为:0x600002568000,该地址存储的内容为 0x0000000101746ab8。你们可以这样理解,假设z指针有一个地址A,z指针在内存中的地址A -> 0x600002568000,然后0x600002568000存储的内容为0x0000000101746ab8。po该地址打印的是Person,为什么呢?从第一个问题的回答截图我们知道对象的本质是个结构体,而且该结构体的第一个成员是isa指针,那么我们可以猜想0x0000000101746ab8就是isa指针的地址。至于为什么会打印出Person,我们后续分析isa的详细结构的时候会为大家解答。

    从上图我们可以看到,给对象 z 赋值的name 、lastName、no 都打印出来了,但是age以及testChar没有打印。其实是有打印的,只是打印错了,注意看0x0000001200000071的打印,是一串莫名其妙的数字,其实这个内容存储的就是age以及testChar的值,只不过打印方式错了,age是int类型,int类型在x86架构下是占4个字节,而char类型在任何架构下都是占 1 个字节。

    所以,我们要打印age的话,只要将前4个字节打印出来,要打印testChar的话,将后4个字节打印出来,为什么是后4个字节,后面会进行详细分析讲解。上图的打印验证了我们的结论。

    不知道大家有没有发现,成员变量在内存中存储的顺序并非是按照定义的顺序存储的,我们对比下:

    内存中存储的成员变量顺序:age 、testChar、no、name、LastName

    类中定义的成员变量顺序    :age 、no、name、LastName、testChar

    为什么呢?因为编译期会对内存做优化,防止出现内存浪费。

            解释下为什么 'q' 在内存中存储的是113,因为计算机只能存储0和1,没办法直接存储字符,存储的是该字符的 ASCⅡ码。

    好了,言归正传,OC对象在内存中存储的内容可以总结如下:

    1、isa指针

    2、其他的成员变量,但是变量存储的顺序并非按照定义的顺序存储的

    3、对象在程序中到底占用了多少内存?

    我们再计算下Person对象在内存中占用了多少个字节,从上文截图中,我们红色框出的数据就是 z 对象的存储数据,是5 * 8 = 40字节,那么person对象是否真的占40个字节呢?

    获取内存大小的三种方式分别是:

    1、sizeof

    2、class_getInstanceSize    //    需要添加头文件<objc/runtime.h>

    3、malloc_size        //    需要添加头文件<malloc/malloc.h>

    我们来写代码确认一下,在工程中添加如下打印代码:

    class_getInstanceSize确实返回了40,也就是说我们计算得没错,Person对象的成员实际确实是占用了40个字节,但是后面的malloc_size返回的是48。

    这就很奇怪了!!!从函数名字我们猜到malloc_size是系统申请的空间大小,为什么系统申请的空间大小是48呢?为什么不是40呢?

    我们先来看看这3种方式获取内存大小的区别!

    1、sizeof     

    是一个运算符,并不是一个函数。原理是在编译器编译阶段就会确定传入参数的类型大小并直接转化成 8 、16 、24 这样的常数,而不是在运行时计算。

    它的功能是:获得保证能容纳实现所建立的最大对象的字节大小。

    由于在编译时确定占用内存大小,因此sizeof不能用来返回动态分配的内存空间的大小。

    2、class_getInstanceSize    

    用于获取类的实例对象所占用的内存大小,并返回具体的字节数,其本质就是获取实例对象中成员变量的内存大小,返回创建一个实例对象所需内存大小。

    我们可以通过查看iOS开源的objc4源码了解 class_getInstanceSize() 具体是怎么计算的

    搜索class_getInstanceSize函数

    调用了alignedInstanceSize函数

    alignedInstanceSize内部调用了unalignedInstanceSize来获取instanceSize,然后再传入word_align进行字节对齐计算。data()->ro()在之前的objc(runtime)章节已经简单分析过了,ro中存储着类中只读的信息(成员变量,方法,协议等)。

    word_align (采用8字节对齐)

    WORD_MASK在64位架构下是7UL,其实就是7。

    那么word_align内部其实就是做了这样的运算, (x + 7) & ~ 7

    X+7其实就是为了进位,以8为进制的进位操作;然后先计算7的异或,7的异或不就是前面全1,最后3位全0吗;再将两者进行&操作,最终的结果一定是8的倍数。word_align其实就是将传入的数字进行8对齐。

    我们来举个例子,假设我们传入的是15,15+7 = 23,将23转换成二进制 :

    23 =  1111 ; 7的异或转成4位二进制 = 1000

    然后再将 1111 & 1000  = 1000

    1000转成十进制不就是16吗,2*8 = 16。

    结论,class_getInstanceSize是返回以8字节为对齐的对象所需的内存大小。所以我们之前计算的Person成员占用40字节正好是8的倍数,所以返回40没错。

    3、malloc_size

    此函数为系统内核函数,返回系统实际为对象分配的内存大小(采用16字节对齐)。源码在libmalloc中,全局搜索发现里面没有什么对齐操作啊,只调用了find_registered_zone,但是在这个函数里也找不到任何对齐操作,其实find_registered_zone真的就是如同函数名称一样内部只是查找是否有注册了该对象的zone空间,如果有,找到该空间,然后返回zone空间中该对象所占用的内存大小。所以malloc_size内部没有做分配工作,那么我们怎么才能知道malloc_size为什么给Person对象分配的是48字节呢?

    从源码分析已无从得到答案,那么我们就只有从 [Person alloc] 方法入手,以打断点,通过汇编指令查看函数内部的调用来了解系统分配内存是什么流程了。

    在 Person*z = [[Personalloc]init];处打个断点,运行代码

    在lldb输入 si(Step Into 单步跟踪进入) 指令  进入函数内部,直到进入下图的函数

    发现竟然是libobjc动态库里的函数。这不巧了么,打开前面下载好的objc工程。然后全局搜索objc_alloc_init

    最后一个函数就是objc_alloc_init,内部其实就是调用了callAlloc()来申请内存。细心的读者肯定已经发现了一个很奇怪的现象,这些函数内部都是调用了callAlloc(),从注释可以了解到这些函数都是从这些注释里的OC方法转换成的。

    我们先看看init方法内部做了什么

    嗯,什么都没做!什么都没做!什么都没做!重要的事情要说三遍。

    但是笔者在查找init的方法过程中发现了这个函数

    那么这就很奇怪了,为什么我们的代码不是走这个函数呢?其实是因为编译器在编译阶段做了优化,然后你只要是    [[cls alloc] init]     这种写法就会直接调用到objc_alloc_init函数,而不会直接调用+(id)alloc,但是后面还是会调用到这个alloc方法,至于为什么,笔者先卖个关子。

    我们再继续看callAlloc是怎么做的

    callAlloc(cls, true/*checkNil*/, false/*allocWithZone*/)

    callAlloc有3个参数,第一个是当前的类,第二个参数传了ture,第三个参数传了false。

    为什么会最终调用到_objc_rootAlloc呢?我们先静态分析一下,因为Person对象没有实现alloc方法,所以找不到alloc的实现,然后会从Person的父类去查询是否有实现该方法。Person的父类是NSObject,所以会去NSObject的方法列表中去查找。正好NSObject有实现alloc方法,而alloc里面就是调用了_objc_rootAlloc。

    我们将之前的Person类加到objc工程中运行一下验证一下

    大家注意看这次callAlloc传的后2位参数,跟上一次传的是不一样的

    callAlloc(cls, false/*checkNil*/, true/*allocWithZone*/);

    这次第二个参数传的是false

    第三个参数传的是true

    最终调用了_objc_rootAllocWithZone函数,自定义的allocWithZone标志位其实是在消息转发的过程中写入的。

    继续进入_class_createInstanceFromZone

    截图中已经对关键函数进行了注释解释,并且用红线划出了3个重点函数

    cls->instanceSize(extraBytes)

    现在最后一条红线,判断size是否小于16,如果小于16则直接赋值16,这就意味着一个对象的内存大小最低都是16字节。

    然后extraBytes传入的时候是0,所以 size = alignedInstanceSize;

    alignedInstanceSize内部又调用了word_align(),咦这个函数怎么这么熟悉?

    word_align我们在前文已经分析过了,是对传入的值进行8字节对齐。

    那么unalignedInstanceSize()是什么呢?

    原来是去ro 中去获取instanceSize。ro 在第一次消息转发的时候调用了initialize进行isa的初始化,然后ro的instanceSize就有值了。

    if ( fastpath( cache.hasFastInstanceSize( extraBytes ) ) ) {

                return    cache.fastInstanceSize( extraBytes ) ;

     }

    我们进入hasFastInstanceSize方法看看

    __builtin_constant_p(extra)判断extra是否为编译常数,整个方法查资料得到的是这个方法判断是否有缓存,具体怎么判断后面知道了再补充。 回到instanceSize方法,如果有缓存就直接返回缓存就会调用返回size,看下fastInstanceSizefan方法,注意最后的align16方法是个算法,就是为了保证返回的内存地址大小永远是16字节的倍数。这就是16进制内存对齐。

    (id)calloc(1, size)  重点!!!

    兜兜转转最终还是回到了libmalloc库里面,将库下载下来,然后全局搜索calloc,最终在下图找到具体实现

    上述的default_zone是一个默认的空间大小,目的就是引导程序进入一个创建真正zone的流程; size是我们传入的空间的大小。

    红线划的位置就是实际申请内存的函数调用,但是已经不能右键jump了,而且全局搜索也搜索不到。

    唯一的办法是打断点进行调试,在zone->calloc代码处打上断点,等程序执行到断点时,有两种方式可以查看zone->calloc源码实现

    1、按住control + step into,进入calloc的源码实现(需要打开Debug的汇编模式)

    2、在控制台输入lldb命令p zone->callocde查找源码实现

    全局搜索default_zone_calloc方法,找到具体实现

    default_zone_calloc内部调用了runtime_default_zone来获取zone,然后又是一个zone->calloc(肯定是不能跳转进去看源码的,崩溃),内部创建的zone的类型决定了后续zone->calloc的调用,那么这个zone到底是什么,调用的calloc又会被重定向到哪个函数?我们看看能否从runtime_default_zone中得到什么有用的信息

    执行到这malloc_zones已经有值了,说明zone已经创建好了,就是在_malloc_initialize_once内部创建的,_malloc_initialize_once内部其实是进行单次_malloc_initialize的调用,zone就是在这个函数内部创建的。

    _malloc_initialize

    函数比较长,笔者挑了重要的部分截图出来

    设置在Libsystem中获取的相关环境变量

    nano_create_zone内部申请了空间

    nano_common_allocate_based_pages这个函数内部调用了mach内核进行物理内存页的分配。

    由此我们知道了default_zone_calloc中最后zone->calloc的真面目了

    ok,我们用lldb指令 p zone->calloc 来验证一下

    完美,成就感油然而生有没有!!!(其实之前的zone->calloc也可以从源代码进行分析得到default_zone的,有兴趣的读者可以自行分析)

    全局搜索nano_calloc

    找重点_nano_malloc_check_clear

    _nano_malloc_check_clear的源码非常长,但是我们要找的是分配空间大小,所以查看segregated_size_to_fit内部实现就行了,后续的代码不再分析!

    看到这,大家应该能找到规律了,右移 然后左移,这不就是典型的内存对齐操作吗?

    我们看看NANO_REGIME_QUANTA_SIZE这个值是多少

    注释已经很清楚了,就是16

    结论:calloc内部对传入的对象分配内存是以16位为对齐的。

    malloc_size获取的内存大小就是系统分配给对象的内存大小,也就是说malloc_size返回的内存大小等于对象成员变量需要的内存大小size进行16字节对齐后的值。

    obj->initInstanceIsa(cls, hasCxxDtor)

    这个函数内部就是调用了initIsa(cls,true, hasCxxDtor);

    函数内部先定义了一个 isa_t 类型的 newisa(0),看最后一句代码 isa = newisa ; 说明这就是我们要找的isa了,红线的位置其实就是在给isa进行初始化赋值

    我们看看 isa_t 是个什么类型?

    union 联合体,也叫公用体。其实在之前分析APP启动的过程中已经提到过了union。

    联合体是一个结构:

    1、它的所有成员相对于基地址的偏移量都为0;

    2、此结构空间要大到足够容纳最"宽"的成员;

    3、其对齐方式要适合其中所有的成员;

    ISA_BITFIELD 位域

    有些信息在存储时,并不需要占用一个完整的字节, 而只需占几个或一个二进制位。例如在存放一个开关量时,只有0和1 两种状态, 用一位二进位即可。为了节省存储空间,并使处理简便。

    了解了这些基础信息,我们回过头来继续分析isa联合体:

    isa_t(){} 是初始化方法

    Class cls 绑定的类

    uintptr_t bits typedef unsigned long长整形8字节

    有一个struct结构体,里面包含ISA_BITFIELD(这里使用宏定义的原因是因为要根据系统架构进行区分的)

    我们看看具体的位域

    nonpointer:表示是否对isa指针开启指针优化;0代表纯isa指针,1代表不止是类对象指针,还包含了类信息、对象的引用计数等;

    has_assoc:关联对象标志位,0没有,1存在

    has_cxx_dtor:该对象是否有 C++ 或者 Objc 的析构器,如果有析构函数,则需要做析构逻辑, 如果没有,则可以更快的释放对象

    shiftcls:存储类指针的值。开启指针优化的情况下,在 arm64 架构中有 33 位用来存储类指针。

    magic:用于调试器判断当前对象是真的对象还是没有初始化的空间

    weakly_referenced:该对象是否被指向或者曾经指向一个ARC的若变量,没有弱引用的对象可以更快释放。

    deallocating:对象是否正在释放内存的标识

    has_sidetable_rc:当对象引用技术大于10时,则需要将extra_rc的一半转存到sidetable

    extra_rc:表示该对象的引用计数值,实际上是引用计数值减 1

    之所以isa指针这么设计是为了优化性能,节省空间。指针有8字节,64bit,但是单纯的地址指针用不完那么多空间,如果空着就会浪费,所以在空余的地方加入对象的其它信息,节约了空间


    最后在掘金上找到一份大佬做的一幅图,很好的说明了isa的各个位域的位置

    4、传说中的isa里面到底存储了什么东西?

    这个问题想必大家都能答出来,分析initInstanceIsa其实就相当于回答问题4了。

    最后:isa是通过什么操作拿到cls指针的?

    其实很简单,就是通过 & 上一个mask

    取出bits 然后 和 ISA_MASK 进行 & 运算

    不同的架构ISA_MASK的值是不一样的,因为不同架构isa的位域是不一样的

    真机arm64架构下的    ISA_MASK        0x0000000ffffffff8ULL

    模拟器arm64架构下的    ISA_MASK        0x007ffffffffffff8ULL

    mac X86_64架构下的    ISA_MASK        0x00007ffffffffff8ULL

    既然知道了怎么计算,那么我们就自己打个断点验证一下上图类、元类、isa的关系:

    相关文章

      网友评论

          本文标题:重学iOS系列之底层基础(一)OC对象的本质

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