50

深入探究 Objective-C 对象的底层原理

 5 years ago
source link: https://mp.weixin.qq.com/s/X-GKeFAYVdXrW1F2WBKO4g?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

来这里找志同道合的小伙伴!

作 者 简 介

杨立—— 京东商城iOS研发工程师

熟悉移动端领域主流的开发架构,曾主导开发拍拍二手APP中商详、店铺、集市详情等项目。喜欢探究技术原理,有特别的好奇心和编程洁癖。

职业信仰:编程是一门艺术,若爱请深爱。

本文基于对象的实现原理来深入剖析 OC 的底层相关原理。这里并不会简单的介绍纯理论知识,而是借助工具和编码实现相关业务逻辑并作论述。

> > > >   内容简介

> > > >   一、instance对象的内存探究

我们平时创建一个 OC 对象是这样的: NSObject*obj=[[NSObjectalloc]init]; 但是我们这样编写一行代码之后,不妨思考下: 它最终会生成什么样子的代码?obj 对象的内存分配是怎样的?对象底层的数据结构是如何的? 带着这些疑问,我们可以一探究竟。

众所周知:OC 在编译器的作用下,最终会转成 C/C++ 代码,进而转成汇编代码,最后才会生成机器可以识别的二进制代码,如下图:

因此,作为 iOS 工程师理论上我们可以通过C/C++、汇编、机器语言来探究它的底层。但由于篇幅原因,本文会重点从 C/C++ 层面来一一论述。

要想看 obj 的底层实现,我们需要借助 clang (Xcode自带的编译器前端) 编译器进行编译。基于简单考量,我们可以建立一个命令行项目,然后 cd 到 main.m 的目录下,通过终端运行指令:

  • $:  xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o main_arm64.cpp

具体操作可参考下图:

jaE3yae.jpg!web

此时生成的 main_arm64.cpp 就是 main.m 在编译后的源码文件。通过查阅源码发现: NSObject 转成 C++ 代码长这样子的:

mYzIrmR.jpg!web

二者极其的相似,而 Class 又是这样定义的: typedef struct objc_class Class ,说白了它就是一个结构体!因此说,一个 NSObject 类编译后是一个 C++ 的结构体,结构体的成员变量仅包含一个 isa 。因此我们可以得出结论:一个 NSObject 对象在内存分配上相当于包含一个成员变量的结构体的内存分配。而该结构体在64bit 系统下只占用8个字节(当然32bit下占用4个字节),也就是说 obj 对象在内存分配上实际占用8个字节。

我们不妨继续深入探究来验证上述结论。通过 runtime 的 class getInstanceSize(Class _Nullable cls) 我们可以获取 NSObject 的实例对象所占用内存的大小;通过 extern size t malloc_size(const void *ptr) 可以获得 obj 指针所指向的那块内存的大小。但是,二者有什么区别吗?我们可以通过 Xcode 日志查看一下,如图:

INram2q.jpg!web

打印结果不一样!为什么?带着这个好奇心我们不妨通过两个函数的具体实现来解释说明。

实际上,目前来说苹果的很多底层实现都是开源的了。我们可以在苹果开源上来下载源码阅读,本文内容中只需下载 objc4 即可。查阅源码后不难发现:class_getInstanceSize 的底层实现其实就是依次调用了: alignedInstanceSize()、word-align() 。我们都知道 alloc 的内部实际是调用了 allocWithZone: ,而通过源码发现 allocWithZone 内部又是依次调用了: objc-rootAllocWithZone > class-createInstance > class-createInstanceFromZone > instanceSize > alignedInstanceSize > word-align。 仔细看可以发现,最后两步的函数调用二者是一样的。但是关键一步在于: instanceSize() 。如图:

JZVVBjR.jpg!web

原来苹果在 CF 框架内部硬性规定了所有的对象在内存上必须至少是占用16个字节。也就是说:alignedInstanceSize() 内存对齐后是8个字节,由于extraBytes等于0,因此 size < 16成立,所以最终的 size 返回的是16!

综上:也就解释了为什么打印了 8 和 16 不同的结果。实际上,我们可以这么理解 class_getInstanceSize、mallocsize 的区别:前者是获得NSObject实例对象的成员变量所占用的大小,后者是操作系统实际上给NSObject的实例对象 obj 分配的内存大小。打个比方:我今天去菜市场买肉,我本来只要半斤就够吃了,但是卖肉的老板必须卖给我一斤,因为老板卖肉的规则就是至少是每人每次卖一斤,且必须也是一斤的整数倍量。

假如现在我定义一个 Person 类,其内部包含一个成员变量: int age; 那么我们猜一下 class_getInstanceSize、mallocsize 分别会打印多少?答案是:16、16。没错的,因为这里涉及到一个结构体成员数据对齐的常识。即在结构体中,成员数据对齐需满足以下规则:

  • 结构体中的第一个成员的首地址也即是结构体变量的首地址。

  • 结构体中的每一个成员的首地址相对于结构体的首地址的偏移量(offset)是该成员数据类型大小的整数倍。

  • 结构体的总大小是对齐模数(对齐模数等于#pragma pack(n)所指定的n与结构体中最大数据类型的成员大小的最小值)的整数倍。

因此,在包含一个成员变量的 Person 类中,编译后生成的 C++ 结构体中本质上是有两个成员:isa、age,由于 isa 占用8个字节,age 类型为 int 占用4个字节,为了满足规则第三条:结构体的总大小必须是最大数据类型的成员大小的整数倍,就是 8 的整数倍为 16。

综上,一个 OC 对象在编译后会生成一个 C++ 结构体,结构体中包含了所有的成员变量和一个 isa,在内存分配上会按照一定的对齐规则进行管理。

> > > >   二、OC对象的分类及其底层数据结构

上面讲述了 instance 对象本质的一些认识,接下来重点阐述 OC 三大对象的底层之间的相互关系。

从语言设计角度来划分,可将 OC 对象分为三大类:

  • 实例对象,

  • 类对象

  • 元类对象

> > > >   1、如何获取三大对象的地址?

首先导入头文件 #import <objc/runtime.h> 并创建以下对象:

obj1、obj2为两个不同的 实例对象

NSObject*obj1=[[NSObjectalloc]init];

NSObject*obj2=[[NSObjectalloc]init];

objClass1、objClass2、objClass3、objClass4为NSObject的 类对象

ClassobjClass1=[obj1class];

ClassobjClass2=[obj2class];

ClassobjClass3=object_getClass(obj1);

ClassobjClass4=object_getClass(obj2);

objMetaClass1、objMetaClass2为NSObject的 元类对象

ClassobjMetaClass1=object_getClass(objClass2);

ClassobjMetaClass2=object_getClass(objClass4);

通过日志打印,获得地址分别如下图所示:

fmmMruF.jpg!web

通过阅读内存地址可知: 一个类可以创建 多个 不同的实例对象,但是仅可以创建 一个 类对象和 一个 元类对象!

> > > >   2、对象的底层数据结构

今假设存在以下三种类 JDManJDPersonNSObject

继承关系为: JDMan继承自JDPerson,JDPerson继承自NSObject。

且JDPerson包含2个成员变量、1个属性、1个对象方法,1个类方法;JDMan同JDPerson

如图所示:

aeMBFzB.jpg!web

结合前边讨论,我们可将OC的类编译成C++代码,如下图所示:

YfiaamM.png!web

不难发现:所有的实例对象的C++结构体中仅仅包含了成员变量(当然也存储这一个 isa指针 和一个 superclass指针 ),也就是说实例对象仅仅存储各自的成员变量的值。那么他们的对象方法、类方法、甚至协议等相关信息存储在哪里呢?

我们不妨先来思考一个事情:对象可以创建多个,每个对象都有自己的成员变量和对应的值,但是方法大家调用的都是同一个,不管你是实例对象还是类对象都是调用的一个方法。因此OC在设计这门语言的时候,我们有必要将只需要存储一份的数据交给实例对象去管理吗?肯定不需要。

上述我们发现而类对象和元类对象正好在内存中只有一份。这恰巧在某种程度上佐证了一个事实: 实例方法存储中类对象中,类方法存储在元类对象中。

当然如果进一步分析的话,类对象中都存储着以下信息数据:

isa指针

superclass指针

类的成员变量信息(ivar

类的属性信息(@property)、

类的协议信息(@protocol)、

类的对象方法信息(instance method)、

......

而元类对象中存储的信息数据包括:

isa指针

superclass指针

类的类方法信息(classmethod

......

上述只是我们主观的分析得出的结论。那么接下来我们就要去用事实证明这些结论的正确性。

我们已经知道,三大对象(实例对象、类对象、元类对象)本质上都是OC中的 Class类型 的结构体。要想证明我们上述的分析结果,那么就必须要彻底探究清楚Class的深层结构。在OC中,我们通过查看头文件的方式只能看到 typedefstructobjc_class*; 这样的声明,继续阅读 objc_class* 的相关代码。如下图所示:

vY7fIra.jpg!web

但是很遗憾,在OC2.0版本中很多都是过期的,如此、这并不是我们期望的。

那么有没有其他方式呢?答案是有的。

幸好苹果给我们开源了相关源码。我们可以到苹果开源上去下载相关源码 objc4-723库 ,从 objc4-723库中找到objc-runtime-new文件 进而找到 structobjc_class* 的定义。如下图所示:

BbM7B33.jpg!web

会发现实现的相关代码(上图中并没有展示全部的相关代码,只是截取了部分核心代码。如有兴趣可自行查阅原文)太多了,好复杂。不过这足以能够帮我们去证明一些事情了。

熟悉C++的同学应该都知道,只要是结构体的内部数据结构、格式是一致的,那么就可以进行结构体之间的类型转换(可以理解为OC中的对象类型强转,不过可能会导致被转换的结构体的某些数据的丢失)的。

基于这一点,我们不妨把官方的实现进行简化:只保留必要的我们期望的信息,如方法缓存表、协议缓存表、属性表、属性信息、协议信息、描述信息等等。如下所示:

这样我们就可以通过断点调试的方式进行验证了。以类对象personClassData为例进行验证。因为 structjd_objc_class* 中有isa(继承而来)、superclass、cache(方法缓存相关)、bits、data等信息,通过分析可得:通过data函数的调用可以获得类对象中主要的存储的信息数据。personClassData正是通过调用 structjd_objc_class*data() 函数获取的 structclass_rw_t* 类型的返回值。如下图对 structclass_rw_t* 的分析所示:

7fa2aeu.jpg!web

从图中可知:personClass类对象中的data函数返回的personClassData( structclass_rw_t* 类型)存储着很多和JDPerson相关的信息:如成员变量、协议信息、对象方法等等。这样就证明了我们前边的分析的结论了。

同理:我们可以对元类对象也用同样的方式进行分析。结果是一样的。

> > > >   三、isa/superclass指针

我们已经知道,三大对象的结构体中都有一个isa指针、一个superclass指针。那么他们之间的关系是如何的呢?

> > > >   1、isa指针

我们可以通过代码进行测试,如下图:

Nn22Ybv.jpg!web

obj是一个实例对象,里边有一个isa指针,再用LLVM的相关指令: p/x 可以打印出obj的isa的值 0x001dffffa8f48141 。objClass为 Class 类型的类对象,将其强制转换成我们简化后的结构体 structjd_objc_class* 然后打印的其地址为 0x001dffffa8f48140 。地址并不相等。貌似实例对象的isa指针不是指向类对象。

然而事实并不是这样的。从iOS系统支持64bit以来,实例对象的isa指针需要进行一个与运算 & ISA MASK 才行。也就是说用0x001dffffa8f48141 & ISA MASK 得到的值才是isa真实的指向。那么对于ISA_MASK的定义苹果是这样设计的:

由于我们Mac(iOS架构是 arm64 )的架构是 __x86_64__ ,因此也就是说:我们要想得到实例对象isa真正的指向我们需要进行运算: 0x001dffffa8f48141 & 0x00007ffffffffff8 。得到的结果是: 0x001dffffa8f48140 。正如上图所示的一样。因此实例对象的isa指针事实上是指向类对象的。

那么类对象的isa指针是指向哪里的呢?看下图代码所示:

qmaIFbq.jpg!web

jd manClass是我们获得简化后的类对象,jd manMetaClass是我们获得简化后的元类对象。图中代码同理:我们也验证了类对象的isa指针指向的是元类对象。

> > > >   2、superclass

我们已经验证到:实例对象的isa指向类对象,类对象的isa指向元类对象。但是他们各自的superclass又是如何指向的呢?

其实,我们可以利用类似的断点调试方式对superclass进行类似的探究。

我们同样可利用JDMan、JDPerson、NSObjet来测试。代码如下:

相关的日志打印如下:

很明显:子类类对象的superclass指向父类类对象,父类类对象的superclass指向基类( 即NSObject类

> > > >   四、OC对象相关总结

说了这么多,现以下图总结:

UzEnyur.jpg!web

1、在OC对象中可分为实例对象、类对象、元类对象

2、实例对象保存成员变量信息,类对象保存属性、对象方法、协议信息、成员变量描述信息,元类对象保存的是类方法等信息

3、对于一个NSObject对象,在内存分配时操作系统给其分类了16个字节(通过malloc size获得),但是实际使用的是8个字节(64bit环境下通过class getInstanceSize获得)

4、三种对象本质上都是一个Class类型的结构体,Class的定义为:typedef struct objc class *Class; 而typedef struct objc class *Class;的具体实现可去苹果开源下载源码,可参考objc4-723库中objc-runtime-new.h文件中的相关源码实现

5、isa:子类的实例对象的isa指针指向子类的类对象,子类的类对象的isa指针指向子类的元类对象,子类的元类对象的isa指针指向基类的元类对象(即NSObject的元类对象)

6、superclass:子类的类对象的superclass指针指向父类的类对象,父类的类对象的superclass指针指向基类的类对象,基类的类对象的superclass指针指向nil(即没有父类指向nil);元类对象的superclass同理:子类的元类对象的superclass指向父类的元类对象,父类的元类对象的superclass指向基类的元类对象,基类的元类对象的superclass指向基类的类对象

> > > >   写在最后

本文对OC中三大对象(实例对象、类对象、元类对象)之间的联系和区别以及我们开发中常见的isa、superclass等指针进行了较为详尽的论述。同时我们一步一步的也窥探了OC实例对象在内存中的分配情况。本文涉及到的内容在一些常规开发中可能并不常见,但是也许能很好的帮助我们更深入的去理解OC的一些底层的实现原理。这样我们在开发中遇到一些莫名其妙的问题时也许本文就起作用了,同时也希望通过本文的阐述,能给读者一些启发,能帮助大家提高阅读源码的主动性,促进大家勇于去探究未知事情的本质。由于笔者水平有限,如有纰漏烦请大家积极斧正。让我们共同学习,共同成长。谢谢大家的支持!

文末福利

---------------------END ---------------------

下面的内容同样精彩

点击图片即可阅读

bUrUZjB.jpg!web

reaaQb2.jpg!web

京东技术   关注技术的公众号

fYNjmqZ.jpg!webFvQRfim.jpg!web

长按,识别二维码,加关注


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK