alloc作用及使用
在日常的开发中,alloc是我们频繁使用的一个词汇,在我的印象中alloc是创建对象必须写的,甚至写alloc都已经变成了肌肉记忆,但其实它的内部操作并不是表面这样简简单单的,今天就探索一下alloc到底都做了什么事情。
底层探索
首先,准备一份很朴素的一个Demo,朴素的一个App工程,朴素的一个ZPerson类,还有ViewController.m中几行朴素的代码:
这里首先
alloc创建了一个对象person1,之后分别用%@、%p打印了person1,并用%p打印了&person1,发现了前两个值几乎都是一样的,只不过%@以<类名: 地址>输出,同时也可以发现经过了指针赋值的person2或者是执行init之后的person3,他们的%@和%p输出都是一样的。但是输出这三者的取地址时发现都是不一样的。
%@和%p的区别:
%@是调用消息接收者的- (NSString *)description方法;%p是以地址的形式输出该变量;- 不管是
person1-3哪一个指针变量他们都存储了Person对象的地址,所以输出的时候会调用Person的description方法以及输出Person对应的地址; - 而以取地址符
&输出的时候是输出了存储Person地址的指针变量的地址,所以值是不一样的,而且指针变量都是存储在栈区(一般地址以0x7开头); - 同时也可以发现
init方法并没有对Person的地址进行处理;
那么到底alloc和init在内部做了什么呢,下面让我们探索下康康~
三种探索方式
既然要探索内部操作,那就需要看看alloc方法内部是怎么实现的,但是当我们command点进去之后就止步于此了:
下面有三种方式可以再深一步看到
alloc之后的去处:
1.符号断点alloc,step into进入下一步
首先,将断点打在alloc的调用处,之后断点如图,按住control左键点击step into就会进入汇编页面,看到具体的调用:
如上图所示,这时我们可以看到调用的是objc_alloc(记住是objc_alloc哦),此时就可以根据objc_alloc加一个符号断点来看看调用情况了:
再次运行就可以发现原来
objc_alloc在libobjc.dylib动态库中,此时的目标就是去寻找libobjc动态库代码了:
2.断点alloc查看汇编调用
同样是断点断住alloc方法调用处,并且点击Debug->Debug Workflow->Always Show Disassembly
同样是可以发现会去调用
objc_alloc方法,后面的步骤依然符号断点objc_alloc查看这个方法具体在哪个位置或是哪个库中;
3.直接符号断点alloc
除了上述两种方式外,还要一种简单粗暴的方法,因为我们的关注点就在alloc上面,那么我们就可以直接符号断点alloc寻找线索(锅里搞一波):
直接定位到了libobjc.dylib动态库,由于我们调用的alloc方法都是NSObject的,所以这里会显示出[NSObject alloc];
源代码调试
同样还有一种比较舒服的方式,就是下载objc源码,并且编译进行调试,相关内容推荐KC靓仔的内容,实际效果如下图:
alloc流程详解
目前进入到源码工程中,并在工程中新建一个target,在main.m写个alloc并且点击进去看下,会跳到_objc_rootAlloc函数位置:
但是我们实际运行一次之后,好像发现并不是这样的- -
左侧堆栈显示是先走到
objc_alloc之后再走到_objc_alloc的,同时我们在 三种探索方式 看到显示的方法也是objc_alloc,那么为什么点击alloc方法会跳转到_objc_rootAlloc呢?
llvm优化
在工程内全局搜索objc_alloc,如下图:
原来是在这里进行了Hook操作,将alloc的IMP指向了objc_alloc,那么什么时候又调回了_objc_rootAlloc呢?
当第一次调用
objc_alloc进来的时候由于条件不满足不会进入前面的if语句,而是走到了objc_msgSend(cls, @selector(alloc))这句,那么再下次进来的时候就会正常进入。内部是由于llvm编译时会对alloc等关键方法进行插桩Hook,当在执行alloc时候先走到objc_alloc方法进行一些处理并且对消息接收者打上标记,那么下次再执行alloc就会判断到已经标记过了从而执行;_objc_rootAlloc。
流程分析
目前我们已经知道了调用alloc之后会经过objc_alloc再到_objc_rootAlloc,之后其内部会经过一些中间层方法(中间层是一种工厂设计思想,保留上层代码稳定性供开发者使用,下层代码不断优化更新),具体流程如下:
由于源码比较庞大并且代码走向分支也比较多,所以当探索源码时候尽可能的明确自己的探索目的,忽略一些语义上看起来和目的关系不大的代码(不然真的会子子孙孙,无穷匮也),所以我们定位到
_class_createInstanceFromZone方法:
首先进入instanceSize方法:
上图将开辟内存的大小进行了一个8字节对齐的算法,例如:4对齐之后为8,15对齐之后为16,30对齐之后为32,这个对齐算法等同于(x+WORD_MASK) >> 3 << 3,先右移3位去掉低位的数字,再左移三位保留回高位的数字,相当于对(x+WORD_MASK)的向下取整,对x的向上取整;
//假设x=8
//WORD_MASK=7可在源码中查看
(8 + 7) & ~7
(15) & ~7
7: 0000 0111
~7: 1111 1000
15: 0000 1111
15 & ~7: 0000 1000 = 8
15 >> 3: 0000 0001
15 >> 3 << 3: 0000 1000 = 8
为什么要对8字节字节呢?
- 安全性:如果不对齐的话,那么意味着各个内存的中的数据很有可能会紧挨着,如上图,如果在首地址访问了6个字节那么就会访问到有可能不属于自己的内存,所以进行了8字节对齐,同时
8字节对齐是在结构体的开辟大小基础上执行的; - 执行效率:系统每次都会去内存中根据首地址和内存大小读取数据,如果每次读取数据时内存大小都会变化的就会降低读取的效率,所以对开辟内存大小进行
8字节对齐,同时在arm64架构中每个指针占8字节,并且每个类都会有其isa指针,这也是为什么8字节对齐的原因;
经过calloc就会开辟size大小的内存空间,并且将首地址返回存入obj中;
可以看到在calloc执行前后obj的地址是发生了变化的,所以calloc是将开辟后的内存首地址返回了过来;
在执行了obj->initInstanceIsa()之后再次进行修改输出obj就会将具体的类名输出出来,可以看出这是绑定obj和cls的一步;
init&new原理
可以看到init的流程为init->_objc_rootInit,但是在_objc_rootInit中直接返回了obj对象,没有进行任何处理,这是为了让子类去进行重写操作,进行写自定义的处理;
new方法内部是先调用callAlloc创建内存并绑定cls,之后同样是进行了上面的init方法,所以new=[alloc init],这也解释了开头的例子中为什么init没有影响到对象的地址;
有关对象大小如何计算,以及isa指针相关内容后续补充更新;