前言
本篇就来探究一下,主要有以下几个方面:
- 影响对象内存的因素
- 结构体对齐原则及理解
- 系统如何读取结构体的汇编验证
- 对齐规则的设计意义
一、对象内存的探究
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、结构体内存对齐的规则、验证及意义;
本篇文章的探究到此就结束了,欢迎大家的阅读,如果有发现错误或不足之处,欢迎大家的批评指正。