『底层探索』1 - 探究 Alloc Process
0 Comments没有对象怎么办?new 一个,在 Objective-C 中我们可以通过 alloc 或 new 创建一个对象,那么问题来了?它底层是怎么实现的呢?
overview
在 iOS 开发中,当我们创建一个类的实例的时候,会不假思索的使用以下两种来创建对象。
1 | [XXObject alloc] init]; |
question
先来个思考题,对于如下的代码,输出结果与你分析的一样么?
1 | RDPerson *p1 = [RDPerson alloc]; |
Log 语句打印的第一个元素是变量指向的对象,第二个元素是指向对象的内存地址,第三个是元素变量的内存地址。
其中 RDPerson 为继承于 NSObject 的一个类,目前没有任何属性和方法。
可能你对 %@
%p
的格式含义不清楚。
- %@ 是打印指定对象的 description 属性,一般是一个字符串。
- %p 是以 0x 开头,打印出指针的值,也就是内存地址。
- 更多可以参考官网文档对 String Format Specifiers 的描述。
Log 是一个宏定义,是对 printf 函数的封装。
1 |
从左往右,第三列是打印变量 p1, p2, p3 的内存地址,肯定是不相同的。那么第一列和第二列的打印内容是否是相同的?或者不同的呢?记一下你现在的思考答案。
代码的运行结果出来了,看有没有出乎你所料。
1 | <RDPerson: 0x10123c230> -- 0x10123c230 -- 0x7ffeefbff528 |
根据打印结果可以看出,变量 p1, p2, p3 中存储的都是 RDPerson 对象的内存地址,也就是它们指向了同一个对象。
这三个变量的地址是依次递减的,也印证了之前说的,变量是存储在栈上,栈底是高地址,栈顶是低地址,它们是由高到低进行分布的。
alloc 的流程是什么样的? init 又做了啥?通过 Apple 开源的 runtime 源码 objc4, 我们可以一探究竟,本文参考的是 objc-781 版本。
alloc
在 NSObject.mm
文件中,我们可以找到 alloc
的代码实现
1 | + (id)alloc { |
跟着这个调用路径,我们可以挖掘出如下调用流程:
1 | 1 _objc_rootAlloc(self) |
比较关键的方法是 _class_createInstanceFromZone
。 在这个方法中,完成了内存空间计算、内存申请、对象关联等操作,完整代码如下:
1 | _class_createInstanceFromZone(Class cls, size_t extraBytes, void *zone, |
fastpath 和 slowpath 是啥?找找声明的地方。
1 |
这是两个宏定义。__builtin_expect
又是什么呢?通过查询 gcc 资料得知,__builtin_expect
是 gcc 编译器的内置函数,用于程序员将分支预测信息告诉编译器,从而让编译器优化我们的代码,提高指令跳转性能。在底层我们知道,分支语句都是通过 jmp 跳转指令来实现的,jmp 要跳转的指令地址越近,做的计算越少,则性能越好。
内存空间计算
第一步,对象占用内存的空间是怎么计算的呢?这引起了我的兴趣。
1 | size_t instanceSize(size_t extraBytes) const { |
这里计算的内存空间至少是 16 字节的。size_t 是啥?
1 | typedef __SIZE_TYPE__ size_t; |
1 | #ifndef __SIZE_TYPE__ |
在 stddef.h
文件中可以找到 \_\_SIZE_TYPE__
的宏定义,因此 size_t 类型也就是 long unsigned int 的 typedef。
接着看看 cache.fastInstanceSize 做了啥?
1 | size_t fastInstanceSize(size_t extra) const |
__builtin_constant_p 是 gcc 内置函数,用于判断是否是编译器常量。
解开神秘计算的最后一道面纱了,align16 函数。
1 | static inline size_t align16(size_t x) { |
看上去一眼懵逼,知道是个计算,啥意思,笨办法,代入 x 一个值,看看啥效果。这里假如 x = 9,别问为啥是 9,我的幸运数字。当然你可以随便选个数字。
1 | 0000 0000 0000 0000 0000 0000 0000 1001 // 9 用 32 位二进制表示 |
我们可以得出一个结论,align16 的作用是以 16 为基准,进行向上取整。小于 16 的数字,取 16,大于 16 小于 32 的数字取 32,以此类推。
为什么要这么处理呢?这就涉及到了内存字节对齐的知识了。感兴趣的可以查查为什么内存要字节对齐呢。
内存申请
第二步中,calloc 是 c 标准库函数,void *calloc(size_t __count, size_t __size)
分配内存空间,并返回一个指向它的指针。其中第一个参数是要被分配的元素的个数,第二个参数是元素的大小。
初始化 isa
第三步,initIsa 是设置生成对象 obj 的 isa。通过源码,发现最终调用的是这个函数。
1 | inline void |
根据前面的分析,我们知道 shiftcls
存储的是对象所属的类对象的地址。在这里给 shiftcls
赋值的时候,为什么 cls 要右移三位呢?
很多人估计想的是因为 isa 前三位表示的是 nonpointer
, has_assoc
, has_cxx_dtor
所以要避开这三位存储。这么想的同学,你忽略了 isa 的结构,isa 是联合体位域,给位域的元素赋值,就是给位域中的指定位赋值。
这里真正的原因是: 类的指针要按照字节 (8bits) 内存对齐,所以任何对象的后三位都是 0,右移三位的原因是舍弃无意义数据,减小内存的的消耗。不信的同学,你可以多创建一个对象,然后观察对象的地址,看看是不是有这样的规律。
其中 uintptr_t 和 isa_t 的定义如下:
1 | typedef unsigned long uintptr_t; |
1 | //isa_t 定义 联合 |
这里的 struct 和 ISA_BITFIELD 是定义了一个位域的数据结构,后面的文章会详细说明 isa 的结构的。
initIsa
方法根据是否是 nonpointer 走不同的 isa 生成逻辑。如果不是 nonpointer 的话,isa 直接赋值为 cls 的地址。 这下真相大白了。
new
通过 new 创建对象的流程是咋样的呢?在源码面前了无秘密。
1 | + (id)new { |
所以使用 new
就相当于 [[XXX alloc] init]
。如果子类的实现中重写了 init
方法,new 会调用子类的 init 方法。没有重写则调用父类方法。
如果你有携带初始化属性的 initWithXXX
初始化方法,使用 new
来创建对象则是不是调用这个方法的。
init
init 的作用是啥呢?字面理解是初始化数据,看看源码是否如我们猜想的?
1 | + (id)init { |
居然什么也没做,仅仅返回了 self 而已,这是为什么呢?这是一个不错的思考题。
sum-up
通过一幅图,我们可以总结 alloc 的调用流程。
reference
这是我写这篇文章时用到的资料,感兴趣可以看看。
我是穆哥,卖码维生的一朵小浪花,我们下期见。
文章作者:muhlenXi
原始链接:blog.xiyinjun.com/2020/09/05/072-oc-alloc/
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 CN 许可协议。转载请注明出处!