首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >【iOS进阶必学】 对象及结构体内存的探究

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

作者头像
CC老师
发布2021-08-25 15:05:57
4610
发布2021-08-25 15:05:57
举报

前言

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

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

一、对象内存的探究

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中,对象的本质其实是一个结构体,这一点将在后续的文章中探索到,本节就继续探究结构体的内存对齐。

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

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,分析步骤如下:

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,分析步骤如下:

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,以下为结合规则分析的产生这个结果的原因:

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行以上的部分。该部分代码做了以下几件事情

    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的各成员赋值,也是观察结构体读取方式的关键一部分:

    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 个字节

    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:

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、结构体内存对齐的规则、验证及意义;

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

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2021-08-22,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 HelloCoder全栈小集 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
相关产品与服务
腾讯云代码分析
腾讯云代码分析(内部代号CodeDog)是集众多代码分析工具的云原生、分布式、高性能的代码综合分析跟踪管理平台,其主要功能是持续跟踪分析代码,观测项目代码质量,支撑团队传承代码文化。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档