「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!」前端
学习不迷茫,无阻我飞扬!你们好我是Tommy!今天咱们继续来对底层进行探索,本章内容会比较多,里面的可能有些知识不太好理解,你们能够分小节进行阅读。废话不说咱们这就开始!git
alloc
的运行流程进行了梳理,但这里存在一个问题不知道你们是否发现了?就是咱们经过符号断点等方式发现,alloc最早是调用了objc_alloc
方法后再开始走调用流程的;(动态分析)
alloc
调用的并非objc_alloc
而是_objc_rootAlloc
函数(静态分析)
,这又是什么缘由呢? SEL
和IMP
,SEL
就是方法标示,IMP
就是指向方法具体实现的指针,就比如一本书的目录同样,你须要先查到目录的条目以后再根据对应的页码找到具体内容。OC是动态语言SEL
和IMP
是能够进行动态改变的,因此alloc
是存在被改变可能性的。objc_alloc
看看是否有结果......runtime
中alloc
的IMP
的的确确是被替换了,这个已经证实咱们分析的思路是正确的;fixupMessageRef
函数是在何时被调用的?继续经过搜索来得出答案。fixupMessageRef
函数是在_read_images
这个函数中被调用的。_read_images
方法上面的注释:“对连接中的头信息执行初始化处理”,应该能够猜到_read_images
方法可能与DYLD加载Mach-O文件有必定关系。咱们能够给map_images_nolock
下个符号断点,为啥呢?由于_read_images
我测试了没法断住,根据方法上面的注释得知是经过map_images_nolock
这个函数调用的,因此果断试了一下能够断住。alloc
确实是在runtime
的源码中有被替换的迹象;fixupMessageRef
这个方法名称,咱们能够理解程序在运行时,须要对alloc
等一些方法进行修复处理;那咱们是否是能够理解成:无论当前是否存在问题,alloc
方法始终都会被改动调用objc_alloc
;fixupMessageRef
方法是在_read_images
中被调用的,而_read_images
是在DYLD加载Mach-O文件时进行加载的;Mach-O文件中会存在一个叫作符号列表的内容,里面就会将App的方法存放到此表中,当DYLD加载时就会读取列表进行映射操做,而这个过程就叫作符号绑定(如今能够先这么简单的理解)objc_alloc
,若是存在问题就经过fixupMessageRef
方法进行修复处理,而处理结果依然是调用objc_alloc
,这一点须要你们细品一下。 若是以上思路都明确以后,咱们应该会想到alloc方法在运行时作的只是修复工做,那么其实真正对alloc方法进行修改的并非在运行时,实际上可能仍是在更底层进行修改的,而只是在runtime层增长了修复的逻辑,极可能是苹果出于严谨性的考虑,在这一步额外增长的一层保护(多是为了防止开发人员经过hook等方式对alloc方法进行修改吧!~)。LLVM-project
这里是连接[LLVM-project下载],建议使用VSCode
进行打开。objc_alloc
看看有什么结果,咱们点击第一个结果就能发现这些线索;“当此方法返回true时,Clang将把某些选择器的非超级消息发送转换为对相应入口点的调用”,经过这条注释以及下面的alloc => objc_alloc
例子咱们就能够明白了,在编译阶段alloc
就已经被进行了转换设置。shouldUseRuntimeFunctionsForAlloc
函数看看调用逻辑,发现是在tryGenerateSpecializedMessageSend
函数中进行调用的。tryGenerateSpecializedMessageSend
函数查看调用逻辑,搜索后咱们来到了GeneratePossiblySpecializedMessageSend
函数。就是当alloc()第一次执行时,被LLVM按特殊消息发送来处理了,底层将目标转换成了objc_alloc();objc_alloc执行后第一次调用了callAlloc();github
首次进入callAlloc()后去执行objc_msgSend的方法,又再一次调用了alloc(),可是此次LLVM是按正常方式进行处理,发送给了_objc_rootAlloc();_objc_rootAlloc()执行后第二次调用了callAlloc();而后开始对内存进行对象内存的开辟工做直至完成。web
alloc
流程图,在这幅图中咱们当时发现callAlloc()
被执行了2次,那么咱们将咱们今天探索获得的结果,添加到这幅流程图中进行补完,你们能够对比看一下就能了解callAlloc
为何会被调用了2次
的真正缘由了。tryGenerateSpecializedMessageSend
函数中对alloc
方法处理为例子,一步一步跟踪,最终咱们走到了下面图片所示的位置;经过上下传参最终会经过Builder.CreateCall()
跟Builder.CreateInvoke()
进行函数的指令调用;HOOK
方式的处理,这里猜想应该是对这些方法进行了一些监测和监控处理。到此本小节结束。查看对象占用内存的大小:objective-c
ZXPerson
的对象在内存中占用了多少空间,咱们能够经过class_getInstanceSize()
方法打印大小,使用此方法时请导入 #import <objc/runtime.h>
头文件。编译运行后显示了占用大小。发现影响大小的因素:算法
class_getInstanceSize()方法:后端
Command+shift+O
搜索class_getInstanceSize
直接就能够定位到。没有变量时打印为何是8?:缓存
class_getInstanceSize()
方法打印结果是8
,这也就说明咱们必定从父类中继承过来了成员变量,咱们再经过源码进行验证。NSObject
,就会看到父类中存在一个变量叫作isa
;那么第一个疑问就解开了,确实从父类中继承了变量过来;那么大小为何是8
呢?咱们继续分析。isa
的类型是Class
,咱们跟踪一下看看有什么结果,Command+shift+O
搜索Class
,发现Class是一个类型定义,实际是objc_class
类型的指针类型,而在arm64
下一个指针正好是占用8
个字节。
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
结构体类型。 ps:这么作的目的是苹果为了在底层对开发人员定义的类进行统一处理而进行了转换,由于苹果不可能在底层去逐一的去实现开发人员定义的类,这是不可能定义出来的,由于可变性太大了;因此为了方便对类进行管理和操做,就必须设计一个通用的类型来替代。
markdown
通源码探究咱们发现Object-C
的底层都是经过C/C++
来实现的,因此OC
中的对象也会转化成C/C++
中的某一个数据结构,到此本小结结束。数据结构
Object-C
的底层都是经过C/C++
来实现的,因此OC
中的对象也会转化成C/C++
中的某一个数据结构。_class_createInstanceFromZone()
里找到instanceSize()
,经过上一篇的探索咱们已经得知了,该方法是负责返回对象所需的空间大小的;咱们跟踪进去能够看到优先从缓存中查找大小,若是缓存没有就从新计算大小,最后还有一个判断就是若是计算的大小不足16字节
,就补足16字节
。 alignedInstanceSize()
方法中我看能够看到底层系统将对象占用的内存大小进行了字节对齐,我看经过word_align()
了解具体对齐算法。 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
方式对齐。这么作虽然牺牲了必定的内存空间,可是读取的效率会大幅提高,也就是用 “空间换时间”。NSObject
里集成了isa
属性占用8
字节;instanceSize()
得知对象内部结构是已8
字节进行对齐,但系统是最小给分配了16
字节;(x + WORD_MASK) & ~WORD_MASK
方式进行计算;8
字节对齐?这是由于在arm64
下,8
字节基本上就是最大的占用字节数了。16
字节会怎么样?其实在最后底层还会以16
字节进行一次对齐处理,请看下一个小节内容结构体内存对齐。x/4gx
查看了类对象中在内存中的存放状态,其中咱们发现了一个现象就是一个8字节的空间里面存放了2个不一样的数据,这种现象就叫作内存对齐而且作了相关优化处理。当咱们建立一个对象指针时,该指针实际指向的是一个结构体类型,那么对于结构体来讲内存大小这块是否有什么不同?下面就让咱们来一块儿探究一番。ZXStruct3
的第一个成员占用到第 3
个字节位置,根据 原则2
应按照结构内部最大元素的大小的整数倍开始存储,因此从 8
开始;而后再用 8 + zx_t1
大小,就能够直接得出实际大小了也就是 8 + 24 = 32
。原则2
对齐,最后加上嵌套结构体就是最终的大小结果。ZXStruct1
前三个成员为例,将3个成员放大来观察。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 |
首先咱们先来看一个现象,我对ZXPerson
类的对象*zxp
分别经过class_getInstanceSize()
、sizeof()
、malloc_size()
、3
个函数进行打印输出;
此时咱们ZXPerson类中定义了4个属性再加上隐藏属性isa,一共是5个属
class_getInstanceSize()
打印了32
, 这个没有问题(8+8+8+4+1 最后按8字节对齐 = 32)
;sizeof()
打印了8
,这个没有问题(由于打印的是指针,指针的大小就是8占字节)
;malloc_size()
打印了32
,跟class_getInstanceSize()
同样,貌似也应该没有问题;此时咱们ZXPerson类中新增一个属性zxNikeName再来看看结果。
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
这个库下面。咱们就能够再经过源码方式来进行研究了(往后咱们探究的思路都是以这个方式来进行的)
。
在探索源码前咱们还能够去苹果官网搜索这个函数的官方解释 malloc_size
的苹果官网解释: “返回ptr所指向的分配的内存块的大小。内存块的大小老是至少和它的分配同样大,也可能会更大”
,经过官方的解释咱们就能理解咱们如今遇到的这个现象了吧,现象就是返回的大小可能跟实际分配的一致或更大。那么接下来,咱们带着这个问题来开始源码的探索。
下载libmalloc
可编译的源码:下载libmalloc可编译的源码
在上一篇文章中咱们已经对alloc
的开辟流程进行了梳理,发现 alloc
申请内存是 calloc
发起的,因此咱们直接把断点断到calloc
上。对于这块不清楚的同窗请走传送门 《Objective-C 底层对象研究-上》
咱们将断点断在calloc
上,来跟踪内存开辟的机制,编译-运行后咱们进入到了calloc
里,这只是一个封装函数,继续跟踪_malloc_zone_calloc()
。
进来后咱们能够观察一下,根据上面的官方文档的说明,咱们只需关注ptr
就能够了,那么咱们就定位到了1560
行。可是在想从1560
行往下走就走不到了(不管是搜索关键字,符号断点都没法定位)
。仔细观察后发现是经过zone
这个对象中calloc
的方法返回的,这时咱们能够经过LLDB
命令 po zone->calloc
进行查看,返回的结果就是实际调用。 (这个zone->calloc其实能够理解成是一个赋值语句,从这个zone->calloc中获取到相关的函数去执行,当搜索 “=zone->calloc”关键字时,会有好多相似的语句,都是用于从获取赋值的)
咱们搜索default_zone_calloc()
找到位置发现又调用了zone
这个对象中calloc
的方法,咱们继续po
它获得结果。
咱们再寻找nano_malloc.c
文件的878
行,根据分析咱们能够分析出return p
是正确的路线,p
是经过_nano_malloc_check_clear()
函数返回的,咱们继续就探索下去。
_nano_malloc_check_clear()
咱们能够将复杂的方法简单化处理下,先将不重要的判断隐藏掉。思路分析:
*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;
复制代码
算法解析:
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
字节对齐?
NSObject
,因此每一个类默认都会包含一个8
字节的isa
属性,若是随便增长1
个变量就已经超过8
字节(也就是最少也是16
字节起步),因此苹果索性就按16
字节进行对齐处理下降运算次数。C语言
、C++
、Objective-C
语言的轻量级编译器。源代码发布于BSD
协议下。Clang
将支持其普通lambda
表达式、返回类型的简化处理以及更好的处理constexpr
关键字。LLVM
是构架编译器(compiler)的框架系统,以C++
编写而成,用于优化以任意程序语言编写的程序的编译时间(compile-time)、连接时间(link-time)、运行时间(run-time)以及空闲时间(idle-time),对开发者保持开放,并兼容已有脚本。