目录
- 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
函数(静态分析)
,这又是什么原因呢?
- 我们这里不如大胆猜测一下,OC里面的方法调用都离不开两个东西
SEL
和IMP
,SEL
就是方法标示,IMP
就是指向方法具体实现的指针,就好比一本书的目录一样,你需要先查到目录的条目之后再根据对应的页码找到具体内容。OC是动态语言SEL
和IMP
是可以进行动态改变的,所以alloc
是存在被改变可能性的。
- 通过上篇《Objective-C 底层对象研究-上》我们已经对
-
探索调用 objc_alloc 的原因:
- 经过我们的分析我们已经有了大致思路,那么我们就用过研究源码来验证我们的分析是否正确。
- 首先我们先通过搜索
objc_alloc
看看是否有结果......
- 哈哈!发现搜索出来的内容还是挺多的,但是不要怕,经过我的逐一排查我定位到了这里(红框处)。
- 从这段代码我们就很明显的发现了在
runtime
中alloc
的IMP
的的确确是被替换了,这个已经证明我们分析的思路是正确的;
- 那么我们继续看一下这个
fixupMessageRef
函数是在什么时候被调用的?继续通过搜索来得出答案。
[图片上传失败...(image-f21061-1625212644421)] - 经过排查找到了
fixupMessageRef
函数是在_read_images
这个函数中被调用的。 - 再看
_read_images
方法上面的注释:“对链接中的头信息执行初始化处理”,应该可以猜到_read_images
方法可能与DYLD加载Mach-O文件有一定关系。我们可以给map_images_nolock
下个符号断点,为啥呢?因为_read_images
我测试了无法断住,根据方法上面的注释得知是通过map_images_nolock
这个函数调用的,所以果断试了一下可以断住。
- 通过符号断点验证了我们的想法的的确确是由dyld进行调用的。到此我们可以先做一个简单的梳理:
-
思路梳理:
- 1、通过分析确认了
alloc
确实是在runtime
的源码中有被替换的迹象; - 2、通过
fixupMessageRef
这个方法名称,我们可以理解程序在运行时,需要对alloc
等一些方法进行修复处理;那我们是不是可以理解成:不管当前是否存在问题,alloc
方法始终都会被改动调用objc_alloc
; - 3、
fixupMessageRef
方法是在_read_images
中被调用的,而_read_images
是在DYLD加载Mach-O文件时进行加载的;Mach-O文件中会存在一个叫做符号列表的内容,里面就会将App的方法存放到此表中,当DYLD加载时就会读取列表进行映射操作,而这个过程就叫做符号绑定(现在可以先这么简单的理解)
- 5、通过以上分析我们可以得知,alloc方法在运行时会被进行检测,如果检测没有问题它依然还是调用
objc_alloc
,如果存在问题就通过fixupMessageRef
方法进行修复处理,而处理结果依然是调用objc_alloc
,这一点需要大家细品一下。 如果以上思路都明确之后,我们应该会想到alloc方法在运行时做的只是修复工作,那么其实真正对alloc方法进行修改的并不是在运行时,实际上可能还是在更底层进行修改的,而只是在runtime层增加了修复的逻辑,很可能是苹果出于严谨性的考虑,在这一步额外增加的一层保护(可能是为了防止开发人员通过hook等方式对alloc方法进行修改吧!~)。
- 1、通过分析确认了
-
在LLVM中探索原因:
- 想要探索LLVM我们需要下载
LLVM-project
这里是链接[LLVM-project下载],建议使用VSCode
进行打开。 - 下载完毕之后试试搜索
objc_alloc
看看有什么结果,我们点击第一个结果就能发现这些线索;“当此方法返回true时,Clang将把某些选择器的非超级消息发送转换为对相应入口点的调用”,通过这条注释以及下面的alloc => objc_alloc
例子我们就可以明白了,在编译阶段alloc
就已经被进行了转换设置。
[图片上传失败...(image-541a7c-1625212644421)] - 我们继续搜索
shouldUseRuntimeFunctionsForAlloc
函数看看调用逻辑,发现是在tryGenerateSpecializedMessageSend
函数中进行调用的。
- 再搜索
tryGenerateSpecializedMessageSend
函数查看调用逻辑,搜索后我们来到了GeneratePossiblySpecializedMessageSend
函数。
- 从代码我们可以简要的看出,当发送消息时会先判断是否符合发送特殊消息的条件,如果符合就尝试通过特殊方式发送,如果不满足就按正常流程发送消息。按照这个逻辑就能得出一个结论了:
- 想要探索LLVM我们需要下载
-
小结论:
就是当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次
的真正原因了。
- 接下来我们可以在深入一点,查看一下底层是如何处理函数调用的,我们可以通过
tryGenerateSpecializedMessageSend
函数中对alloc
方法处理为例子,一步一步跟踪,最终我们走到了下面图片所示的位置;通过上下传参最终会通过Builder.CreateCall()
跟Builder.CreateInvoke()
进行函数的指令调用;
- 通过对底层LLVM的探索,我们可以发现苹果对一些重要方法,尤其是跟内存有关的方法都进行了类似
HOOK
方式的处理,这里猜测应该是对这些方法进行了一些监测和监控处理。到此本小节结束。
- 我在上篇《Objective-C 底层对象研究-上》中画过一个
3、对象内存大小的影响因素
-
查看对象占用内存的大小:
- 我们接下来探索一下对象在内存中的大小,每个对象都是在执行alloc后都会开辟出内存空间;我们来看一下
ZXPerson
的对象在内存中占用了多少空间,我们可以通过class_getInstanceSize()
方法打印大小,使用此方法时请导入#import
头文件。编译运行后显示了占用大小。
- 我们接下来探索一下对象在内存中的大小,每个对象都是在执行alloc后都会开辟出内存空间;我们来看一下
-
发现影响大小的因素:
-
增加属性和成员变量:我们添加或者删除一下属性和成员变量可以观察到,对象的大小会有不同的不变化,增加时大小会增大,反之亦然;
-
添加方法:属性和变量会影响大小改变,我们也可以试试添加方法是否也会改变大小?答案是并不会。
- 到此我们可以得到一个结论:对象的内存大小是由成员变量决定的,跟其他内容没有关系。
-
-
class_getInstanceSize()方法:
- 我们进入到objc源码
Command+shift+O
搜索class_getInstanceSize
直接就可以定位到。
- 我们一步一步定位到这里给出了明确提示:May be unaligned depending on class's ivars.
- 我们进入到objc源码
-
没有变量时打印为什么是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:这么做的目的是苹果为了在底层对开发人员定义的类进行统一处理而进行了转换,因为苹果不可能在底层去逐一的去实现开发人员定义的类,这是不可能定义出来的,因为可变性太大了;所以为了方便对类进行管理和操作,就必须设计一个通用的类型来替代。
通源码探究我们发现
Object-C
的底层都是通过C/C++
来实现的,所以OC
中的对象也会转化成C/C++
中的某一个数据结构,到此本小结结束。 - 当我们将所有定义的成员变量删除之后,通过
4、字节对齐
通过上一节的研究,我们得知
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
字节进行一次对齐处理,请看下一个小节内容结构体内存对齐。
- 我们定义的类从
5、结构体内存对齐
- 在上一篇我们通过
x/4gx
查看了类对象中在内存中的存放状态,其中我们发现了一个现象就是一个8字节的空间里面存放了2个不同的数据,这种现象就叫做内存对齐并且做了相关优化处理。当我们创建一个对象指针时,该指针实际指向的是一个结构体类型,那么对于结构体来说内存大小这块是否有什么不一样?下面就让我们来一起探究一番。 - 结构体内存的三个原则:
- 结构体内第一个成员以0为起始位置,而后的成员起始位置要从成员的占用大小或子成员的占用大小的整数倍开始;
- 如果内部成员是一个结构体,则结构体成员要从其内部最大元素占用大小的整数倍地址开始存储;
- 构体的总大小,也就是sizeof的结果,必须是其内部最大成员的整数倍.不足的要补⻬;
- 我们可以自己编写2个结构体来进行验证:
-
内部成员声明位置先后不同,得到的大小不同;出现这样的原因就是根依据上面的三个原则而得到的结果,我们先来验证一下非嵌套的结构体。
-
-
测试下带嵌套的结构体,我新建一个ZXStruct3,然后将ZXStruct1声明为内部的一个成员。
- 理解:
-
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
6、malloc的分析探索
-
首先我们先来看一个现象,我对
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
字节对齐。
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 底层对象研究-上》 下一篇:待续.....