大家好,今天来为大家解答深入探索iOS核心:OC对象本质与底层原理详解(一)这个问题的一些问题点,包括也一样很多人还不知道,因此呢,今天就来为大家分析分析,现在让我们一起来看看吧!如果解决了您的问题,还望您关注下本站哦,谢谢~
在开发过程中,您有没有想过我们创建的OC对象的本质是什么?实例对象是如何存储在内存中的?该对象在程序中占用了多少内存?传说中的isa中到底存储了什么?本章中,作者将带领读者通过源码分析和实战练习,为上述问题提供详细的解答。
我们平时写的OC代码其实底层是用cc++实现的,这一点在上个APP推出本章源码分析时已经得到了验证。因此,OC的面向对象实现是基于cc++数据结构的。所以如果想了解对象的本质,就需要将OC代码编译成C++代码。
1、OC对象本质到底是什么?
我们先看一段代码。这段代码定义了一个继承自NSObject 的类Person,包含2 个成员变量和一个对象方法。
在终端上cd到当前main.m文件所在目录,然后执行以下命令:
xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o main__.cpp
然后打开main__.cpp并搜索intmain
从上图我们肯定可以看出,我们写的Person类的本质其实是一个结构体,而OC的对象方法本质上也是一个C函数。
注意看下图
转换后的Person_IMPL结构有一个额外的NSObject_IMPL类型的结构成员NSObject_IVARS。那么什么是NSObject_IMPL呢?
NSObject_IMPL结构非常简单,只有一个Class类型的成员isa。
类实际上是一个指向结构的指针。
将NSObject_IMPL 引入到Person_IMPL 结构中并对其进行扩展,我们可以得出结论,Person 类的本质实际上是:
结构体Person_IMPL {
Isa类;
int _age;
NSString *_name;
}
由此可以得出一个答案:OC对象的本质其实就是一个结构体。
2、实例对象在内存中是怎么存储的?
我们先把之前的Person代码改一下。更改后,它看起来像这样:
然后给main中的实例对象赋值,并设置断点运行:
最后使用lldb中的x、p、po指令查看实例对象的内存数据。
解释一下p、po、x/8gx的含义。 p 是表达式- 的别名。该命令允许执行指令后面的代码、表达式、内存地址等,并返回执行结果(包括值的类型以及结果的引用名称$0和$1)。 po 是表达式-O — 的别名,po 只会输出对应的值。 x/8gx对象表示8个十六进制8字节地址空间的输出(x表示十六进制,8表示8,g表示字节单位,相当于x/8xg对象)
如上图所示,实例对象的z指针指向的首地址为:0x600002568000,该地址存储的内容为0x0000000101746ab8。可以这样理解,假设z指针有一个地址A,那么z指针在内存中的地址是A -0x600002568000,那么0x600002568000中存储的内容就是0x0000000101746ab8。 po地址打印Person,为什么?从第一个问题的答案截图中,我们知道对象的本质是一个结构体,而该结构体的第一个成员就是isa指针,因此我们可以猜测0x0000000101746ab8就是isa指针的地址。至于为什么要打印Person,等后面分析isa的详细结构时再给大家解答。
从上图我们可以看到,name、lastName、no分配给对象z都打印出来了,但是age和testChar没有打印出来。其实是打印出来的,但是打印错了。注意打印0x0000001200000071。这是一串令人费解的数字。其实这个内容存储了age和testChar的值,但是打印方法是错误的。 Age是int类型,int类型在x86架构下占用4个字节,而char类型在任何架构下都占用1个字节。
因此,如果我们要打印age,只需要打印出前4个字节即可。如果我们想打印testChar,我们只需要打印出最后4个字节。为什么是最后4个字节?稍后我会详细分析。上图的打印验证了我们的结论。
不知道大家有没有注意到,成员变量在内存中的存储顺序并不是按照定义的顺序。我们来比较一下:
成员变量在内存中存储的顺序:age、testChar、no、name、LastName
类中定义的成员变量的顺序:age、no、name、LastName、testChar
为什么?因为编译时会优化内存,防止内存浪费。
解释为什么"q"在内存中存储113,因为计算机只能存储0和1,而不能直接存储字符。存储的是字符的ASC码。
好吧,让我们回到正题。 OC对象在内存中存储的内容可以总结如下:
1.isa指针
2.其他成员变量,但变量存储的顺序与定义的顺序不同。
3、对象在程序中到底占用了多少内存?
我们来计算一下Person对象在内存中占用了多少字节。从上面的截图来看,红框中的数据是z对象的存储数据,为5 * 8=40字节。那么person 对象真的占用40 个字节吗?
获取内存大小的三种方法是:
1.sizeof
2. class_getInstanceSize //需要添加头文件
3. malloc_size //需要添加头文件
我们来写代码确认一下,在项目中添加如下打印代码:
class_getInstanceSize 确实返回了40,这意味着我们的计算是正确的。 Person对象的成员实际上占用了40个字节,但是后续的malloc_size返回的是48。
这很奇怪!从函数名我们猜测malloc_size是系统请求的空间大小。为什么系统请求的空间大小是48?为什么不是40?
我们先来看看这三种获取内存大小的方式有什么区别!
1、sizeof
是一个运算符,而不是一个函数。其原理是,传入参数的类型大小会在编译器的编译阶段确定,并直接转换为8、16、24等常量,而不是在运行时计算。
它的功能是获取保证容纳实现创建的最大对象的字节大小。
由于占用的内存大小是在编译时确定的,因此无法使用sizeof返回动态分配的内存空间的大小。
2、class_getInstanceSize
用于获取类的实例对象占用的内存大小并返回具体字节数。它的本质是获取实例对象中成员变量的内存大小,并返回创建实例对象所需的内存大小。
我们可以通过查看iOS开源objc4源码来了解class_getInstanceSize()具体是如何计算的。
搜索class_getInstanceSize 函数
调用alignedInstanceSize函数
alignedInstanceSize内部调用unalignedInstanceSize获取instanceSize,然后传入word_align进行字节对齐计算。 data()-ro()在前面的objc(运行时)章节中已经简单分析过。 ro存储类中的只读信息(成员变量、方法、协议等)。
word_align (采用8字节对齐)
WORD_MASK在64位架构中是7UL,实际上是7。
那么word_align内部其实就是做这个操作的,(x + 7) ~ 7
实际上,结果必须是8 的倍数。 word_align 实际上将传入的数字按8 对齐。
我们举个例子,假设我们传入15,15+7=23,将23转换成二进制:
23=1111; 7 的XOR 转换为4 位二进制=1000
那么1111 1000=1000
1000换算成十进制不是就是16吗? 2*8=16。
总之,class_getInstanceSize 是返回8 字节对齐对象所需的内存大小。因此,我们之前计算的Person成员占用的40个字节正好是8的倍数,所以返回40是正确的。
3、malloc_size
该函数是系统内核函数,返回系统实际为对象分配的内存大小(使用16字节对齐)。源代码位于libmalloc 中。全局搜索发现里面没有对齐操作。只调用了find_registered_zone,但该函数中找不到对齐操作。事实上,find_registered_zone真的就像函数名一样。在内部,它只搜索注册文件是否已注册。对象的区域空间。如果有,则找到该空间,然后返回该对象在该区域空间所占用的内存大小。那么malloc_size内部并没有做任何分配工作,那么我们怎么知道为什么malloc_size会分配48个字节给Person对象呢?
没有办法从源码分析中得到答案,所以我们只能从【Person alloc】方法入手,利用断点,通过汇编指令查看函数内部的调用,来了解系统分配内存的过程。
在Person*z=[[Personalloc]init]; 处打断点并运行代码
在lldb中输入si(Step Into单步跟踪入口)命令进入函数,直至进入下图函数
我发现原来是libobjc动态库中的一个函数。这不是巧合吗?打开之前下载的objc项目。然后全局搜索objc_alloc_init
最后一个函数是objc_alloc_init,它内部实际上调用了callAlloc()来申请内存。细心的读者一定发现了一个很奇怪的现象。这些函数都在内部调用callAlloc()。从注释中我们可以知道这些函数都是由这些注释中的OC方法转换而来的。
我们先看一下init方法内部做了什么
好吧,什么也没做!什么也没做!什么也没做!重要的事情说三遍。
但是作者在寻找init方法的时候发现了这个函数。
那么这就很奇怪了,为什么我们的代码没有使用这个函数呢?其实是因为编译器在编译阶段就做了优化,然后只要写[[cls alloc] init],就会直接调用objc_alloc_init函数,而不是+(id)alloc,但还是会稍后会被调用。说到这个alloc方法,至于为什么,笔者就先尝试一下。
我们继续看看callAlloc是如何完成的
callAlloc(cls,true/*checkNil*/,false/*allocWithZone*/)
callAlloc有3个参数,第一个是当前类,第二个参数传true,第三个参数传false。
为什么最终会调用_objc_rootAlloc?我们先来静态分析一下。因为Person对象没有实现alloc方法,所以我们找不到alloc的实现。然后我们会从Person的父类中检查该方法是否实现。 Person的父类是NSObject,所以会在NSObject的方法列表中查找。恰好NSObject实现了alloc方法,alloc中调用了_objc_rootAlloc。
我们将之前的Person类添加到objc项目中并运行来验证一下。
请注意这次callAlloc传递的最后两个参数。它们与上次通过的不同。
callAlloc(cls,false/*checkNil*/,true/*allocWithZone*/);
这次第二个参数传的是false
第三个参数传true
最后调用_objc_rootAllocWithZone函数,在消息转发过程中实际写入自定义allocWithZone标志。
继续_class_createInstanceFromZone
截图中对关键功能进行了注释和说明,其中三个关键功能用红线画出了。
cls-instanceSize(extraBytes)
现在最后一个红线是判断大小是否小于16,如果小于16,则直接赋值16。这意味着对象的最小内存大小是16 字节。
那么传入extraBytes时,为0,所以size=alignedInstanceSize;
alignedInstanceSize在内部调用word_align()。为什么这个功能这么熟悉?
word_align,我们之前分析过,是传入值的8字节对齐。
那么unalignedInstanceSize()是什么?
原来我们去ro获取instanceSize。 ro第一次转发消息时,会调用initialize初始化isa,然后ro的instanceSize就有值了。
if( fastpath( cache.hasFastInstanceSize( extraBytes ) ) ) {
返回cache.fastInstanceSize( extraBytes );
}
我们进入hasFastInstanceSize方法看看
__builtin_constant_p(extra) 确定extra 是否是编译常量。从整个方法中得到的就是这个方法判断是否有缓存。如果您知道如何确定它,我稍后会添加更多详细信息。返回到instanceSize方法。如果有缓存,就返回缓存,返回大小会被调用。看一下fastInstanceSizefan 方法。注意最后一个align16方法是保证返回的内存地址大小始终是16字节的倍数的算法。这是十六进制内存对齐。
(id)calloc(1, size) 重点!!!
兜兜转转最后还是回到了libmalloc库,下载了该库,然后全局搜索calloc,最后找到了下图的具体实现
上面的default_zone是一个默认的空间大小,目的是引导程序进入创建真实zone的过程; size 是我们传入的空间的大小。
红线标记的位置是真正申请内存的函数调用,但是右键跳转已经无法进行,全局搜索也找不到。
唯一的方法是使用断点进行调试。在zone-calloc 代码处放置一个断点。当程序执行到断点时,有两种方式查看zone-calloc源码实现。
1、按control+step into进入calloc的源码实现(需要开启Debug汇编模式)
2、在控制台输入lldb命令p zone-callocde查找源码实现。
全局搜索default_zone_calloc方法找到具体实现
default_zone_calloc内部调用runtime_default_zone获取zone,然后进行一个zone-calloc(肯定不能跳进去查看源码,崩溃了)。内部创建的zone类型决定了后续的zone-calloc调用,那么这个zone到底是什么?什么,被调用的calloc将被重定向到哪个函数?让我们看看是否可以从runtime_default_zone获取任何有用的信息
当malloc_zones执行到这里,已经有值了,说明zone已经创建了,是在_malloc_initialize_once里面创建的。在_malloc_initialize_once 内部,实际上有一个对_malloc_initialize 的调用,并且区域是在该函数内部创建的。
_malloc_初始化
功能比较长,所以我选择了重要的部分并进行了截图。
设置Libsystem中获取的相关环境变量nano_create_zone,并在内部申请空间。
nano_common_allocate_based_pages函数内部调用mach内核来分配物理内存页。
由此我们知道了default_zone_calloc中最后一个zone-calloc的真面目。
ok,我们用lldb命令p zone-calloc来验证一下
完美,成就感油然而生! (其实前面的zone-calloc也可以从源码中分析得到default_zone,有兴趣的读者可以自行分析)
全局搜索nano_calloc
找到key_nano_malloc_check_clear
_nano_malloc_check_clear的源码很长,但是我们要找的是分配的空间大小,所以只看segreated_size_to_fit的内部实现,后续代码就不分析了!
看到这里,大家应该能找到规律了,右移再左移。这不是典型的内存对齐操作吗?
我们来看看NANO_REGIME_QUANTA_SIZE 的值是多少
评论很清楚,就是16
结论:calloc内部对传入的对象分配内存是以16位为对齐的。
malloc_size获取的内存大小就是系统分配给对象的内存大小,也就是说malloc_size返回的内存大小等于对象成员变量需要的内存大小size进行16字节对齐后的值。
obj-initInstanceIsa(cls, hasCxxDtor)
该函数调用initIsa(cls,true, hasCxxDtor);
在函数内部,首先定义了isa_t类型的newisa(0)。看最后一段代码isa=newisa;这意味着这就是我们正在寻找的isa。红线的位置其实就是对isa的初始化赋值。
我们看看isa_t是什么类型?
工会也称为公共机构。其实在之前APP启动的分析中已经提到过union。
联合是一种结构:
1、其所有成员相对基地址的偏移量均为0;
2、结构空间应足够大,以容纳“最宽”的构件;
3、其排列方式应适合所有成员;
ISA_BITFIELD 位域
有些信息在存储时,并不需要占用一个完整的字节,而只需占用几个或一个二进制位。例如,存储开关值时,只有两种状态:0和1,因此只需使用一位二进制位即可。节省存储空间并方便搬运。
了解了这些基本信息后,我们回过头来继续分析isa联盟:
isa_t(){} 是初始化方法
类cls 绑定类
uintptr_t 位typedef unsigned long long 8 字节
有一个struct结构体,其中包含ISA_BITFIELD(这里之所以使用宏定义是因为需要根据系统架构来区分)
我们来看一下具体的位域
nonpointer:表示是否启用isa指针的指针优化; 0表示纯isa指针,1不仅表示类对象指针,还表示类信息、对象引用计数等;
has_assoc:关联对象标志,0不存在,1存在
has_cxx_dtor:对象是否具有C++ 或Objc 析构函数。如果有析构函数,就需要做析构逻辑。如果没有,可以更快地释放该对象。
shiftcls:存储类指针的值。当指针优化开启时,arm64架构中使用33位来存储类指针。
magic:调试器用来判断当前对象是真实对象还是未初始化的空间
weakly_referenced:对象是否指向或曾经指向ARC变量。没有弱引用的对象可以更快地释放。
释放:识别对象是否正在释放内存
has_sidetable_rc:当对象引用技术大于10时,需要将extra_rc的一半转移到sidetable
extra_rc:表示对象的引用计数值,实际上就是引用计数值减1
isa指针之所以这样设计,是为了优化性能和节省空间。指针有8个字节,64位,但是一个简单的地址指针无法占用那么多空间,留空就会浪费,所以将对象的其他信息添加到空闲空间中以节省空间。
最后在掘金上找到了一张大佬做的图,很好的解释了isa的各个位域的位置。
4、传说中的isa里面到底存储了什么东西?
这个问题大家一定都能回答出来。分析initInstanceIsa其实就相当于回答问题4。
最后:isa是通过什么操作拿到cls指针的?
其实很简单,通过前面的mask即可
取出位并使用ISA_MASK 进行操作
ISA_MASK的值在不同的架构中是不同的,因为不同架构中isa的位域不同。
真机arm64架构下ISA_MASK0x0000000ffffffff8ULL
模拟器arm64架构下ISA_MASK0x007ffffffffffff8ULL
mac X86_64架构下ISA_MASK0x00007ffffffffff8ULL
【深入探索iOS核心:OC对象本质与底层原理详解(一)】相关文章:
用户评论
终于来学习iOS底部了!这个系列太赞了!
有7位网友表示赞同!
OC对象是什么?一直没有深入了解,这次好好学习一下。
有12位网友表示赞同!
对新手友好的讲解能让我更有信心去学iOS开发。
有19位网友表示赞同!
学习基础知识总是很重要的,尤其是底层。能打好基础才能更好地开发更复杂的项目。
有10位网友表示赞同!
这篇系列的文章绝对是入门iOS开发的必备资料!
有12位网友表示赞同!
喜欢这种循序渐进的讲解方式,很容易理解复杂的概念。
有9位网友表示赞同!
现在很多人都说掌握底层知识能提升代码效率,看来是真的。
有11位网友表示赞同!
我一直在想学习OC对象到底从哪里开始,这个系列帮我解答了我一个难题!
有6位网友表示赞同!
期待后续的文章,希望能更深入地了解iOS开发的底层原理。
有13位网友表示赞同!
看了标题就感觉很有干货!希望内容能够详细阐述OC对象的本质。
有20位网友表示赞同!
学习iOS开发是一个漫长的过程,从基础开始很重要。
有19位网友表示赞同!
这个系列可以帮助我更好地理解Objective-C语言的运行机制。
有12位网友表示赞同!
iOS开发越来越热门了,多学一些底层知识才能脱颖而出。
有6位网友表示赞同!
感觉学习iOS开发是一个很烧脑的事情,需要不断地总结和反思。
有17位网友表示赞同!
我会把这个系列分享给我的同学,希望能一起深入学习iOS开发。
有14位网友表示赞同!
期待作者能多些实践案例,这样能更直观地理解OC对象的作用。
有18位网友表示赞同!
感谢作者分享这些有价值的知识,希望你能继续创作更多干货!
有19位网友表示赞同!
学习了这个系列之后,我相信我能写出更优秀的iOS应用程序。
有15位网友表示赞同!
对感兴趣的朋友,一定要去看一下这个系列文章!
有10位网友表示赞同!