iOS之武功秘籍①:OC对象原理-上(alloc & init

2021-02-18  本文已影响0人  長茳

iOS之武功秘籍 文章汇总

写在前面

春节的夜晚,十分的难以入睡,梦醒时分,翻开秘籍最新objc4-818.2源码,有个小伙在渐渐的发着呆......

一、探索的线索和方向

拿到秘籍的那一刻,脑子就一直在高速的运转着,要怎么才能学好呢?

我们想着手开始探索"武林绝学"(iOS的底层),但又不知道从哪里开始,怎么办呢?

那就从main函数入手!

我们先开启上帝视角!来观察一个粗略的加载流程.进行准备工作:

嗯哼,我们都知道main函数是非常之早的,但是结果告诉我们在main函数之前,系统还做了其他的!!那么在main函数之前还有什么呢?来我们来瞧瞧

然后我们来运行一下程序看看:

此时会来到我们下的第一个符号断点libSystem_initializer,通过堆栈信息我们会看到程序会来到非常著名的dyld,经过一系列流程后在来到libSystem_initailizer.这也就从dyld来到了libSystem库.

接下来会来到我们的第二个符号断点libdispatch_init,也就来到了libdispatch库了

libdispatchGCD的源码,我们后续在研究这个.
过掉这个断点来到我们下的第三个符号断点_objc_init,也就来到了libobjc的底层,它是整个一个runtime的一些源码.

过完以上三个断点才会来到我们熟悉的main函数.

过掉main函数的断点就会来到我们熟悉的了

走完这些流程,可能有些小可爱会问?咦,你这咋有这么详细的堆栈信息呢?

只需关闭 Xcode 左侧 Debug 区域最下面的第一个按钮就行 show only stack frames with debug symbols and between libraries

Snip20200918_56.png

到此我们来总结一波.

通过以上的堆栈信息,我们可以总结一个简单的加载流程:

这里面的分析角度和思维都是比较有意思的,为了让大家有比较好的体验感.接下来,我们先从大家都比较熟悉的OC对象开始分析吧.

二、alloc原理初探 一 OC对象的alloc

我们要研究对象,肯定要从创建开始研究的!下面我有一个非常有意思的提问,小伙伴们不妨花个十秒钟思考一下!来代码如下:


%@ 打印对象 %p 打印地址 &p 指针地址

问题:
1.这里p1对象是否创建完成
2.p1、p2、p3以及p4是否为同一个对象


不知道你脑海中的答案是否和上面的打印一致:

嗯哼,alloc出来就已经把对象的内存地址确定了,那么是怎么确定的呢?下面开始探索

发现进不去了,怎么办?看不到具体的源码实现!很多时候我们经常也会遇到这样的情况,就是想做一些事,就是碰壁,无从下手!大家请注意这里:我要开始装逼咯!

三、alloc底层探索思路(底层探索分析的三种方法)

下面介绍三种方式来查看他的实现.

方法一:符号断点直接定位

添加alloc符号断点(在前面(探索的线索和方向)已经介绍了怎么加符号断点)

结果如下


方法二:代码跟踪 - control + step into

进去后可以看到objc_alloc

方式三:汇编进入分析

之后继续按住control键和step into键得到:

之后需要添加objc_alloc符号断点后,点击 Xcode日志栏的继续运行按钮

此时此刻,还有谁!就这些东西能难倒我们?不存在的

四、alloc流程分析

①.汇编配合源码跟流程

通过前面alloc底层探索思路(底层探索分析的三种方法)的介绍,我们知道了三种探索底层实现的方法,那我们来玩一玩.
我们打开准备好的可编译的objc4源码
我们刚刚前面查到了alloc流程,我们在源码里面搜索一下:

在源码里面看到了alloc方法,我的天,好高兴啊,来到这里就有底层的实现.我们点击_objc_rootAlloc方法来到:

继续点击callAlloc方法来到:

到这的源码可能就会让你头晕目眩,不想看了

本来看源码就枯燥,还有这么多if-else逻辑岔路口,就会有很多人关闭了Xcode.

看啥不好看源码,是嫌自己头发太旺盛吗?

别急,我这里已经帮你掉过头发了(捋过思路了)

那么他到底走的是哪一个流程呢?我们来验证一下

汇编和源码同步辅导来跟流程

来我们根据刚刚看的源码来捋个草图:


根据源码我们知道在callAlloc的时候出现了分叉:objc_msgSend_objc_rootAllocWithZone,那么他到底是往那个分叉走的呢?根据刚刚我们的走的汇编,我们得到的是走的_objc_rootAllocWithZone.
而我们跑汇编跟流程的时候,只断了两下即:objc_rootAlloc直接来到了_objc_rootAllocWithZone.然后callAlloc这个断点变没有断住?为什么呢?请看下文

②.编译器优化

我们先来看下面的例子(使用真机调试,看汇编): 运行程序得到汇编代码:

看到结果有些小伙伴可能会问?为什么有wx呢?
这涉及到寄存器的知识.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]模式:

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源码来帮助我们.

由此可以得出当我们调用alloc方法时会调用 objc_alloc,其实这部分是由系统在llvm底层帮我们转发到objc_alloc的.llvm在我们编译启动时,就已经处理好了.

我们来验证一下:

③.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方法.

自定义类第一次进入callAllocmsgSend消息发送流程:

第二次进入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字节对齐之前,首先需要了解内存字节对齐的原则,主要有以下三点:

为什么需要16字节对齐

下面以align16(size_t 8)->(8 + size_t(15)) & ~size_t(15)为例,图解16字节对齐算法的计算过程,如下所示

calloc()

用来动态开辟内存,返回地址指针.没有具体实现代码,接下来的文章会讲到malloc源码

(这里的zone基本是不会走的,苹果废弃了zone开辟空间,并且这里zone的入参传入的也是nil

根据size = cls->instanceSize(extraBytes)计算的内存大小,向内存中申请大小为size的内存,并赋值给obj.

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 又做了什么?

但是一般在开发过程中不建议使用new,主要是因为有时会重写init方法做一些自定义的操作.

写在后面

最后我们来一起解答前面最开始留下的两个问题:

解答:

问题1:p1、p2、p3对象和地址打印都一致, 为何&p打印不一致?
其实说白了alloc就做到了对象指针的确定,我们开辟内存真正的家伙就是alloc. 他们的指针都是同一个,但是因为都是不同对象接受而已,所以执行不同的地址,即&p打印的是他们自身的地址
问题二:p4的地址为什么和p1、p2、p3都不一样?
因为p1、p2、p3是同一个alloc开辟出来的,而p4是new出来的,new会单独调用alloc. 所以他们打印肯定不一样.

总结:

上一篇 下一篇

猜你喜欢

热点阅读