Objective-C 底层对象探究-中

「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!前端

目录

1. 背景

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

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函数是在何时被调用的?继续经过搜索来得出答案。
    图片.png
    • 通过排查找到了fixupMessageRef函数是在_read_images这个函数中被调用的。
    • 再看_read_images方法上面的注释:“对连接中的头信息执行初始化处理”,应该能够猜到_read_images方法可能与DYLD加载Mach-O文件有必定关系。咱们能够给map_images_nolock下个符号断点,为啥呢?由于_read_images我测试了没法断住,根据方法上面的注释得知是经过map_images_nolock这个函数调用的,因此果断试了一下能够断住。
    图片.png 图片.png
    • 经过符号断点验证了咱们的想法的的确确是由dyld进行调用的。到此咱们能够先作一个简单的梳理:
  • 思路梳理:
    • 一、经过分析确认了alloc确实是在runtime的源码中有被替换的迹象;
    • 二、经过fixupMessageRef这个方法名称,咱们能够理解程序在运行时,须要对alloc等一些方法进行修复处理;那咱们是否是能够理解成:无论当前是否存在问题,alloc方法始终都会被改动调用objc_alloc
    • 三、fixupMessageRef方法是在_read_images中被调用的,而_read_images是在DYLD加载Mach-O文件时进行加载的;Mach-O文件中会存在一个叫作符号列表的内容,里面就会将App的方法存放到此表中,当DYLD加载时就会读取列表进行映射操做,而这个过程就叫作符号绑定(如今能够先这么简单的理解)
    图片.png
    • 五、经过以上分析咱们能够得知,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就已经被进行了转换设置。
    图片.png
    • 咱们继续搜索shouldUseRuntimeFunctionsForAlloc函数看看调用逻辑,发现是在tryGenerateSpecializedMessageSend函数中进行调用的。
    图片.png
    • 再搜索tryGenerateSpecializedMessageSend函数查看调用逻辑,搜索后咱们来到了GeneratePossiblySpecializedMessageSend函数。
    图片.png
    • 从代码咱们能够简要的看出,当发送消息时会先判断是否符合发送特殊消息的条件,若是符合就尝试经过特殊方式发送,若是不知足就按正常流程发送消息。按照这个逻辑就能得出一个结论了:
  • 小结论:
    • 就是当alloc()第一次执行时,被LLVM按特殊消息发送来处理了,底层将目标转换成了objc_alloc();objc_alloc执行后第一次调用了callAlloc();github

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

  • 再次梳理alloc流程:
    • 我在上篇《Objective-C 底层对象研究-上》中画过一个alloc流程图,在这幅图中咱们当时发现callAlloc()被执行了2次,那么咱们将咱们今天探索获得的结果,添加到这幅流程图中进行补完,你们能够对比看一下就能了解callAlloc为何会被调用了2次的真正缘由了。
    图片.png 图片.png
    • 接下来咱们能够在深刻一点,查看一下底层是如何处理函数调用的,咱们能够经过tryGenerateSpecializedMessageSend函数中对alloc方法处理为例子,一步一步跟踪,最终咱们走到了下面图片所示的位置;经过上下传参最终会经过Builder.CreateCall()Builder.CreateInvoke()进行函数的指令调用;
    图片.png 图片.png
    • 经过对底层LLVM的探索,咱们能够发现苹果对一些重要方法,尤为是跟内存有关的方法都进行了相似HOOK方式的处理,这里猜想应该是对这些方法进行了一些监测和监控处理。到此本小节结束。

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

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

    • 咱们接下来探索一下对象在内存中的大小,每一个对象都是在执行alloc后都会开辟出内存空间;咱们来看一下ZXPerson的对象在内存中占用了多少空间,咱们能够经过class_getInstanceSize()方法打印大小,使用此方法时请导入 #import <objc/runtime.h>头文件。编译运行后显示了占用大小。

    图片.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:这么作的目的是苹果为了在底层对开发人员定义的类进行统一处理而进行了转换,由于苹果不可能在底层去逐一的去实现开发人员定义的类,这是不可能定义出来的,由于可变性太大了;因此为了方便对类进行管理和操做,就必须设计一个通用的类型来替代。markdown

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

四、字节对齐

  • 经过上一节的研究,咱们得知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字节进行一次对齐处理,请看下一个小节内容结构体内存对齐

五、结构体内存对齐

  • 在上一篇咱们经过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

六、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字节对齐。

七、对象内部对齐与结构体内部对齐的差异与意义

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

八、总结

  • 经过了解LLVM对alloc的优化处理,咱们探究了callAlloc调用2次的缘由,以及调用的流程;
  • 对象中的属性、成员变量是惟一影响大小的因素;
  • 对象内部属性、成员变量是已8字节进行对齐处理;
  • 记住结构体内部对齐的三个原则;
  • 对象在堆内存中是以16字节进行对齐的;
  • 要理解对象内部对齐与结构体内部对齐的差异与意义;
注:
写到最后
导航:
相关文章
相关标签/搜索