探究OC对象的本质(上)

331 阅读8分钟

alloc做了什么?

我们在实例化对象的时候,通常会调用alloc方法和init方法

Person *p1 = [Person alloc];
Person *p2 = [p1 init];
Person *p3 = [p1 init];
NSLog(@"p1: %@--%p--%p",p1, p1, &p1);
NSLog(@"p2: %@--%p--%p",p2, p2, &p2);
NSLog(@"p3: %@--%p--%p",p3, p3, &p3);

打印结果为:

p1: <Person: 0x600002f5c020>--0x600002f5c020--0x16b09bfc8
p2: <Person: 0x600002f5c020>--0x600002f5c020--0x16b09bfc0
p3: <Person: 0x600002f5c020>--0x600002f5c020--0x16b09bfb8

由此结果可以看出,p1,p2,p3指向了同一块内存区域,如图所示:

截屏2022-04-13 下午4.15.01.png

而且我们可以发现,三个指针的地址是连续的,因为在栈上,且相差八个字节,可以得出一个对象的指针大小为8字节。

如何找到对应的源码?

上面的探索初步可以得出,alloc会在堆上申请空间,但是具体怎么做的,我们就需要对底层进行探究。但是我们发现苹果并没有对alloc的实现在文档有详细的说明。有三种方式可以继续探究:

1、通过符号断点去定位

  • 添加符号断点:

截屏2022-04-13 下午4.35.53.png

截屏2022-04-13 下午4.30.57.png

  • 运行后发现,调用alloc的地方很多,无法区分是我们需要的。

截屏2022-04-13 下午4.38.20.png

  • 可以先把该断点放行,在我们需要的地方打上断点,当断点到了我们需要的地方再将符号断点恢复,继续放行,这样就可以查看指定位置的符号断点了

截屏2022-04-13 下午4.40.14.png

截屏2022-04-13 下午4.41.50.png

截屏2022-04-13 下午4.42.12.png

截屏2022-04-13 下午4.46.17.png

截屏2022-04-13 下午4.42.12.png

截屏2022-04-13 下午4.47.58.png

从最后一张图片可以得出两个信息: 1、实际上调用的是NSObject的alloc方法,因为Person类继承自NSObject,父类实现了该方法,Person类并未实现。 2、alloc方法所在的动态库名为libobjc.A.dylib

step into: 截屏2022-04-13 下午8.59.02.png

2、通过control+step into和符号断点

  • 先在需要探究的地方打上断点,运行程序 截屏2022-04-13 下午4.41.50.png

  • 断点进行到对应位置,按住control键,点击如图位置

截屏2022-04-13 下午9.37.55.png 截屏2022-04-13 下午9.36.11.png

截屏2022-04-13 下午9.37.55.png

  • 接着下符号断点,会显示该符号所在动态库的名字(我用的12.3版本的Xcode没有显示,不知道为什么)

3、通过汇编跟流程

  • 同样先下断点
  • 如图处选择,运行程序 截屏2022-04-13 下午9.47.20.png

截屏2022-04-13 下午9.49.55.png

  • 找到符号后,如法炮制。

源码的探索

接着我们就去找苹果开源代码: opensource.apple.com/tarballs/

也可以去github上的苹果的仓库里面去找: github.com/apple-oss-d…

目前我们用838版本,打开源码后,我们全局搜索alloc {,

截屏2022-04-13 下午10.45.26.png

点进去,

截屏2022-04-13 下午10.46.13.png

发现调用了_objc_rootAlloc方法,jump to Definition不起作用,因为还没编译,就先通过全局搜索的方式搜索该方法

截屏2022-04-13 下午10.48.21.png

截屏2022-04-13 下午10.49.58.png

如法炮制,找callAlloc方法

截屏2022-04-13 下午10.51.21.png

但是我们发现这个方法中出现了条件分支,这样就不知道该走哪条分支。怎么办呢

从上面的探索中我们得到了几个符号:_objc_rootAlloc, callAlloc, _objc_rootAllocWithZone, 我们可以通过在测试项目中打符号断点的方法进行流程跟随,从而确定它的调用流程。

截屏2022-04-13 下午11.01.31.png

这里注意,在运行程序前先把符号断点关掉,为了避免无谓的断点干扰,因为程序中不止一个地方调用了alloc,当程序运行起来,断点断到目标位置后再打开符号断点,接着放行断点,会发现断点分别会在_objc_rootAlloc,_objc_rootAllocWithZone处停住,也就说明了该调用流程为alloc --> _objc_rootAlloc --> _objc_rootAllocWithZone,可是我们在源码中却看到明明调用了callAlloc方法,这里就牵扯到了编译器优化。

编译器优化

如图代码,打开汇编选项,运行程序

截屏2022-04-14 下午2.22.27.png

截屏2022-04-14 下午2.22.02.png

汇编如下:

截屏2022-04-14 下午2.23.40.png

其中w开头的指的是32位寄存器,x开头的指的是64位寄存器,可是我们运行的是64位系统,为啥会有32位寄存器,这是因为int类型的4个字节,32位足够,这样可以避免无谓的浪费。

0x100031e60 <+56>: mov    w8, #0xa表示将10赋值给w8寄存器。 0x100031e64 <+60>: str    w8, [sp, #0xc]表示将w8寄存器的值,传送到地址值为[sp, #0xc](sp表示堆栈指针,后面的表示偏移量)的(存储器)内存中。 0x100031e70 <+72>: ldr    w8, [sp, #0xc]表示读取该地址值的值到w8中。

我们可以通过register read xxxlldb命令读取寄存器的值;

我们在Build Settings中的Optimization Level选项中,可以看到编译器在Release下是Fastest,Smallest,我们把Debug也改成一样的选项。

截屏2022-04-14 下午2.33.15.png 截屏2022-04-14 下午2.30.16.png

运行程序,会发现汇编变少了,这就是所谓的编译器优化。

截屏2022-04-14 下午2.33.57.png

可以联想到上面我们在探索alloc的源码调用流程中的callAlloc方法,也是被编译器优化掉了,这样可以节省性能。

截屏2022-04-14 下午3.26.09.png

我们可以自己在代码中运用slowpath以及fastpath来优化代码的调用,详细介绍参考:www.jianshu.com/p/536824702…

字节对齐

我们通过对可编译源码跟流程发现alloc的调用流程大致为:

截屏2022-04-15 上午10.54.12.png

static inline size_t align16(size_t x) {
    return (x + size_t(15)) & ~size_t(15);
}

以上为字节对齐的代码,当传进来的值为8时(size_t(15)为15,可通过po size_t(15)得到。~表示取反):也就是(8+15) 15的取反,换算成二进制为:

0000 0000 0001 0111   23
&
1111 1111 1111 0000   15取反
=
0000 0000 0001 0000   16

0000 0000 0000 1111   15

可以看出这个算法最后得到的结果都会是16的倍数。 那为什么要16字节对齐呢?一个对象至少有一个isa指针,而一个isa指针的大小为8个字节,也就是说一个什么都没有的对象的大小为八字节,也就是对象最小为8字节,而我们研究最多的就是对象,如果规定其大小的时候则对其最小研究对象大小进行翻倍。而且系统在读取的时候,每次读取的大小是固定的,每次8字节,这样更快速便捷,所以8的倍数会更合理,设置为16则是为最小研究对象预留出空间,避免读取的时候读取错误。这也是一种空间换时间的策略。

由上面可以得出,alloc做了什么事情:开辟内存空间,关联类。

alloc,init和new的区分

+ (id)init {
    return (id)self;
}

这是init的源码,返回了id类型的自身。那为什么前面进行了类关联,这里为什么又要强转为id类型呢,这是因为这个方法是给程序员提供的构造方法,子类可以重写该方法,也就是工厂方法。

+ (id)new {
    return [callAlloc(self, false/*checkNil*/) init];
}

callAllocalloc的内部方法,这里说明new == [[Class alloc] init]

不建议直接new,因为我们有可能会写带有自己参数的初始化方法。

对象开辟内存的影响因素

我们创建一个Person类,不添加任何东西,我们对源码打断点,这里注意先让断点走到我们需要调试的地方,再打开获取申请空间的断点,原理和前面一样。

截屏2022-04-15 下午1.36.21.png

可以看出其大小为16字节,因为只有一个isa指针,16字节对齐的规则,所以是16字节。

接下来我们为其添加两个字符串类型的属性。

截屏2022-04-15 下午1.39.21.png

截屏2022-04-15 下午1.39.11.png

然后我们走断点,会发现其size是32

截屏2022-04-15 下午1.40.47.png

isa占用8个字节,两个字符串类型2*8=16字节。实际大小为8+2*8=24字节,16字节对齐的原则,所以为32字节.

我们重新运行程序,打上断点 截屏2022-04-15 下午1.51.32.png

输入lldb命令x p:输出p对象的内存分布:

截屏2022-04-15 下午1.53.20.png

但是这样看着并不清晰,我们使用x/4gx p(x/5gx):x表示16进制输出,4表示4个内存单元,g表示8字节,x表示16进制:

截屏2022-04-15 下午1.55.02.png

iOS是小端模式,从右往左读:高字节在高地址,低字节在低地址。按照数字的读法,左边的数字会是高位,高字节就指的是左边的字节,低字节指的是右边的,一般来说,低地址在左边,高地址在右边,具体参考该篇文章:blog.csdn.net/ALakers/art…

第一个地址肯定是isa指针,但是目前打印出来的却不是对象的指针地址,这是因为有一个Mask,下篇文章解释。

我们对第二个地址和第三个地址进行打印:

截屏2022-04-15 下午1.57.33.png

OC对象的本质为结构体objc_object,对象中一般会存储isa指针以及成员变量的值。

关于汇编的一些知识:
b,bl 跳转,相当于函数调用
ret 返回
; 注释
寄存器 运算器 控制器
register read x0 打印返回值
register read x1 打印调用方法,需要强转为char*