OC底层原理(alloc和init篇)

OC对象,每天都在用,用一句话概括,它可能是最熟悉的陌生人。如果让你谈谈你对OC对象的认知,咱们应该如何入手呢?今儿,咱们来个抽骨扒筋,从根本上来对OC对象进行深入的全面的剖析。

研究OC的对象,需要找一个合适的切入点,如果上来就从ISA指针开始讲的话,可能会觉得有些突兀,那么咱们就以对象最最常用的方法alloc和init方法来作为切入点开始研究。来看下面的代码:

 1   - (void)initObjects{
 2       Person *person1 = [Person alloc];
 3       Person *person2 = [person1 init];
 4       Person *person3 = [person1 init];
 5       NSlog("%@ %p %p",person1,person1,&person1);
 6       NSlog("%@ %p %p",person2,person2,&person2);
 7       NSlog("%@ %p %p",person3,person3,&person2);
 8   }
复制代码

Person是继承自NSobject的一个类,然后person1是Person类的对象,person2 和 person3 是 person1 init之后的对象。那么,纵向的来看打印信息中每列的值是否一样呢?来看打印结果:

<Person: 0x100616610>    0x100616610    0x7ffeefbff3f8

<Person: 0x100616610>    0x100616610    0x7ffeefbff3f0

<Person: 0x100616610>    0x100616610    0x7ffeefbff3f0
复制代码

从打印结果中可以看出,第一列和第二列是一样的,第三列是不一样的。这样的结果也非常容易解释,说明person1,person2 和 person3都指向同一个对象,其实来时,第一列和第二列的打印是一样的,都是打印这个对象。而&person,打印的是指针的地址,那指针的地址肯定是不一样。那为啥会出现这样的现象呢?就得从alloc和init方法分别干了什么说起。

alloc

顾名思义,alloc,跟C语言中的malloc是不是有点类似,就是从内存中申请空间,来存放这个对象。我们来继续深入探究一下alloc究竟做了什么。如果我们按住command键跳转到相应的alloc源码中,是无法查看alloc的源码的,所以我们需要在苹果的源码中去寻找,就是下面的网站

opensource.apple.com/tarballs/

我们用的是objc4-787.2.tar.gz这份开源代码,下载下来解压,然后有个xcode工程,打开,就能看到这份源码了。全局搜索+(id)alloc这个方法,就能找到他的实现了。然后经过查看,alloc的调用方法大概就是这样的路径

alloc的方法调用栈

graph TD
alloc --> _objc_rootAlloc --> callAlloc --> objc_rootAllocWithZone --> class_createInstanceFromZone

如何去验证这个流程,有两种方法,一个是配置可运行的苹果的源码,然后在源码中打断点,另一个是通过打符号断点来验证。第一种方法,可参考这个链接( juejin.cn/post/684490… )进行配置或者做一个安静的伸手党(我本人也是),第二种方法大家平时也用,就不再赘述。

class_createInstanceFromZone的方法调用栈

最后我们在class_createInstanceFromZone里会找到比较关键的三个方法cls->instanceSize,calloc,initInstanceIsa。我们一个个的分析

cls->instanceSize

instanceSize,直译过来就是实例大小,这就是说这个方法,是来决定新建对象在内存中占用多少字节的。我们再进去看一下这个方法都干了什么?fastInstanceSize,再进去,会看到不管走哪个代码,返回的size都是16或者16的倍数。尤其有个方法非常直观,align16。

align16(size + extra - FAST_CACHE_ALLOC_DELTA16);
复制代码

align16,又可以直译了,对齐16。再看一下align16的实现:

static inline size_t align16(size_t x) {
    
    return (x + size_t(15)) & ~size_t(15);
    
    //传进来的数值X          例如传进来是4   0000 0000 0000 0100
    //X + 15                     结果19   0000 0000 0001 0011
    //与15的反数进行与操作              &   1111 1111 1111 0000
    //return                    得到16    0000 0000 0001 0000
}

复制代码

相当于,不管最后4位是个啥,统统进行抹0操作,加上15,保证了最终得到的结果最小是16,经过这个骚操作,就决定了这个对象的大小只能是16的倍数(影响对象所占的内存大小的主要因素就是这个对象属性值占多少内存)。咱们将得到的内存大小返回去,进行下一步。

calloc(1, size)

这个没啥说的,就是拿到前面得到的size,去申请了内存,并把内存赋给了当前对象,宣誓了对内存的权力。

initInstanceIsa

这个就是将这个对象跟类关联起来,并且创建isa指针。这个isa指针非常非常的重要,会是后面的重头戏。

到此,我们就将对象的创建过程讲完了。还有一些边边角角的东西,比如是否有析构函数hasCxxCtor,是否是TaggedPointer类型的对象等,这个在后面遇到了,再说一下,埋个坑。

init

说完alloc,再看init。其实,init的代码非常简单,啥也没干,就是把刚才alloc的对象return出去。这是一种常见的设计,叫工厂设计,其他人就可以通过重写这个方法,来干自己想干的事情。

+ (id)init {
    return (id)self;
}
复制代码

好,这就可以很好的解释了,在文章一开始的打印信息了。

再新增一个知识点,如果使用根类NSObject调用alloc方法,是不会走上述中alloc的方法的,而是走的objc_alloc方法。 如果是其他的类,则会先调用objc_alloc,再调用alloc方法,因为在LLVM层面,调用alloc的时候,先会发送objc_alloc消息,如果返回的值为真则不会再发送alloc消息,如果不为真,则会继续发送alloc消息。这个说起来比较绕口,也不太好理解,后续如果有机会,补充一个小文档来说一下,这里就不深究了。

对象也alloc完了,也知道他要进行内存对齐了,那对象的本质是啥嘞?既然影响它占用内存大小的是属性的多少,那它的属性在内存中是怎么分布的呢?我们继续。

OC->C++代码转换

我们平时编写的Objective-C代码,底层其实都是C/C++代码。所以,要看NSObject的结构,我们得需要对代码进行转换为C/C++代码。就是在终端使用下方的指令进行转换。

xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc OC源文件 -o 输出的CPP文件

比如,新建了一个MacOC环境下的命令行工程,在工程下,会有一个main.m文件,我们就可以对这个main.m文件进行转换。终端指令如下:

xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o main.cpp

这时,就会在目录下生成一个main.cpp文件,咱们就可以看到OC转换后的C++代码了。

我们新建一个MacOS下的命令行行工程,然后在main.m内,新建一个Person类,里面有name,nickName,age,height四个属性。然后通过上面的指令,将main.m转换成main.cpp,然后将main.cpp拖入到工程中来,在build phase->compile Sources里,将这个main.cpp去掉,不让它参与编译的工作。然后会看到Person转换成了下面的样子。

struct Person_IMPL {

    struct NSObject_IMPL NSObject_IVARS;
    int _age;
    NSString *_name;
    NSString *_nickName;
    long _height;
};
复制代码

好,答案显而易见了吗,对象在底层就是结构体,属性也变成了结构体中带成员变量。

结构体内存对齐规则

结构体也是有内存对齐的概念,大致的对齐规则为:
1、总内存占有数量须是内存占用最大的成员变量得倍数,
2、每个成员变量的起始位置,须是当前成员变量占用内存大小的倍数
例如:

struct Struct1{
    double a;  //double类型占用8个字节,故0-7,是a的位置
    int b;     //int类型是4个字节,此时起始位置是8,8是4的倍数,所以,8-11是b的位置
    char c;    //char类型的是1个字节,此时起始位置是12,12是1的倍数,所以12是c的位置
    short d;   //short类型是2个字节,此时的起始位置是13,13不是1的倍数,则需要后移,14是2的倍数,故14-15是d的位置
}Struct1

//Struct1最大占用成员变量为8,实际所需内存大小为15,实际分配的内存大小须为8的倍数,故Struct1的内存大小为16

struct Struct2{
    double a;  //double类型占用8个字节,故0-7,是a的位置
    char b;    //char类型是1个字节,此时起始位置是8,8是1的倍数,所以,8是b的位置
    int c;     //int类型的是4个字节,此时起始位置是9,9不是4的倍数,后移,找到12,所以12-15是c的位置
    short d;   //short类型是2个字节,此时的起始位置是15,15不是2的倍数,则后移,找到16,故16-17是d的位置
}Struct2

//Struct2最大占用成员变量为8,实际所需内存大小为17,实际分配的内存大小须为8的倍数,故Struct2的内存大小为2
复制代码

OC对象内存对齐验证

来看一段OC的代码

Person *person1 = [Person alloc];
        person1.name = @"星星";
        person1.nickName = @"9527";
        person1.age = 18;
        person1.height = 180.2;
        //如果malloc_size方法报错,需要导入<sys/malloc.h> #include <sys/malloc.h>
        //class_getInstanceSize这个是runtime中的API,需要#import <objc/runtime.h>
        NSLog(@"%@,%lu,%lu,%lu",person1,sizeof(person1),class_getInstanceSize([Person class]),malloc_size(( __bridge const void *)(person1)));
复制代码

按照上面的struct内存占用方法来分析,Person类底层是个结构体,它占用内存应该是40个字节(8+8+8+4+8,有一个8是isa指针),那上面的代码打印的结果是什么呢?<Person: 0x100572a30>,8,40,48第一个,对象的地址,第二个是指向这个对象指针大小为8.第三个是这个对象实际占用的大小,也就是咱们按照规则计算出来的大小为40。malloc_size打印出来的是系统给这个对象分配的大小,按照上面16对齐规则,是48。不过,苹果对于内存的优化,非常非常骚,他们引入了位域的技术,搞出了联合体,nonpointer这些东西,此篇文章再埋一个坑,大概会在讲解isa指针的篇幅中会进行详细的介绍。

篇幅不小了,这篇文章先暂时到这儿,后面再继续。用几句话来进行总结。
1、alloc进行了真正的对象的创建,内存分配的工作,其中有三个比较重要的步骤,决定大小,创建空间,创建isa.
2、init方法,只是将创建的对象返回出去,是一种工厂模式,便于开发人员进行定制。
3、对象在底层是结构体,结构体内存分配有内存对齐原则,苹果在创建对象时也有内存对齐,且是16字节的对齐。
下一篇应该是详细讲解isa,并且迁出类对象,元类对象以及它们之间的关系。本篇文章有错误请指正。谢谢~

猜你喜欢

转载自juejin.im/post/6998430773665628167