Objective-C 底层对象探究-中

目录

  • 1、背景
  • 2、LLVM对alloc的优化
  • 3、对象内存大小的影响因素
  • 4、字节对齐
  • 5、结构体内存对齐
  • 6、malloc的分析探索
  • 7、对象内部对齐与结构体内部对齐的差别与意义
  • 8、总结


1. 背景

学习不迷茫,无阻我飞扬!大家好我是Tommy!今天我们继续来对底层进行探索,本章内容会比较多,里面的可能有些知识不太好理解,大家可以分小节进行阅读。废话不说我们这就开始!

2. LLVM对alloc的优化

  • 再次分析 alloc 流程:

    • 通过上篇《Objective-C 底层对象研究-上》我们已经对alloc的运行流程进行了梳理,但这里存在一个问题不知道大家是否发现了?就是我们通过符号断点等方式发现,alloc最先是调用了objc_alloc方法后再开始走调用流程的;(动态分析)
    • 但是我们通过源码方式分析发现alloc调用的并不是objc_alloc而是_objc_rootAlloc函数(静态分析),这又是什么原因呢?
      index.gif
    • 我们这里不如大胆猜测一下,OC里面的方法调用都离不开两个东西SELIMPSEL就是方法标示,IMP就是指向方法具体实现的指针,就好比一本书的目录一样,你需要先查到目录的条目之后再根据对应的页码找到具体内容。OC是动态语言SELIMP是可以进行动态改变的,所以alloc是存在被改变可能性的。
  • 探索调用 objc_alloc 的原因:

    • 经过我们的分析我们已经有了大致思路,那么我们就用过研究源码来验证我们的分析是否正确。
    • 首先我们先通过搜索objc_alloc看看是否有结果......
      图片.png
    • 哈哈!发现搜索出来的内容还是挺多的,但是不要怕,经过我的逐一排查我定位到了这里(红框处)。
    • 从这段代码我们就很明显的发现了在runtimeallocIMP的的确确是被替换了,这个已经证明我们分析的思路是正确的;
      图片.png
    • 那么我们继续看一下这个fixupMessageRef函数是在什么时候被调用的?继续通过搜索来得出答案。
      [图片上传失败...(image-f21061-1625212644421)]
    • 经过排查找到了fixupMessageRef函数是在_read_images这个函数中被调用的。
    • 再看_read_images方法上面的注释:“对链接中的头信息执行初始化处理”,应该可以猜到_read_images方法可能与DYLD加载Mach-O文件有一定关系。我们可以给map_images_nolock下个符号断点,为啥呢?因为_read_images我测试了无法断住,根据方法上面的注释得知是通过map_images_nolock这个函数调用的,所以果断试了一下可以断住。
      图片.png

      图片.png
    • 通过符号断点验证了我们的想法的的确确是由dyld进行调用的。到此我们可以先做一个简单的梳理:
  • 思路梳理:

    • 1、通过分析确认了alloc确实是在runtime的源码中有被替换的迹象;
    • 2、通过fixupMessageRef这个方法名称,我们可以理解程序在运行时,需要对alloc等一些方法进行修复处理;那我们是不是可以理解成:不管当前是否存在问题,alloc方法始终都会被改动调用objc_alloc
    • 3、fixupMessageRef方法是在_read_images中被调用的,而_read_images是在DYLD加载Mach-O文件时进行加载的;Mach-O文件中会存在一个叫做符号列表的内容,里面就会将App的方法存放到此表中,当DYLD加载时就会读取列表进行映射操作,而这个过程就叫做符号绑定(现在可以先这么简单的理解)
      图片.png
    • 5、通过以上分析我们可以得知,alloc方法在运行时会被进行检测,如果检测没有问题它依然还是调用objc_alloc,如果存在问题就通过fixupMessageRef方法进行修复处理,而处理结果依然是调用objc_alloc,这一点需要大家细品一下。 如果以上思路都明确之后,我们应该会想到alloc方法在运行时做的只是修复工作,那么其实真正对alloc方法进行修改的并不是在运行时,实际上可能还是在更底层进行修改的,而只是在runtime层增加了修复的逻辑,很可能是苹果出于严谨性的考虑,在这一步额外增加的一层保护(可能是为了防止开发人员通过hook等方式对alloc方法进行修改吧!~)。
  • 在LLVM中探索原因:

    • 想要探索LLVM我们需要下载LLVM-project这里是链接[LLVM-project下载],建议使用VSCode进行打开。
    • 下载完毕之后试试搜索objc_alloc看看有什么结果,我们点击第一个结果就能发现这些线索;“当此方法返回true时,Clang将把某些选择器的非超级消息发送转换为对相应入口点的调用”,通过这条注释以及下面的alloc => objc_alloc例子我们就可以明白了,在编译阶段alloc就已经被进行了转换设置。
      [图片上传失败...(image-541a7c-1625212644421)]
    • 我们继续搜索shouldUseRuntimeFunctionsForAlloc函数看看调用逻辑,发现是在tryGenerateSpecializedMessageSend函数中进行调用的。
      图片.png
    • 再搜索tryGenerateSpecializedMessageSend函数查看调用逻辑,搜索后我们来到了GeneratePossiblySpecializedMessageSend函数。
      图片.png
    • 从代码我们可以简要的看出,当发送消息时会先判断是否符合发送特殊消息的条件,如果符合就尝试通过特殊方式发送,如果不满足就按正常流程发送消息。按照这个逻辑就能得出一个结论了:
  • 小结论:

    • 就是当alloc()第一次执行时,被LLVM按特殊消息发送来处理了,底层将目标转换成了objc_alloc();objc_alloc执行后第一次调用了callAlloc();

    • 首次进入callAlloc()后去执行objc_msgSend的方法,又再一次调用了alloc(),但是这次LLVM是按正常方式进行处理,发送给了_objc_rootAlloc();_objc_rootAlloc()执行后第二次调用了callAlloc();然后开始对内存进行对象内存的开辟工作直至完成。

  • 再次梳理alloc流程:

    • 我在上篇《Objective-C 底层对象研究-上》中画过一个alloc流程图,在这幅图中我们当时发现callAlloc()被执行了2次,那么我们将我们今天探索得到的结果,添加到这幅流程图中进行补完,大家可以对比看一下就能了解callAlloc为什么会被调用了2次的真正原因了。
    图片.png

    图片.png
    • 接下来我们可以在深入一点,查看一下底层是如何处理函数调用的,我们可以通过tryGenerateSpecializedMessageSend函数中对alloc方法处理为例子,一步一步跟踪,最终我们走到了下面图片所示的位置;通过上下传参最终会通过Builder.CreateCall()Builder.CreateInvoke()进行函数的指令调用;
      图片.png

      图片.png
    • 通过对底层LLVM的探索,我们可以发现苹果对一些重要方法,尤其是跟内存有关的方法都进行了类似HOOK方式的处理,这里猜测应该是对这些方法进行了一些监测和监控处理。到此本小节结束。

3、对象内存大小的影响因素

  • 查看对象占用内存的大小:

    • 我们接下来探索一下对象在内存中的大小,每个对象都是在执行alloc后都会开辟出内存空间;我们来看一下ZXPerson的对象在内存中占用了多少空间,我们可以通过class_getInstanceSize()方法打印大小,使用此方法时请导入 #import 头文件。编译运行后显示了占用大小。
      图片.png
  • 发现影响大小的因素:

    • 增加属性和成员变量:我们添加或者删除一下属性和成员变量可以观察到,对象的大小会有不同的不变化,增加时大小会增大,反之亦然;


      图片.png
    • 添加方法:属性和变量会影响大小改变,我们也可以试试添加方法是否也会改变大小?答案是并不会。


      图片.png
    • 到此我们可以得到一个结论:对象的内存大小是由成员变量决定的,跟其他内容没有关系
  • class_getInstanceSize()方法:

    • 我们进入到objc源码Command+shift+O搜索class_getInstanceSize直接就可以定位到。
      图片.png
    • 我们一步一步定位到这里给出了明确提示:May be unaligned depending on class's ivars.
      图片.png
  • 没有变量时打印为什么是8?:

    • 当我们将所有定义的成员变量删除之后,通过class_getInstanceSize()方法打印结果是8,这也就说明我们一定从父类中继承过来了成员变量,我们再通过源码进行验证。
      图片.png
    • 我们直接搜索父类NSObject,就会看到父类中存在一个变量叫做isa;那么第一个疑问就解开了,确实从父类中继承了变量过来;那么大小为什么是8呢?我们继续分析。
    • 我们发现这个isa的类型是Class,我们跟踪一下看看有什么结果,Command+shift+O搜索Class,发现Class是一个类型定义,实际是objc_class类型的指针类型,而在arm64下一个指针正好是占用8个字节。
      图片.png

      图片.png
    • objc_class是一个结构体并且继承objc_object,那么我们自定义的类在底层实际都变成了objc_object。我们可以通过clang命令对.m文件进行编译。(我的实例程序都写在了mian.m文件里,所以我就编译了main.m文件)
    clang -x objective-c -rewrite-objc -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk main.m
    
    • 编译成C++文件我们就能看到我们定义的类在编译之后都会变成objc_object结构体类型。
      图片.png

      ps:这么做的目的是苹果为了在底层对开发人员定义的类进行统一处理而进行了转换,因为苹果不可能在底层去逐一的去实现开发人员定义的类,这是不可能定义出来的,因为可变性太大了;所以为了方便对类进行管理和操作,就必须设计一个通用的类型来替代。

    通源码探究我们发现Object-C的底层都是通过C/C++来实现的,所以OC中的对象也会转化成C/C++中的某一个数据结构,到此本小结结束。

4、字节对齐

  • 通过上一节的研究,我们得知Object-C的底层都是通过C/C++来实现的,所以OC中的对象也会转化成C/C++中的某一个数据结构。

  • 我们再次回到源码_class_createInstanceFromZone()里找到instanceSize(),通过上一篇的探索我们已经得知了,该方法是负责返回对象所需的空间大小的;我们跟踪进去可以看到优先从缓存中查找大小,如果缓存没有就重新计算大小,最后还有一个判断就是如果计算的大小不足16字节,就补足16字节

    图片.png

  • alignedInstanceSize()方法中我看可以看到底层系统将对象占用的内存大小进行了字节对齐,我看通过word_align()了解具体对齐算法。

    图片.png

  • 算法解析:

    • WORD_MASK 的值是7UL,其实就是7;(UL的意思是 unsignedLong 无符号长整型);
    • 假如x=7;(7+7) & ~7 ;14 & ~7 ;0000 1110 & 1111 1000 = 0000 1000(8)
    • 假如x=9;(9+7) & ~7 ;16 & ~7 ;0001 0000 & 1111 1000 = 0001 0000(16)
    • 我们可以看到算法其实是按8字节进行对齐,不足8就按8算,超过8就以8的倍数进行,例如9:就按8的2倍计算也就是16;如果是20就按8的3倍计算也就是24(大家可以自行验证)
    • (ps:~7 是意思是非7 就是按7的二进制取反)
  • 字节对齐原理:

    • 为什么要进行字节对齐?这是为了提高CPU读内的效率将内存统一按一个大小进行对齐处理,实际占用的大小不足时,就通过补0方式对齐。这么做虽然牺牲了一定的内存空间,但是读取的效率会大幅提升,也就是用 “空间换时间”
      图片.png
  • 思路梳理:

    • 我们定义的类从NSObject里集成了isa属性占用8字节;
    • 分析源码instanceSize()得知对象内部结构是已8字节进行对齐,但系统是最小给分配了16字节;
    • 字节对齐算法:通过(x + WORD_MASK) & ~WORD_MASK方式进行计算;
    • 为什么要选择以8字节对齐?这是因为在arm64下,8字节基本上就是最大的占用字节数了。
    • 如果对象大小超过16字节会怎么样?其实在最后底层还会以16字节进行一次对齐处理,请看下一个小节内容结构体内存对齐

5、结构体内存对齐

  • 在上一篇我们通过x/4gx 查看了类对象中在内存中的存放状态,其中我们发现了一个现象就是一个8字节的空间里面存放了2个不同的数据,这种现象就叫做内存对齐并且做了相关优化处理。当我们创建一个对象指针时,该指针实际指向的是一个结构体类型,那么对于结构体来说内存大小这块是否有什么不一样?下面就让我们来一起探究一番。
    图片.png
  • 结构体内存的三个原则:
    • 结构体内第一个成员以0为起始位置,而后的成员起始位置要从成员的占用大小或子成员的占用大小的整数倍开始;
    • 如果内部成员是一个结构体,则结构体成员要从其内部最大元素占用大小的整数倍地址开始存储;
    • 构体的总大小,也就是sizeof的结果,必须是其内部最大成员的整数倍.不足的要补⻬;
  • 我们可以自己编写2个结构体来进行验证:
    • 内部成员声明位置先后不同,得到的大小不同;出现这样的原因就是根依据上面的三个原则而得到的结果,我们先来验证一下非嵌套的结构体。


      图片.png

      图片.png
  • 测试下带嵌套的结构体,我新建一个ZXStruct3,然后将ZXStruct1声明为内部的一个成员。


    图片.png

    图片.png
    • 理解:
      • ZXStruct3 的第一个成员占用到第 3 个字节位置,根据 原则2 应按照结构内部最大元素的大小的整数倍开始存储,所以从 8 开始;然后再用 8 + zx_t1 大小,就可以直接得出实际大小了也就是 8 + 24 = 32
      • 结论:先计算原结构体占用大小,再根据原则2对齐,最后加上嵌套结构体就是最终的大小结果。
  • 为何要对齐?带来什么好处?
    • 结合我们上面介绍的字节对齐、和结构体对齐的知识,我们就可以猜到对齐的原因就是为了提升读取效率,苹果在内存读取上做了优化处理,请看下面的例子大家就能有所感悟了。
    • 我们还是以ZXStruct1前三个成员为例,将3个成员放大来观察。
      图片.png
    • 不采取对齐:
      • 如果不按成员大小进行对齐,就会安装图上所示的样子进行排序,最后再进行补齐,但是读取逻辑就发生变化了。
      • 首先8位读取,p1可以一次读完,再次按8位读取的时候就发现无法正确读取了,因为发现后8位包含了混合数据,所以需要根据成员大小调整步长读取,共需要4次完成,这样就会降低效率。
    • 采取对齐:
      • 按成员大小进行对齐后,首先按8位读取,p1可以一次读完,这个没有发生改变,后面读取时判断含有混合数据的话,按数据中最大的占位进行读取,并且将补位的空位进行合并,(反正最后都需要补位,不如将空位移动到前面一起读取来提高效率)所以读取3次就可以完成了。
  • 至此结构体内存对齐的相关知识介绍完毕,最后附上一个各个类型所占用大小的列表图。
    C OC 32位 64位
    bool BOOL(64位) 1 1
    signed char (_signed char)int8_t、BOOL(32位) 1 1
    unsigned char Boolean 1 1
    short int16_t 2 2
    unsigned short unichar 2 2
    int、int32_t NSInterger(32位)、boolean_t(32位) 4 4
    unsigned int NSUInterger(32位)、boolean_t(64位) 4 4
    long NSInterger(64位) 4 8
    unsigned long NSUInterger(64位) 4 8
    long long int64_t 8 8
    float CGFloat(32位) 4 4
    double CGFloat(64位) 8 8

6、malloc的分析探索

  • 首先我们先来看一个现象,我对ZXPerson类的对象*zxp分别通过class_getInstanceSize()sizeof()malloc_size()3个函数进行打印输出;

    图片.png

    图片.png

  • 此时我们ZXPerson类中定义了4个属性再加上隐藏属性isa,一共是5个属

    • class_getInstanceSize()打印了32, 这个没有问题(8+8+8+4+1 最后按8字节对齐 = 32)
    • sizeof()打印了8,这个没有问题(因为打印的是指针,指针的大小就是8占字节)
    • malloc_size()打印了32,跟class_getInstanceSize()一样,貌似也应该没有问题;
  • 此时我们ZXPerson类中新增一个属性zxNikeName再来看看结果。


    图片.png

    图片.png
    • class_getInstanceSize()打印了40 没毛病!(8+8+8+4+1+8 最后按8字节对齐正好 = 40)
    • sizeof()没变化;
    • malloc_size()结果却不同了变成了48,奇奇怪怪的事情就这样神奇的发生了!那么为什么呢?接下来我们来一起探索一下。
  • 首先我们先通过追踪下malloc_size(),从注释“Returns size of given ptr”我们得知malloc_size()函数会根据ptr来返回大小值,而ptr就是我们传入的指针。当我们想继续往下追踪时发现已经无法往下走了。那怎么办呢?首先不要慌!我们确定一下这个malloc_size()函数的所在位置是在哪里,从上面的导航我们可以看到这个函数是在malloc这个库下面。我们就可以再通过源码方式来进行研究了(日后我们探究的思路都是以这个方式来进行的)

    图片.png

  • 在探索源码前我们还可以去苹果官网搜索这个函数的官方解释 malloc_size 的苹果官网解释: “返回ptr所指向的分配的内存块的大小。内存块的大小总是至少和它的分配一样大,也可能会更大”,通过官方的解释我们就能理解我们现在遇到的这个现象了吧,现象就是返回的大小可能跟实际分配的一致或更大。那么接下来,我们带着这个问题来开始源码的探索。

    图片.png

  • 下载libmalloc可编译的源码:下载libmalloc可编译的源码

    图片.png

  • 在上一篇文章中我们已经对alloc的开辟流程进行了梳理,发现 alloc 申请内存是 calloc 发起的,所以我们直接把断点断到calloc上。对于这块不清楚的同学请走传送门 《Objective-C 底层对象研究-上》

    图片.png

    图片.png

  • 我们将断点断在calloc上,来跟踪内存开辟的机制,编译-运行后我们进入到了calloc里,这只是一个封装函数,继续跟踪_malloc_zone_calloc()

    图片.png

  • 进来后我们可以观察一下,根据上面的官方文档的说明,我们只需关注ptr就可以了,那么我们就定位到了1560行。但是在想从1560行往下走就走不到了(无论是搜索关键字,符号断点都无法定位)。仔细观察后发现是通过zone这个对象中calloc的方法返回的,这时我们可以通过LLDB命令 po zone->calloc进行查看,返回的结果就是实际调用。
    (这个zone->calloc其实可以理解成是一个赋值语句,从这个zone->calloc中获取到相关的函数去执行,当搜索 “=zone->calloc”关键字时,会有好多类似的语句,都是用于从获取赋值的)

    图片.png

    图片.png

  • 我们搜索default_zone_calloc()找到位置发现又调用了zone这个对象中calloc的方法,我们继续po它得到结果。

    图片.png

    图片.png

    图片.png

  • 我们再寻找nano_malloc.c文件的878行,根据分析我们可以分析出return p 是正确的路线,p是通过_nano_malloc_check_clear()函数返回的,我们继续就探索下去。

    图片.png

    • 进到_nano_malloc_check_clear()我们可以将复杂的方法简单化处理下,先将不重要的判断隐藏掉。
      图片.png
  • 思路分析:

    • *ptr从堆区开辟空间,如果ptr没有,就循环进行查找。segregated_next_block()函数大家可以自己看一下,内部是一个while死循环,我这里不做过多介绍;(额……这里还是啰嗦一下吧,这个函数的功能就是在堆区不断的进行查找,找到合适的位置就分配存储地址,因为堆存储是不是按序的,数据之间存在不规则的空隙,所以需要不断的循环来进行处理)
    • 实际上由于*ptr是新开辟的,所以最终还是会走到segregated_next_block()这步,并将上面算好的slot_bytes大小传递过来进行开辟工作。
    • 那么具体大小就是根据segregated_size_to_fit()函数进行处理的了,我们可以追踪进去。
  • 追踪到segregated_size_to_fit()后我们就看到了NANO_REGIME_QUANTA_SIZE宏定义,追踪进去查看发现是让1左移了4位也就是16,最后再通过公式来进行对齐运算。

    //16字节对齐公式:
    k = (size + NANO_REGIME_QUANTA_SIZE - 1) >> SHIFT_NANO_QUANTUM\
    slot_bytes = k << SHIFT_NANO_QUANTUM;
    
    图片.png

    图片.png
  • 算法解析:

    • NANO_REGIME_QUANTA_SIZE 的值是16
    • 假如 size=7;((7+15)>>4)<<4 ;(22>>4)<<4 ;0001 0110 >> 4 = 0000 0001 ; 0000 0001 << 4 = 0001 0000(16)
    • 假如 size=32;((32+15)>>4)<<4 ;(47>>4)<<4 ;0010 1111 >> 4 = 0000 0010 ; 0000 0010 << 4 = 0010 0000(32)
    • 实际可以替换为:slot_bytes = (size + NANO_REGIME_QUANTA_SIZE - 1) & ~ SHIFT_NANO_QUANTUM
  • 到此就知道了用malloc_size()打印对象是48的原因了,因为进行了16字节对齐。

7、对象内部对齐与结构体内部对齐的差别与意义

  • 对象中成员变量(结构体内部)采用8字节对齐;
  • 对象与对象在堆内存中采用16字节对齐;
  • 为何不考虑都是用8字节对齐?
    • 原因1:拉伸对象与对象直接的内存空隙,有效降低野指针内存访问带来的问题。
    • 原因2:由于我们的类都是继承于NSObject,所以每个类默认都会包含一个8字节的isa属性,如果随便增加1个变量就已经超过8字节(也就是最少也是16字节起步),所以苹果索性就按16字节进行对齐处理降低运算次数。

8、总结

  • 通过了解LLVM对alloc的优化处理,我们探究了callAlloc调用2次的原因,以及调用的流程;
  • 对象中的属性、成员变量是唯一影响大小的因素;
  • 对象内部属性、成员变量是已8字节进行对齐处理;
  • 记住结构体内部对齐的三个原则;
  • 对象在堆内存中是以16字节进行对齐的;
  • 要理解对象内部对齐与结构体内部对齐的差别与意义;
注:
  • Clang: Clang 是一个C语言C++Objective-C语言的轻量级编译器。源代码发布于BSD协议下。Clang将支持其普通lambda表达式、返回类型的简化处理以及更好的处理constexpr关键字。
  • LLVM: LLVM是构架编译器(compiler)的框架系统,以C++编写而成,用于优化以任意程序语言编写的程序的编译时间(compile-time)、链接时间(link-time)、运行时间(run-time)以及空闲时间(idle-time),对开发者保持开放,并兼容已有脚本。
写到最后
  • 到此本篇内容以及结束!如果您喜欢的话别忘了赏个赞!您的点赞是我最大的动力源泉!
导航:
  • 上一篇:《Objective-C 底层对象研究-上》 下一篇:待续.....

你可能感兴趣的:(Objective-C 底层对象探究-中)