前言
作为一名 iOS 程序员,每天忙碌在各个业务线,做过很多功能开发过很多App,却很少有机会去了解 OC底层的一些原理和实现机制。今天就让我们一起去探索alloc的底层流程。在此之前我们做个小测试。下面分别输出对象的内容,对象的地址,以及对象指针的地址代码和打印如下:
XZPerson * obj = [XZPerson alloc];
XZPerson * obj1 = [obj init];
XZPerson * obj2 = [obj init];
XZPerson * newObj = [XZPerson alloc];
NSLog(@"%@---%p--%p",obj,obj,&obj);
NSLog(@"%@---%p--%p",obj1,obj1,&obj1);
NSLog(@"%@---%p--%p",obj2,obj2,&obj2);
NSLog(@"%@---%p--%p",newObj,newObj,&newObj);
<XZPerson: 0x2802e8f60>---0x2802e8f60--0x16b185b58
<XZPerson: 0x2802e8f60>---0x2802e8f60--0x16b185b50
<XZPerson: 0x2802e8f60>---0x2802e8f60--0x16b185b48
<XZPerson: 0x2802e8f30>---0x2802e8f30--0x16b185b40
经过分析可得出以下结论
obj、obj1、obj2打印的内容,对象的地址是一样的,但是对象的指针地址不一样。newObj和obj、obj1、obj2打印的内容,对象的地址,指针的地址都是不一样的。
这就要问一句 why? 原因如下图:
总结:
alloc具有开辟一块内存功能,而init没有开辟内存的功能。栈区开辟的内存是高地址到低地址,堆区则是低地址到高地址。
请让我们一起来探究在初始化对象时,alloc到底做了什么?
准备工作
-
下载源码 objc4-818.2
-
编译源码(请各位看官自行搜索一下 “objc4-818.2源码编译”,我也是参考其他博友的)
下面介绍三种探索底层的方法
1. 符号断点
在需要调试的位置打上断点,当断点断住的时候按住control键,然后step into 进入下一步,然后在汇编里面找到方法,然后给这个方法添加符号断点,加完符号断点,continue 单步到符号断点。具体流程如下图
2. 汇编
运行程序,当执行到断点的时候,Xcode -> Debug -> Debug Workflow -> Always Show Disassembly 进入汇编找到跳转的方法,此时有两种方式,一种找到方法直接添加断点调试。另一种就是断住跳转的地方,按住control键,然后step into 进入下一步,这种stp into 可以一直走,过程比较繁琐,如下图:
3. 直接加符号断点
原则上需要探究哪个方法就添加那个方法的符号断点。(不过需要注意的是,在程序运行之前一定要先关闭符号断点,程序断在断点位置以后再启用符号断点。不然项目中可能有很多用到符号断点的方法,你会很凌乱,找不到目标和方向。。。)
编译源码
根据源码工程,点击进入alloc, 流程图如下
探索流程如下断点进入
alloc
+ (id)alloc {
return _objc_rootAlloc(self);
}
断点进入 _objc_rootAlloc
id _objc_rootAlloc(Class cls)
{
return callAlloc(cls, false/*checkNil*/, true/*allocWithZone*/);
}
断点进入 callAlloc
static ALWAYS_INLINE id
callAlloc(Class cls, bool checkNil, bool allocWithZone=false)
{
#if __OBJC2__ //判断是不是 objc2.0版本
//slowpath(x):x很可能为假,为真的概率很小
//fastpath(x):x很可能为真
//其实将fastpath和slowpath去掉是完全不影响任何功能,写上是告诉编译器对代码进行优化
if (slowpath(checkNil && !cls)) return nil;
//判断该类是否实现自自定义的 +allocWithZone,没有则进入if条件句
if (fastpath(!cls->ISA()->hasCustomAWZ())) {
return _objc_rootAllocWithZone(cls, nil);
}
#endif
// No shortcuts available.
if (allocWithZone) {
return ((id(*)(id, SEL, struct _NSZone *))objc_msgSend)(cls, @selector(allocWithZone:), nil);
}
return ((id(*)(id, SEL))objc_msgSend)(cls, @selector(alloc));
}
断点进入 _objc_rootAllocWithZone
id
_objc_rootAllocWithZone(Class cls, malloc_zone_t *zone __unused)
{
// allocWithZone under __OBJC2__ ignores the zone parameter
return _class_createInstanceFromZone(cls, 0, nil,
OBJECT_CONSTRUCT_CALL_BADALLOC);
}
此时走入真正的核心代码,断点进入 _class_createInstanceFromZone
从流程图和源代码可以看出
_class_createInstanceFromZone 方法中有核心三个方法需要实现
cls->instanceSize: 计算内存大小(id)calloc(1, size): 开辟内存,返回地址指针obj->initInstanceIsa:初始化指针,和类关联起来
下面就对这三个方法重点分析
instanceSize:计算内存大小
断点进入 instanceSize
size_t fastInstanceSize(size_t extra) const
{
ASSERT(hasFastInstanceSize(extra));
if (__builtin_constant_p(extra) && extra == 0) {
return _flags & FAST_CACHE_ALLOC_MASK16;
} else {
size_t size = _flags & FAST_CACHE_ALLOC_MASK;
// remove the FAST_CACHE_ALLOC_DELTA16 that was added
// by setFastInstanceSize
return align16(size + extra - FAST_CACHE_ALLOC_DELTA16);
}
}
断点进入 align16 (16字节对齐)
static inline size_t align16(size_t x) {
return (x + size_t(15)) & ~size_t(15);
}
我们探究下 align16 方法的具体实现 已 align16(10)为例
x = 10 (x + size_t(15)) & ~size_t(15)
~(取反)
10 + 15 = 250001 1001
150000 1111
~151111 0000
25 & ~150001 1001&1111 0000
结果:0001 000016
总结:align16 算法实际上就是取16的整数倍。我认为是向下取整,理由我是站在纯算法的角度,(x + 15)是16的几倍,超过的部分抹去。例如 (20 + 15) = 35 = 16 * 2 + 3,结果是32。这种算法和 >> 4 << 4 是一样的,得出的结果就是16的倍数,不足16的全部抹去。
为什么需要16字节对齐
cpu读取数据是以固定字节块来读取的,这是一个用空间换取时间的做法,如果频繁的读取字节未对齐的数据,降低了cpu的性能和读取速度。- 更安全 由于在一个对象中
isa指针是占8个字节,如果不进行节对齐 ,对象之间就会紧挨着,容易造成访问混乱。16字节对齐,会预留部分空间,访问更安全
calloc:开辟内存,返回地址指针
首先由 instanceSize 方法 计算出需要的内存大小,然后向系统申请 size 大小的内存,返回给objc,因此objc是指向内存地址的指针,下面我们通过断点打印的方法来验证下
从上图中可以看出,obj还没有进行赋值,此时有地址值,说明系统给他分配了一块脏地址,我们继续走到下一个断点
上图:执行calloc后打印的是一个16进制的指针地址,说明已经开辟了内存,但是和平常见到的地址指针(<LWPerson: 0x100726d00>)不一样,为什么呢?
obj没有和cls进行关联绑定- 同时验证了
calloc只是开辟了内存
initInstanceIsa:初始化指针 ,和类关联起来
断点进入 initInstanceIsa
inline void
objc_object::initInstanceIsa(Class cls, bool hasCxxDtor)
{
ASSERT(!cls->instancesRequireRawIsa());
ASSERT(hasCxxDtor == cls->hasCxxDtor());
initIsa(cls, true, hasCxxDtor);
}
具体的
isa结构和源码探究,会在后面单独发文
在isa指针初始化以后,打印objc
上图中打印结果显示:指针已经与类进行了关联,alloc探索也完成了。
alloc流程图
init探究
- (id)init {
return _objc_rootInit(self);
}
断点进入 _objc_rootInit
id
_objc_rootInit(id obj)
{
// In practice, it will be hard to rely on this function.
// Many classes do not properly chain -init calls.
return obj;
}
init方法返回的是对象本身init可以提供给开发者更多的自由去自定义 ,通过id实现强转,返回我们需要的类型
new探究
+ (id)new {
return [callAlloc(self, false/*checkNil*/) init];
}
源码显示 走了callAlloc的方法流程,然后走了init方法 ,所以 new看做是alloc + init
总结
alloc 的核心作用就是开辟内存,通过isa指针与类进行关联,init方法就是个工厂方法,通过id实现强转,返回我们需要的类型然后提供给对象本身,提供开发者更多的自由即方便我们进行方法重写来完成一些特殊的功能呢需求,new 是对(alloc+init)进行了封装,无法在初始化的时候添加其它的需求。综合来说建议开发者使用 [[Class alloc]init] 去初始化对象。
补充:为什么创建一个对象的时候,要走两次 callAlloc 方法?
通过 llvm 分析,苹果做了插桩处理:
- 第一次:执行
alloc时,会通过方法映射,调用objc_alloc,此时做了插桩操作(做标记 receiver),接下来就是第一次调用callAlloc→objc_msgSend(alloc) - 第二次:再次执行
alloc,再次执行objc_alloc,发现有标记存在,所以不再执行objc_alloc方法,而是调用本身的alloc,进而执行_objc_rootAlloc→callAlloc→objc_msgSend(allocWithZone)