写在前面
春节的夜晚,十分的难以入睡,梦醒时分,翻开秘籍最新objc4-818.2源码,有个小伙在渐渐的发着呆......
一、探索的线索和方向
拿到秘籍的那一刻,脑子就一直在高速的运转着,要怎么才能学好呢?
我们想着手开始探索"武林绝学"(iOS
的底层),但又不知道从哪里开始,怎么办呢?
那就从main
函数入手!
我们先开启上帝视角!来观察一个粗略的加载流程.进行准备工作:
- 在
main
函数中直接打断点,然后我们这时打印一下堆栈信息瞧瞧(bt
-lldb
调试指令打印堆栈信息)
嗯哼,我们都知道main
函数是非常之早的,但是结果告诉我们在main
函数之前,系统还做了其他的!!那么在main
函数之前还有什么呢?来我们来瞧瞧
-
添加三个符号断点
libSystem_initializer
、libdispatch_init
、_objc_init
我们按上图操作依次添加好
libSystem_initializer
、libdispatch_init
、_objc_init
符号断点
此时会来到我们下的第一个符号断点libSystem_initializer
,通过堆栈信息我们会看到程序会来到非常著名的dyld
,经过一系列流程后在来到libSystem_initailizer
.这也就从dyld
来到了libSystem
库.
接下来会来到我们的第二个符号断点libdispatch_init
,也就来到了libdispatch
库了
而libdispatch
是GCD
的源码,我们后续在研究这个.
过掉这个断点来到我们下的第三个符号断点_objc_init
,也就来到了libobjc
的底层,它是整个一个runtime
的一些源码.
过完以上三个断点才会来到我们熟悉的main
函数.
过掉main
函数的断点就会来到我们熟悉的了
走完这些流程,可能有些小可爱会问?咦,你这咋有这么详细的堆栈信息呢?
只需关闭
Xcode
左侧Debug
区域最下面的第一个按钮就行show only stack frames with debug symbols and between libraries
到此我们来总结一波.
-
dyld
启动加载动态库、共享内存、全局C++对象的构造函数的调用、一系列的初始化、dyld注册回调函数 -
libsystem
的初始化libSystem_initializer
-
libdispatch_init
队列环境的准备 - 从
_os_object_init
过渡到_objc_init
- 以及
_dyld_objc_notify_register
镜像文件的映射 - 类-分类-属性-协议-SEL-方法 的加载
- 展开分析
Runtime
各个部分的原理 -
main
函数的启动
这里面的分析角度和思维都是比较有意思的,为了让大家有比较好的体验感.接下来,我们先从大家都比较熟悉的OC对象开始分析吧.
二、alloc原理初探 一 OC对象的alloc
我们要研究对象,肯定要从创建开始研究的!下面我有一个非常有意思的提问,小伙伴们不妨花个十秒钟思考一下!来代码如下:
%@ 打印对象 %p 打印地址 &p 指针地址
问题:
1.这里p1对象是否创建完成
2.p1、p2、p3以及p4是否为同一个对象
不知道你脑海中的答案是否和上面的打印一致:
- 从上面可以得出我们创建了四个临时对象p1、p2、p3、p4
- p1、p2、p3这三个对象的指针是不同的但是他们所指向的内存是同一片,而p4对象的指针和他所指向的内存地址都和p1、p2、p3不同(为什么呢? - 看完本编你就知道为什么了)
- 遗留问题:
- ①.p1、p2、p3对象和地址打印都一致, 为何&p打印不一致?
- ②.p4的地址为什么和p1、p2、p3都不一样?
- 从反向可以证明
alloc
才是创建对象-开辟内存 -
init
只是一个初始化构造函数. -
new
又alloc
出了另一内存空间
嗯哼,alloc出来就已经把对象的内存地址确定了,那么是怎么确定的呢?下面开始探索
现在我们跳进这个万恶之源(通过
Command+单击->Jump to Defintion
的方式进入)-
发现跳不进去查看实现,怎么办,请来到 objc4官方源码或objc4小编配好可运行的源码,接下来几天都会动不动就进去了!!我希望每一个小伙伴都不要只在这外面蹭一蹭,深层交流才有意义
没有注释
没有源码实现
更加不知道下一步流程
发现进不去了,怎么办?看不到具体的源码实现!很多时候我们经常也会遇到这样的情况,就是想做一些事,就是碰壁,无从下手!大家请注意这里:我要开始装逼咯!
三、alloc底层探索思路(底层探索分析的三种方法)
下面介绍三种方式来查看他的实现.
方法一:符号断点直接定位
添加alloc
符号断点(在前面(探索的线索和方向)已经介绍了怎么加符号断点)
- 先将
alloc
符号断点先置灰(alloc
函数在很多地方被调用,在到达我们目标位置前,先置灰) -
Xcode
开启运行,程序到达[TCJPerson alloc]
断点后,开启alloc
符号断点 - 点击
Xcode
日志栏的继续运行按钮
结果如下
-
[NSObject alloc]
成功看到所在链接库libobjc.A.dylib
- 其底层调用的就是
_objc_rootAlloc
函数
方法二:代码跟踪 - control + step into
-
①关掉之前的相关符号断点,来到研究对象断点处
-
②按住键盘
control
键+鼠标点击Xcode
日志栏的step into
按钮
进去后可以看到objc_alloc
-
③如果你是用真机的请继续第二部的操作,后来到
-
④如果你是用模拟器的话,在第二部后需要添加
objc_alloc
符号断点后,点击Xcode
日志栏的继续运行按钮 ⑤不管你是真机还是模拟器最终都来到了
libobjc.A.dylib
,进而也看到了底层objc_alloc
⑥和方法一不谋而合
方式三:汇编进入分析
①关闭其他的符号断点,来到研究对象断点
-
②
Xcode
工具栏 选择Debug
-->Debug Workflow
-->Always Show Disassembly
,这个 选项表示 始终显示反汇编 ,即 通过汇编 跟流程 ③在汇编显示16行处添加断点到
objc_alloc
-
④如果你是用真机操作,按住
control
键和step into
键结果如下:
之后继续按住control
键和step into
键得到:
- ⑤如果你是用模拟器的话,在第三步添加符号断点后,按住
control
键和step into
键结果如:
之后需要添加objc_alloc
符号断点后,点击 Xcode
日志栏的继续运行按钮
- 嗯哼
libobjc.A.dylib - objc_alloc:
也就轻松得到!
此时此刻,还有谁!就这些东西能难倒我们?不存在的
四、alloc流程分析
①.汇编配合源码跟流程
通过前面alloc底层探索思路(底层探索分析的三种方法)的介绍,我们知道了三种探索底层实现的方法,那我们来玩一玩.
我们打开准备好的可编译的objc4源码
我们刚刚前面查到了alloc
流程,我们在源码里面搜索一下:
在源码里面看到了alloc
方法,我的天,好高兴啊,来到这里就有底层的实现.我们点击_objc_rootAlloc
方法来到:
继续点击callAlloc
方法来到:
到这的源码可能就会让你头晕目眩,不想看了
本来看源码就枯燥,还有这么多if-else
逻辑岔路口,就会有很多人关闭了Xcode
.
看啥不好看源码,是嫌自己头发太旺盛吗?
别急,我这里已经帮你掉过头发了(捋过思路了)
那么他到底走的是哪一个流程呢?我们来验证一下
汇编和源码同步辅导来跟流程
在我们的第一份代码里面
加入
我们刚刚捋过的三个符号断点_objc_rootAlloc
、callAlloc
、_objc_rootAllocWithZone
.-
先关闭符号断点,来到我们的研究对象断点处
-
打开我们刚刚下的三个符号断点,来到第一个符号断点
_objc_rootAlloc
: -
过掉此
_objc_rootAlloc
断点来到了_objc_rootAllocWithZone
断点:
来我们根据刚刚看的源码来捋个草图:
根据源码我们知道在callAlloc
的时候出现了分叉:objc_msgSend
和_objc_rootAllocWithZone
,那么他到底是往那个分叉走的呢?根据刚刚我们的走的汇编,我们得到的是走的_objc_rootAllocWithZone
.
而我们跑汇编跟流程的时候,只断了两下
即:objc_rootAlloc
直接来到了_objc_rootAllocWithZone
.然后callAlloc
这个断点变没有断住?为什么呢?请看下文
②.编译器优化
我们先来看下面的例子(使用真机调试,看汇编):看到结果有些小伙伴可能会问?为什么有w
和x
呢?
这涉及到寄存器的知识.w
代表32位,x
代表64位.那为什么我们跑到真机上还有w
呢?这考虑到兼容问题,例如我们存储一个int = 10
类型的数据,在32位下就能存储,不需要用64位.
寄存器 - 其寄存器的作用就是进行数据的临时存储
- ARM64拥有有31个64位的通用寄存器 x0 到 x30,这些寄存器通常用来存放一般性的数据,称为通用寄存器(有时也有特定用途)
- 比如x0 ~ x7 用来存储参数,x0主要用来存储参数和接收返回值.
- 那么w0 到 w28 这些是32位的. 因为64位CPU可以兼容32位.所以可以只使用64位寄存器的低32位.
- 比如 w0 就是 x0的低32位!
- 通常,CPU会先将内存中的数据存储到通用寄存器中,然后再对通用寄存器中的数据进行运算
我们刚刚在 int a = 10
处打了一个断点,那么哪个代表他呢,我们打印一下:
接下来又来到 mov w9, #0x14
:
接下来来到add w9, w9, w10
即: 10 + 20 放到 w9
里面:
在正常开发过程中我们都是Debug
模式下,想要提高编译速度,可将Debug
环境也选中Fastest,Smallest[-OS]
模式:
-
target
->BuildSettings
: 搜索:optimization
我们发现Optimization Level
中,Release
环境下,已自动选择Fastest,Smallest[-OS]
- 接下来我们将
Debug
模式下也选中Fastest,Smallest[-OS]
模式:
在Fastest,Smallest[-OS]
模式下,会发现汇编页面展示的代码已精简很多:
那么Fastest,Smallest[-OS]
代表什么意思呢?就是按照最快最小的路径来执行.
在下来我们看源码的过程中都会看到有很多的过程都会被优化掉 - 这就是编译器的强大.
这也就是我们在发布版本的时候要调到Release
版本(现在苹果在我们发版的时候会自动帮我们选择Release
环境,早期的时候需要我们手动设置选择). 因为Release
环境下,系统自动选择Fastest,Smallest[-OS]
模式,完成编译器优化,节省性能.
③.alloc源码流程
我们先来看下面的代码
接下来我先给出他们各自调用alloc
方法后的堆栈详情图:
看到上面的调用堆栈图,我们不难发现两个问题:
问题一:不管我是NSObject
类,还是自定义的TCJPerson类
调用alloc
方法为什么最开始走的是objc_alloc
问题二:NSObject
没有走alloc
方法
问题三:自定义的TCJPerson
类为什么走了两次callAlloc
③.1 objc_alloc 方法
为什么首先会来到objc_alloc
?
第一处解释:源码中的Calls [cls alloc]
告诉我们,当我们调用alloc
方法时底层是调用
汇编代码也告诉我们首先调用的是objc_alloc
.
第三处解释:需要借助llvm
源码来帮助我们.
-
打开
llvm
源码文件(用Xcode
打开比较慢,可用Visual Studio Code
即VSCode
打开),搜索alloc
,找到CGObjC.cpp
文件 可以看到这里有明确标注,
[self alloc] -> objc_alloc(self)
-
函数中显示,当接收到
alloc
名称的selector
时,调用EmitObjCAlloc
函数.继续全局搜索EmitObjCAlloc
:
由此可以得出当我们调用alloc
方法时会调用 objc_alloc
,其实这部分是由系统在llvm
底层帮我们转发到objc_alloc
的.llvm
在我们编译
启动时,就已经处理好了.
我们来验证一下:
-
首先来我们的研究对象断点处:
-
接着在
objc4源码
中的objc_alloc
方法实现处打下断点:
结果都来到了
objc_alloc
方法,接着调用callAlloc
方法.那么问题一问题二的答案我们相信大家都知道了吧.
③.2 callAlloc 方法
①static ALWAYS_INLINE id
中的 ALWAYS_INLINE
说明
inline
是一种降低函数调用成本的方法,其本质是在调用声明为 inline
的函数时,会直接把函数的实现替换过去,这样减少了调用函数的成本. 是一种以空间换时间的做法.
#define ALWAYS_INLINE inline __attribute__((always_inline))
ALWAYS_INLINE
宏会强制开启inline
②if (slowpath(checkNil && !cls))判断
#define fastpath(x) (__builtin_expect(bool(x), 1))
#define slowpath(x) (__builtin_expect(bool(x), 0))
这两个宏使用__builtin_expect
函数
__builtin_expect(EXP, N)
__builtin_expect是gcc引入的
- 作用: 允许程序员将最有可能执行的分支告诉编译器.编译器可以对代码进行优化,以减少指令跳转带来的性能下降.即性能优化
- 函数: __builtin_expect(EXP, N) 表示 EXP==N的概率很大
fastpath
:定义中__builtin_expect((x),1)
表示 x
的值为真的可能性更大;即 执行if
里面语句的机会更大
slowpath
:定义中的__builtin_expect((x),0)
表示 x
的值为假的可能性更大。即执行 else
里面语句的机会更大
在日常的开发中,也可以通过设置来优化编译器,达到性能优化的目的,设置的路径为:Build Setting --> Optimization Level --> Debug --> 将None 改为 fastest 或者 smallest
(前面有介绍)
③if (fastpath(!cls->ISA()->hasCustomAWZ()))判断
跟进hasCustomAWZ()
实现可发现:
而FAST_CACHE_HAS_DEFAULT_AWZ
的定义为:
判断的主要依据:还是看缓存中是否有默认的alloc/allocWithZone
方法(这个值会存储在metaclass
中).
而对于NSObject
类而言就有少许不同了:因为NSObject
的初始化,系统在llvm
编译时就已经初始化好了.因此缓存中就有alloc/allocWithZone
方法了.即hasCustomAWZ()
为false
那么!cls->ISA()->hasCustomAWZ()
就为true
:
而我们自定义的TCJPerson
类初次创建是没有默认的alloc/allocWithZone
实现的。所以继续向下执行进入到msgSend
消息发送流程,调用[NSObject alloc]
方法,即就是alloc
方法,接着会来到_objc_rootAlloc
,后再次来callAlloc
,而这次因为调用的是NSObject
类的,所以缓存中存在alloc/allocWithZone
实现,接着走_objc_rootAllocWithZone
方法.
自定义类第一次进入callAlloc
走msgSend
消息发送流程:
第二次进入callAlloc
走_objc_rootAllocWithZone
:
到这也就解释了问题三:自定义的TCJPerson
类为什么走了两次callAlloc
.
③.3 alloc 方法
③.4 _objc_rootAlloc 方法
③.5 callAlloc 方法(自定义类二次进入)
调用 NSObject
的[NSObject alloc]
不会来到③.3-③.4-③.5这个流程,只有自定义的类TCJPerson
调用[TCJPerson alloc]
才会来到③.3-③.4-③.5这个流程
③.6 _objc_rootAllocWithZone 方法
③.7 _class_createInstanceFromZone 方法 (alloc的核心方法)
①hasCxxCtor()
hasCxxCtor()
是判断当前class
或者superclass
是否有.cxx_construct
构造方法的实现
②hasCxxDtor()
hasCxxDtor()
是判断判断当前class
或者superclass
是否有.cxx_destruct
析构方法的实现
③canAllocNonpointer()
canAllocNonpointer()
是具体标记某个类是否支持优化的isa
,即是对 isa
的类型的区分,如果一个类和它父类的实例不能使用 isa_t
类型的 isa
的话,返回值为 false
.在 Objective-C 2.0
中,大部分类都是支持的.
④size = cls->instanceSize(extraBytes)
instanceSize(extraBytes)
计算需要开辟的内存大小,传入的extraBytes 为 0
跳转至instanceSize
的源码实现
通过断点调试,会执行到cache.fastInstanceSize
方法
继续跟断点,进入align16
源码实现(16字节对齐算法):
既然提到了内存对齐(后面文章会详细讲解),那我们就来预热一下:
内存字节对齐原则
在解释为什么需要16字节对齐
之前,首先需要了解内存字节对齐的原则,主要有以下三点:
- 数据成员对齐规则:
struct
或者union
的数据成员,第一个数据成员放在offset
为0
的地方,以后每个数据成员存储的起始位置要从该成员大小或者成员的子成员大小(只要该成员有子成员,比如数据、结构体等)的整数倍开始(例如int
在32位
机中是4字节
,则要从4
的整数倍地址开始存储) - 数据成员为结构体:如果一个结构里有某些结构体成员,则结构体成员要从其内部最大元素大小的整数倍地址开始存储(例如:
struct a
里面存有struct b
,b
里面有char、int、double
等元素,则b
应该从8
的整数倍开始存储) - 结构体的整体对齐规则:结构体的总大小,即
sizeof
的结果,必须是其内部最大成员的整数倍
,不足的要补齐.
为什么需要16字节对齐
- 提高性能,加快存储速度:
通常内存是由一个个字节组成,cpu
在存储数据时,是以固定字节块为单位进行存取的.这是一个以空间换时间的一种优化方式,这样不用考虑字节未对齐的数据,极大节省了计算资源,提升了存取速度。 - 更安全
由于在一个对象中,第一个属性isa
占8字节
,当然一个对象可能还有其他属性,当无其他属性时,会预留8字节
,即16字节对齐
.因为苹果公司现在采用的16字节对齐
(早期是8字节对齐
--objc4-756.2及以前版本
),如果不预留,就相当于这个对象的isa
和其他对象的isa
紧挨着,在CPU
存取时它以16字节
为单位长度去访问的,这样会访问到相邻对象,容易造成访问混乱,那么16字节
对齐后,可以加快CPU
读取速度,同时使访问更安全,不会产生访问混乱的情况
下面以align16(size_t 8)->(8 + size_t(15)) & ~size_t(15)
为例,图解16字节对齐算法的计算过程,如下所示
- 首先将原始的内存
8
与size_t(15)
相加,得到8 + 15 = 23
其二进制:0000 0000 0001 0111
- 将
size_t(15)
即15的二进制
:0000 0000 0000 1111
进行~(取反)
操作其取反二进制为:1111 1111 1111 0000
,~(取反
)的规则是:1变为0,0变为1
- 最后将
23的二进制
与15的取反结果的二进制
进行&(与)
操作,&(与)
的规则是:都是1为1,反之为0
,最后的结果为0000 0000 0001 0000
即16
(十进制),即内存的大小是以16的倍数增加的
.
⑤calloc()
用来动态开辟内存,返回地址指针
.没有具体实现代码,接下来的文章会讲到malloc
源码
(这里的zone
基本是不会走的,苹果废弃了zone
开辟空间,并且这里zone
的入参传入的也是nil
)
根据size = cls->instanceSize(extraBytes)
计算的内存大小,向内存中申请大小为size
的内存,并赋值给obj
.
- 执行前打印
obj
只有cls类名
,执行后打印,已为成功申请内存的首地址了. - 但并不是我们想象中的格式
<TCJPerson: 0x0000000101906140>
,这是因为这一步只是单纯的完成内存申请,返回首地址
. - 而类和地址的关联:是在接下来我们要说的
obj->initInstanceIsa(cls, hasCxxDtor)
完成
⑥obj->initInstanceIsa(cls, hasCxxDtor)
类与isa
关联
已知
zone=false,fast=true,则(!zone && fast)=true
内部调用initIsa(cls, true, hasCxxDtor)
初始化isa
指针,并将isa
指针指向申请的内存地址,在将指针与cls
类进行关联(具体的isa
结构和绑定关系,后续会作为单独章节进行讲解)
经过initIsa
后,打印obj
,此时发现地址与类完成绑定:
在_class_createInstanceFromZone中,主要做了3件事,1.计算对象所需的空间大小;2.根据计算大小开辟空间,返回地址指针;3.初始化isa,使其与当前对象关联
到此处一个TCJPerson
对象就创建完成了.
五、init源码分析
那么init
做了什么?
init
什么也不做,就是给开发者使用工厂设计模式提供一个接口
补充:关于子类中if (self = [super init])
为什么要这么写——子类先继承父类的属性,再判断是否为空,如若为空没必要进行一系列操作了直接返回nil
.
就是一个初始化的构造方法!提供构造能力:比如array初始化 字典 还有button 这就是给工厂设计!
六、new源码分析
那么 new
又做了什么?
- 底层就是调用了
alloc
下层的callAlloc
创建对象 - 然后调用了
init
的初始化方法 -
new
方法也就是为了方便直接!
但是一般在开发过程中不建议使用new
,主要是因为有时会重写init
方法做一些自定义的操作.
写在后面
最后我们来一起解答前面最开始留下的两个问题:
- ①.p1、p2、p3对象和地址打印都一致, 为何&p打印不一致?
- ②.p4的地址为什么和p1、p2、p3都不一样?
问题1:p1、p2、p3对象和地址打印都一致, 为何&p打印不一致?
其实说白了alloc
就做到了对象指针的确定,我们开辟内存真正的家伙就是alloc
. 他们的指针都是同一个,但是因为都是不同对象接受而已,所以执行不同的地址,即&p打印的是他们自身的地址
问题二:p4的地址为什么和p1、p2、p3都不一样?
因为p1、p2、p3是同一个alloc
开辟出来的,而p4是new
出来的,new
会单独调用alloc
. 所以他们打印肯定不一样.
总结:
- 对象的开辟内存交由
alloc
方法封装 -
init
只是一种工厂设计方案,为了方便子类重写:自定义实现,提供一些初始化就伴随的东西 -
new
封装了alloc 和init
- 这一篇里面也涉及了一些探索的思路和方法:
- 源码跟入
- 汇编分析
- 符号断点设置
- 和谐学习,不急不躁.我还是我,颜色不一样的烟火.