【iOS进阶必学】 对象及结构体内存的探究

2021-08-25 15:05:57 浏览数 (1)

前言

本篇就来探究一下,主要有以下几个方面:

  • 影响对象内存的因素
  • 结构体对齐原则及理解
  • 系统如何读取结构体的汇编验证
  • 对齐规则的设计意义

一、对象内存的探究

1.1 影响对象内存大小的因素

首先创建一个iOS工程,并创建一个 BPPerson 类(这个类会跟随我们很长一段时间先来看下这个类的相关属性)

此时在控制台中打印对象的大小为32

我们分别打开注释,增加 height 成员变量,并在 BPPerson.m 中增加 weight 属性,此时再打印结果如下

由此可以看出增加属性和成员变量,是会影响到对象内存大小的,下面我们分析下得出这两个值的原因:

OC对象默认会包含一个 isa,这是一个 Class 类型,是一个结构体指针,所以固定会先有 8 字节,再来分析下各个属性

注释 height 和 weight 时:

  • name:8 字节
  • title:8 字节
  • age:4 字节
  • inCharacter:1 字节
  • outCharacter:1 字节
  • 此时共 30 字节,字节对齐后为 32 字节

取消注释 height 和 weight 时:

  • height:8 字节
  • weight:4 字节

此时加上 12 字节为 42 字节,对齐后为 48 字节。

我们知道 OC 中对象的属性都会默认生成对应的成员变量,因此我们用 @dynamic 禁止 age 生成成员变量,再次观察结果如下:

由此观察发现,当属性 age 不再生成成员变量时,内存变为了 40。因此我们发现属性之所以能够影响内存,是因为生成了成员变量,也就是说最终影响对象内存大小的是成员变量。

随之想到的是,方法会影响内存的大小吗?我们在 BPPerson 增加一个类方法和实例方法,再次观察的结果如下:

我们可以发现跟上次打印结果完全一样,无论类方法还是实例方法都不会影响对象内存。

结论:影响对象内存大小的是成员变量,属性影响内存大小的原因是生成了成员变量,而方法对于对象内存大小没有影响。

1.2 对象内存分布及优化

上一小节探究了影响内存的因素,本节我们探索下对象内存的分布。一个对象的内存大小受其成员变量的影响,其内存也由成员变量组成,如下图:

person 指针指向一块内存区域,这块内存即是存储这个对象的地方,包括 isa 和 其他成员变量。

那么问题来了,这些成员变量如何分布呢,是按顺序存储的吗?我们依然以 BPPerson 为例进行探索。

首先在 BPPerson 内部给 weight 赋值为 75,代码很简单,这里就不展示了,其他属性赋值如下:

然后使用 x/8gx person 来查看内存(x/8gx表示以16进制显示,8个字节一组),结果如下:

  • [:] 左边为内存地址,[:] 右边为存储的值
  • 0x28279ae20 为对象首地址,其后 0x000021a104a3d615 为 isa
  • 之后 0x4066c00000000000 为 height,浮点数需要用 p/f 查看
  • age、inCharacter、outCharacter 合并为 0x0000001200004241
  • 0x000000000000004b 为 weight,之后依次为 name 和 title

由此我们发现,内存的分布并非完全按照顺序排列,而是会做一定的优化,比如 age、inCharacter、outCharacter 就会被合并到一起,因为它们加起来也不足 8 字节,而 name、title 则按照顺序进行排列。

这是系统帮我们做的优化,其目的是为了方便提高访问效率的同时,能够优化存储,减少浪费。

二、结构体内存对齐规则及验证

上一节主要探究了对象的内存,但是在OC中,对象的本质其实是一个结构体,这一点将在后续的文章中探索到,本节就继续探究结构体的内存对齐。

首先从一道题开始探究,分别定义两个结构体如下,请问这两个结构体占用内存大小是否一致?如果不一致,分别是多少?

代码语言:javascript复制
typedef struct {
    double a; 
    char   b; 
    int    c; 
    short  d; 
} BPStruct;

typedef struct {
    double a; 
    int    b; 
    char   c; 
    short  d; 
} BPStruct1;

我认为的结果是,两者内存大小一致,都是16字节。不过结果一出,拍拍打脸,下面看结果:

2.1 结构体内存对齐规则介绍

为什么会出现这种情况呢?其实结构体的内存是有其对齐规则的,当然这些规则是为了提升读取效率,其具体规则如下:

  • 结构体的成员中第一个成员从 offset为0的位置 开始,之后的成员的起始位置,要求是该成员类型大小的整数倍,如果是数组等包含子成员的成员,则要是其子成员类型大小的整数倍
  • 如果结构体 A 中包含另一个结构体 B,则 B 的起始位置要是 B 中最大成员的类型大小的整数倍
  • 结构体最终的大小要是其最大成员的类型大小的整数倍,如果包含子结构体,则最终的大小要是 max(自身最大成员大小,子结构体最大成员大小) 的整数倍

根据这个规则,我们来分析下上面题目的答案为什么是 24 和 16。分析前先放一张各个基本类型占用字节大小的图片,在分析过程中可以参照。另外假定一个标记 start 表示成员的开始存储的位置。

首先来看一下 BPStruct,分析步骤如下:

代码语言:javascript复制
typedef struct {
    double a; // double:8字节   第一个成员 start = 0,    存储位置 [0 7]
    char   b; // char:1字节     8是1的倍数 start = 8,    存储位置 [8 9 10 11]
    int    c; // int:4字节     12是4的倍数 start = 12,   存储位置 [12 13 14 15]
    short  d; // short:2字节   16是2的倍数 start = 16,   存储位置 [16 17]
              // [0 17] 共18个字节,但不是double大小,即8的倍数,最终大小为 24
} BPStruct;

再看下 BPStruct1,分析步骤如下:

代码语言:javascript复制
typedef struct {
    double a; // double:8字节   第一个成员 start = 0,               存储位置 [0 7]
    int    b; // int:4字节      8是4的倍数 start = 8,               存储位置 [8 9 10 11]
    char   c; // char:1字节    12是1的倍数 start = 12,              存储位置 [12]
    short  d; // short:2字节   13不是2的倍数,跳过一个 start = 14     存储位置 [14 15]
              // [0 15] 共16个字节,16正好是double,即8的倍数,最终大小为 16
} BPStruct1;

以上题目中的两个结构体都是基本类型,没有包含子结构体的情况,下面我们新建一个结构体,看下结构体中包含子结构体的是怎么适用这个规则的,如图所示为结构体及执行结果:

该结构体中的str就是之前的 BPStruct1,可以看到打印出来的结果为 32,以下为结合规则分析的产生这个结果的原因:

代码语言:javascript复制
typedef struct {
    double a;      // double:8字节   第一个成员 start = 0,               存储位置 [0 7]
    char   b;      // char:1字节     8是1的倍数 start = 8,               存储位置 [8 9 10 11]
    short  c;      // short:2字节  12是2的倍数 start = 12,               存储位置 [12 13]
    int    d;      // int:4字节   14、15均不是4的倍数,跳过,start = 16,    存储位置 [16 17 18 19]
    BPStruct1 str; // 最大成员为 8字节,20、21、22、23均不是倍数,跳过,start = 24
                   // 算下来大小为 24   16,共30字节,但需要是最大成员倍数,即 8 的倍数,最终结果为 32 字节
} BPStruct2;

2.2 如何读取结构体的汇编验证

2.1小节主要是结构体内存规则的介绍,但是系统是否是真的按照这种方式来读的呢?

下面我们写一个函数,然后断点查看汇编,分析一下结构体的读取过程。结构体为2.1节中的 BPStruct1 和 BPStruct2,函数如下:

打开汇编调试 Debug --> Debug overflow --> Always show Disassembly,查看汇编结果如下图:

在进行汇编分析前先简单介绍下一些汇编的指令及相关知识点:

  • iOS是小端系统,函数的栈空间开辟是由高地址向低地址进行开辟,而对于数据的读取都是向高地址的。以上汇编中开头的 sub sp, sp, #0x30 和 结尾的 add sp, sp, #0x30 分别表示 向低地址开辟48字节空间 和 栈平衡(即函数执行完毕需要释放该部分空间)
  • 上图中的 sp 表示栈顶,x0、x1、w10 等表示 ARM 下的寄存器,一个寄存器有8个字节,ARM 下有 32 个通用寄存器,即 x0~x31, w表示寄存器的低32位,w0 即 x0 的低32位
  • sub:减指令,图中表示 sp = sp - #0x30,str/strb 表示将寄存器的值写入栈内存中,ldr 表示将栈内存中数据写入寄存器中,mov 可以简单先当作赋值指令,图中 mov x8, sp 即表示 x8 = sp
  • 图中的 [] 可以理解为一段地址,如 str x0, [sp #0x20] 即表示将 x0寄存器的值写入 [sp #0x20] 这个内存地址的位置,0x表示16进制,0x8表示16进制8,0x10表示16,十六进制满16进一即为0x10,以此类推 0x20 等

下面开始对图中的代码进行分析,通过 register read 寄存器 可以读取寄存器的值:

1、图中是代码断点处,此时刚刚进入函数,即初始状态,我们先分析下汇编代码第8行以上的部分。该部分代码做了以下几件事情

代码语言:javascript复制
    0x100a1e264 < 0>:  sub    sp, sp, #0x30             ; =0x30   // 开辟48字节栈空间
    0x100a1e268 < 4>:  str    x0, [sp, #0x20]  // 将 x0 寄存器写入 [sp, #0x20],此时x0值为 18.5
    0x100a1e26c < 8>:  str    x1, [sp, #0x28]  // 将 x1 寄存器值写入 [sp, #0x28]
    0x100a1e270 < 12>: mov    x8, sp           // 将 sp 寄存器的值写入 x8 寄存器,相当于用 x8 暂存sp的值,因为后面可能会用到 sp 的值,而sp作为栈顶,不能改动
    0x100a1e274 < 16>: mov    x9, #0x900000000000 
    0x100a1e278 < 20>: movk   x9, #0x4066, lsl #48  // 经过此两步操作后,读取x9的值,此时为180.5

此刻的状态如下图:

2、接下来分析汇编代码第 8~14行的部分,这部分对应 BPStruct2的各成员赋值,也是观察结构体读取方式的关键一部分:

代码语言:javascript复制
    0x100a1e27c < 24>: str    x9, [sp]        // 将 x9 的值即 180.5 赋值给 sp 寄存器即栈顶,对应 BPStruct2 s.a = 180.5,即 sp 存的值为 180.5
    0x100a1e280 < 28>: mov    w10, #0x61      // 0x61 即为 'a' 的十六进制ascil码,赋值给 w10寄存器
    0x100a1e284 < 32>: strb   w10, [x8, #0x8] // 将 w10的值赋值给 [x8, #0x8],对应 BPStruct2 s.b = 'a'; 此时可观察 [x8, #0x8]其实为 [sp, #0x8],即sp向上偏移8个字节,便宜量正好对应double的8字节大小,此时 [sp, #0x8]存的值为 'a'
    0x100a1e288 < 36>: mov    w10, #0x6     // 0x6即为6,赋值给 w10
    0x100a1e28c < 40>: strh   w10, [x8, #0xa]  // 将 6 赋值给 sp向上偏移 10个字节的位置,[sp, #0x8]向上的 2 字节处,此时对应 BPStruct2 s.c = 6
    // 注意:[sp, #0x8]存储为 'a', 占用一个字节,而现在向上偏移的 2 字节,正好对应 9 不是 short 的倍数,因此 s.b = 'a' 虽然只使用1字节空间,但是因为字节对齐,占用了2个空间,
    0x100a1e290 < 44>: mov    w10, #0x5a // 0x5a 即为 90, 赋值给 w10, 可以发现w10在这里充当了了零时变量的作用
    0x100a1e294 < 48>: str    w10, [sp, #0xc] // [sp, #0xc]即sp向上偏移12个字节,对应 BPStruct2 s.d = 90
    // 因为 BPStruct2 s.c = 6 赋值完成后,12正好是 int 的整数倍,所以不用再偏移直接存储占用四字节即可

此时的状态图及分析如下图所示:

3、最后一部分为结构体 str 的处理, str的第一个成员 a = 18.5,这一部分中的 q0,查资料得知也是一个寄存器,但不同于通用寄存器,它有 16 个字节

代码语言:javascript复制
    0x100a1e298 < 52>: ldr    q0, [sp, #0x20] // 由第一部分知道[sp, #0x20]此时存的是x0的值,即18.5,对应 str.a = 18.5, 此句代码相当于将 18.5 赋值给 q0 
    0x100a1e29c < 56>: str    q0, [sp, #0x10] // 将 q0 赋值给 [sp, #0x10]
    // 可以发现这个地址为[sp, #0xc]向上偏移4个字节,正好对应int的大小,说明 str 正好紧接着 s.d 存储,因为此时起始位置为 16,正好也是 str 内部最大 double的大小的整数倍

此时的状态图如下:

通过三个部分的汇编代码分析,可以发现str由 [sp #0x10] 开始存储,到 [sp #0x20] 正好16个字节,加上之前的16字节,总共占用 32 字节,由此可以验证结构体内存对齐的原则,系统确实是按照这个规则存储的。

三、结构体内存对齐规则的设计意义

探究一个知识点就是要知其然,也知其所以然,在第二节中提到过,结构体内存对齐规则是为了提升结构体的读取效率,但是这么设计怎么提升了效率呢?我们继续来探究一下。

以 BPStruct 为例,分别看下不对齐与对齐的情况,两种情况下占用空间的大小分别为 15 和 24:

代码语言:javascript复制
typedef struct {
    double a; 
    char   b; 
    int    c; 
    short  d; 
} BPStruct;

首先,我们假定没有内存对齐规则,不进行内存对齐,看一看此时会如何读取成员。

如果没有内存对齐,则每个成员占用大小即为自身类型的大小,起始位置也不必为自身大小整数倍,紧接着上一个成员即可。其存储结果图如下:

这样存储的结果是占用内存确实小了一些,但是在读取上却有几点不便:

1、每次读取都需要根据当前成员的大小计算要读取的空间大小,然后才能进行读取,这样无疑降低了读取效率;

2、假定读取时设定一个尺度读取,如果以最大的a的大小为尺度,每次都读 8 字节,则当读取 b 时,读取的空间就超出了结构体的空间,容易发生错误读取;

3、假定以其它的成员大小读取,会发生一次读取不完整的情况,例如以 char 的大小 1字节读取,则其它成员都无法读取完整。

总之不对齐时,读取上会有很大的不便。下面看下进行内存对齐的情况:

这种对齐后的方式,会找到最大的度量,然后以这个度量为尺度,每次读取这么大的长度,有以下优点:

1、以最大长度读取时,不会超出结构体的内存空间,因为总长度是最大长度的倍数

2、各个成员起始都以自身倍数开始,就保证了以最大尺度读取时,每次都可以读取完整数据,而读取次数相对于逐个读会大大减少

结构体对齐之后,虽然占用存储空间大了一些,但是在读取效率上会大大减小,例子中不对齐需要读取4次,每次还要重新计算要读取多少空间。

采用对齐之后只需要读取两次,每次读取 8 字节即可,这就是一种以空间换时间的思想,由此也可以看出结构体内存对齐的意义所在。

总结

本篇文章主要探索了两件事情:

1、 对象的内存分布及影响对象内存大小的因素;

2、结构体内存对齐的规则、验证及意义;

本篇文章的探究到此就结束了,欢迎大家的阅读,如果有发现错误或不足之处,欢迎大家的批评指正。

0 人点赞