前言
敲了五年的代码,敲了无数个alloc,但是alloc在我们看不到的底层代码里到底都干了些什么?迄今我都还不知道?♀️。所以不妨今天就趁这个机会,来看看alloc究竟在底层干了哪些不为人知的事情。
- 在开始之前先来看一个之前遇到过的面试题,大致如下:
YSHStudent *baseStu = [YSHStudent alloc];
YSHStudent *stu1 = [baseStu init];
YSHStudent *stu2 = [baseStu init];
YSHStudent *stu3 = [baseStu init];
YSHStudent *newStu = [YSHStudent alloc];
NSLog(@"%@---------%p---------%p",baseStu,baseStu,&baseStu);
NSLog(@"%@---------%p---------%p",stu1,stu1,&stu1);
NSLog(@"%@---------%p---------%p",stu2,stu2,&stu2);
NSLog(@"%@---------%p---------%p",stu3,stu3,&stu3);
NSLog(@"%@---------%p---------%p",newStu,newStu,&newStu);
复制代码
此时打印baseStu、stu1、stu2、stu3、newStu对象地址、指针地址有什么异同?
咱们直接打印看下结果。
2021-06-06 10:57:53.766217+0800 alloc[27048:1427279] <YSHStudent: 0x600001f68770>---------0x600001f68770---------0x7ffeeeb8a500
2021-06-06 10:57:53.766808+0800 alloc[27048:1427279] <YSHStudent: 0x600001f68770>---------0x600001f68770---------0x7ffeeeb8a4f8
2021-06-06 10:57:53.767075+0800 alloc[27048:1427279] <YSHStudent: 0x600001f68770>---------0x600001f68770---------0x7ffeeeb8a4f0
2021-06-06 10:57:53.767250+0800 alloc[27048:1427279] <YSHStudent: 0x600001f68780>---------0x600001f68780---------0x7ffeeeb8a4e8
复制代码
通过打印结果可以直接看到:
baseStu和stu1、stu2、stu3的对象地址是一模模一样样的,但是指针地址却不同;
newStu和baseStu、stu1、stu2、stu3不仅指针地址不同,而且对象地址也是不同的;
并且发现指针地址的一个规律,由下至上0x7ffeeeb8a4e8 + 8 = 0x7ffeeeb8a4f0、0x7ffeeeb8a4f0 + 8 = 0x7ffeeeb8a4f8、0x7ffeeeb8a4f8 + 8 = 0x7ffeeeb8a500,是一串连续的相差8字节的指针地址,由此我们可以推断栈内存是连续的并且指针占8字节内存空间(PS:栈区内存从高地址到低地址;堆区从低地址到高地址)
下面我们进入正题,正式开始探索alloc底层原理。
准备工作
- 下载源码,源码地址(苹果开源库)
- 下载下来的源码是不能直接编译的,此时为节省时间可以阅读国内某位酷大神博客或者直接下载github上的可编译源码编译源码地址
- 其实正常来说我们是不知道alloc是属于哪个开源库的,如何知道alloc属于哪个开源库呢,下面我介绍3种方法。
三种常用探索底层方法
一、下符号断点直接跟流程
- 在需要调试的先打上一个断点,然后按住control键+step into,单步往下走一步.
2. 单步走,发现objc_alloc方法,添加objc_alloc符号断点。
3. 通过符号断点发现,alloc在libobjc.A.dylib这个库里。
二、直接添加想要探索的方法的符号断点
例如直接添加alloc符号断点。(PS:在自己用到的地方再激活该符号断点,alloc在项目里不知道会被调用多少次,直接激活,估计键盘都要被自己点报废了)
三、查看底层汇编看流程
了解过逆向的同学,这点肯定不陌生。可以说是最常用的方法之一。具体打开汇编调试的方法如下图:
打开后,到alloc断点后,会进入如下页面,然后发现会调用objc_alloc,可以再添加符号断点,然后单步走跟流程。
以上就是3种底层探索的方法,最后我们都可以拿到alloc是在libobjc.A.dylib这个库里。
编译运行源代码工程
将我们下载的可编译的源码运行起来,然后我们跟下alloc的具体流程。
1. 下断点,进入alloc源码实现
+ (id)alloc {
return _objc_rootAlloc(self);
}
复制代码
2. 进入_objc_rootAlloc源码实现
id
_objc_rootAlloc(Class cls)
{
return callAlloc(cls, false/*checkNil*/, true/*allocWithZone*/);
}
复制代码
3. 进入callAlloc源码实现
static ALWAYS_INLINE id
callAlloc(Class cls, bool checkNil, bool allocWithZone=false)
{
#if __OBJC2__ //是否有可用的编译器优化
//slowpath 大概率为假
//fastpath 大概率为真
//此处去掉slowpath和fastpath对代码逻辑没有丝毫影响,应该只是告诉编译器对代码优化,提升编译效率。
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));
}
复制代码
4. 进入_objc_rootAllocWithZone源码实现
id
_objc_rootAllocWithZone(Class cls, malloc_zone_t *zone __unused)
{
// allocWithZone under __OBJC2__ ignores the zone parameter
//这里没有什么好说的,直接进入第5步
return _class_createInstanceFromZone(cls, 0, nil,
OBJECT_CONSTRUCT_CALL_BADALLOC);
}
复制代码
5. 进入_class_createInstanceFromZone源码实现
进入到这一步就发现,代码比前面几步都要多,应该是进入核心部分了。
通过对源码分析,该方法中有3个核心方法,分别做了3件重要的事情:
- cls->instanceSize(extraBytes);——-计算内存空间
- (id)calloc(1, size);——-开辟内存空间,返回内存地址指针
- obj->initInstanceIsa(cls, hasCxxDtor);——-关联isa和类
static ALWAYS_INLINE id
_class_createInstanceFromZone(Class cls, size_t extraBytes, void *zone,
int construct_flags = OBJECT_CONSTRUCT_NONE,
bool cxxConstruct = true,
size_t *outAllocatedSize = nil)
{
ASSERT(cls->isRealized());
// Read class's info bits all at once for performance
bool hasCxxCtor = cxxConstruct && cls->hasCxxCtor();
bool hasCxxDtor = cls->hasCxxDtor();
bool fast = cls->canAllocNonpointer();
size_t size;
//计算需要开辟的内存空间大小
size = cls->instanceSize(extraBytes);
if (outAllocatedSize) *outAllocatedSize = size;
id obj;
if (zone) {
obj = (id)malloc_zone_calloc((malloc_zone_t *)zone, 1, size);
} else {
//申请内存空间
obj = (id)calloc(1, size);
}
if (slowpath(!obj)) {
if (construct_flags & OBJECT_CONSTRUCT_CALL_BADALLOC) {
return _objc_callBadAllocHandler(cls);
}
return nil;
}
if (!zone && fast) {
//将cls类和isa关联
obj->initInstanceIsa(cls, hasCxxDtor);
} else {
// Use raw pointer isa on the assumption that they might be
// doing something weird with the zone or RR.
obj->initIsa(cls);
}
if (fastpath(!hasCxxCtor)) {
return obj;
}
construct_flags |= OBJECT_CONSTRUCT_FREE_ONFAILURE;
return object_cxxConstructFromClass(obj, cls, construct_flags);
}
复制代码
对源码的分析,我们还可以得出该方法内部实现流程图如下:
下面我们对这几个关键方法的实现进行分析
- 分析instanceSize源码实现
inline size_t instanceSize(size_t extraBytes) const {
if (fastpath(cache.hasFastInstanceSize(extraBytes))) {
return cache.fastInstanceSize(extraBytes);
}
size_t size = alignedInstanceSize() + extraBytes;
// CF requires all objects be at least 16 bytes.
if (size < 16) size = 16;
return size;
}
复制代码
进入fastInstanceSize源码实现
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
//16字节对齐
return align16(size + extra - FAST_CACHE_ALLOC_DELTA16);
}
}
复制代码
进入align16源码实现
static inline size_t align16(size_t x) {
//16字节对齐算法 &为与操作 ~为取反操作
return (x + size_t(15)) & ~size_t(15);
}
复制代码
我们发现此方法是16字节对齐算法,在解析该算法前,我们需要了解为什么要进行16字节对齐。
- 我们需要知道CPU在读取数据时,是以字节快为单位进行读取的,如果频繁读取没有对齐的数据,会严重加大CPU的开销,降低效率
- 为什么16字节对齐而不是8字节对齐,我们都知道在一个对象中,第一个属性isa占8字节,如果只有8字节的话,不预留空间,可能造成这个对象的isa和另一个对象的isa紧挨着,容易造成访问混乱。同时一个对象也不会只有isa一个属性。
由此可见:16字节对齐后,可以加快CPU读取速度,同时访问也会更加安全。
16字节对齐算法的过程,如下所示
x + size_t(15)) & ~size_t(15)
&为与操作 ~为取反操作
&(与)的规则是:全部为1则为1,反之则0
~(取反)的规则是:1变0,0变1
此处我们以9为例
9+15=24
24的二进制为:0001 1000
15的二进制位:0000 1111
15的取反二进制位:1111 0000
0001 1000
1111 0000
= 0001 0000 也就是10进制的16
复制代码
- calloc分析:申请内存,返回地址指针
通过instanceSize方法计算的内存大小,向内存中申请大小为size的内存,并赋值给obj,因此 obj是指向内存地址的指针。如图所示
细心的我们发现,打印出来的格式和之前我们打印<YSHStudent 0x00000001000ebe00>这种格式有些不同。
1.主要是因为obj地址还没有和传入的cls进行关联
2.同时恰恰印证calloc的根本作用只是开辟内存空间
- obj->initInstanceIsa源码解析:类与isa关联
inline void
objc_object::initInstanceIsa(Class cls, bool hasCxxDtor)
{
ASSERT(!cls->instancesRequireRawIsa());
ASSERT(hasCxxDtor == cls->hasCxxDtor());
initIsa(cls, true, hasCxxDtor);
}
复制代码
经过calloc之后,内存已经申请好了,并且类也已经传进来。所以initInstanceIsa主要就是初始化一个isa指针,并将isa指针指向已经申请好的内存地址,再将isa指针与cls类进行关联。
我们单步往下走断点,走完initInstanceIsa方法后,打印obj发现就和之前打印的格式一样了,已经关联起来了。
最后
根据源码的分析,我们大体能画出alloc在底层实现的流程图。
总结
- 通过对alloc源码的分析,可以得知alloc的主要目的就是开辟内存,关联isa和cls类。
- 其中开辟内存的核心步骤有3步:计算内存大小 — 申请内存空间 — 关联isa和cls类