OC底层原理06:内存对齐探索

张建 lol

查看内存地址的方法

1、【方法一】:Debug->Debug Workflow->Alway View Memory

2、【方法二】:x 地址/x 对象

1)下面我们通过代码具体查看一下使用:

1
2
3
4
5
6
7
8
9
10
11
12
@interface ZJPerson : NSObject
@property (nonatomic, copy) NSString *name;
@property (nonatomic, assign) int age;
@end


// 内存对齐
ZJPerson * p = [ZJPerson alloc];
p.name = @"ZJ"; // NSString
p.age = 30; // int

NSLog(@"%@",p);

2)通过LLDB编译命令查看内存情况:

  • x 0x100867a60 :以16进制打印当前地址的内存情况,查看不方便
  • x/4gx 0x100867a60:以16进制形式打印4个地址,查看方便
  • 0x100867a60 : 内存地址
  • 0x001d80010000220d:isa
  • 0x000000000000001e:30
  • 0x0000000100001010:ZJ

3)那么 0x100867a60、0x1020387700x001d80010000220d 0x000000000000001e 0x0000000100001010 0x0000000000000000
有什么关系呢?我们用一张图来表示一下:

4)如果有一个 double/float 类型的 height 呢?

我们来分析一下:

1
2
3
@property (nonatomic, assign) double height;

p.height = 170; // double

通过 x/4gx p 查看打印的结果:

1
2
(lldb) po 0x4065400000000000
4640185359819341824

我们发现竟然没有打印170,这是为什么呢?

原因:16进制和double/float类型的转换
验证:

1
2
(lldb) p/x (double)170
(double) $7 = 0x4065400000000000

我们可以得到和上面的地址一抹抹一样样,还有谁?

5)从打印结果我们可以看出一个问题,两个属性占用内存不是按照定义属性的顺序,这是为什么呢?其实这就是 iOS中的内存字节对齐现象

内存对齐的原因

对于大部分程序员来说,内存对齐 对他们来说都应该是 透明的

  • 平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。

  • 性能原因:数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问 未对齐 的内存,处理器需要作 两次 内存访问;而 对齐 的内存 访问 仅需要 一次 访问。

内存对齐三大原则

每个特定平台上的编译器都有自己的默认 “对齐系数”(也叫对齐模数)。程序员可以通过预编译命令#pragma pack(n),n=1,2,4,8,16来改变这一系数,其中的n就是你要指定的 “对齐系数”。在 ios 中,Xcode默认为 #pragma pack(8),即 8字节对齐

  • 数据成员对齐规则:结构(struct)或联合(union)的数据成员,第一个数据成员放在offset 为0的地方,以后每个数据成员存储的起始位置要从该成员的大小或子成员大小的整数倍开始存储(比如 int 为4字节,那么他的起始位置则为 4*n

  • 结构体作为成员:如果⼀个结构⾥有某些结构体成员,则结构体成员要从其内部最⼤元素⼤⼩的整数倍地址开始存储.(struct a ⾥存有 struct b ,b ⾥有char,int ,double等元素,那 b 应该从 8 的整数倍开始存储.)

  • 结构体总体大小,必须是其内部最大成员的整数倍,不足要补齐

数据类型所占字节

获取内存的三个方法

  • sizeof 是运算符,编译的时候就替换为常数,返回的是 一个类型所占内存的大小

  • class_getInstanceSize 传入一个类对象,返回 一个对象的实例至少需要多少内存,它等价于sizeof,需要导入#import <objc/runtime.h>

  • malloc_size返回 系统实际分配的内存大小,需要导入#import <malloc/malloc.h>

对象属性内存对齐

1、首先通过一个类的内存段来分析

2、然后我们下断点,分析一下对象的内存情况

  • po p:查看当前的对象信息<ZJPerson: 0x60000072f870>

    • ZJPerson为当前的类,0x60000072f870 为当前对象的 首地址
  • iOS为 小端 模式,内存要 倒着读,即 0x0000 0001 0950 2730,不足16位要补0,而 0x0000 000109502730 是指针 isa ,指向内存的首地址 0x60000072f870

  • x p:意思是查看这个地址的内存情况,即内存段

  • 由于小端模式不好读取,所以我们用一个命令自动帮我们整理好 x/6gx 等等

    • x/6xg p:意思就是按照16进制,以6整段打印当前p对象
  • 我们发现OC为我们做了一些优化,我们发现0x0000001200006261这个内存段存储了age,char1,char2,三个属性

    • 我们 po 0x00000012 打印 30,也就是我们的 age 属性值
    • po 0x61 0x62 打印的分别为 97 98 也就是我们 a和b对应的ASCII

3、为什么 age、c1、c2 三个属性放在一个 8字节 内存中呢?

iOS系统不是对每一个属性都开辟8个字节内存空间,如果每一个属性都开辟8个字节,会造成内存浪费
iOS系统通过内存对齐方式,对 属性重排,内存优化

结构体内存对齐

  • 结构体指针的内存大小8
  • 结构体大小根据内部大小来计算

我们都知道 对象本质结构体 ,因此 对象内存对齐 来自 结构体 ,因此接下来我们分析一下机构体是如何内存对齐的:

1、struct1

1
2
3
4
5
6
7
8
9
10
11
// 普通结构体
struct ZJStruct1 {
double a; // 占8个字节 起始位置(0-7)
char b; // 占1个字节 8是1的整数倍[8 1] 起始位置(8)
int c; // 占4个字节 9不是4的整数倍,往下找[9 4] 9 10 11 起始位置(12 13 14 15)
short d; // 占2个字节 16是2的整数倍[16 2] 起始位置(16 17)
}struct1;
// 内部需要的大小为: 17个字节
// 最大属性 : 8个字节
// 结构体整数倍: 最大成员的整数倍 8*3 = 24个字节

我们通过图来分析一下struct1的内存存储情况:

根据内存对齐规则,分析ZJStruct1的内存计算:

  • 变量a:占8个字节,从0开始,此时 0 存储 a
  • 变量b:占1个字节,从8开始,此时8可以整除8,即 8-15 存储 b
  • 变量c:占4个字节,从9开始,此时9不可以整除8,往后移动到1212可以整除4,即 12-15 存储 c
  • 变量d:占2个字节,从16开始,此时16可以整除2,即 16-17 存储 d

因此ZJStruct1的需要的内存大小为 18 字节,而ZJStruct1中最大变量的字节数为 8,所以 ZJStruct1 实际的内存大小必须是 8 的整数倍,18 向上取整到 24,主要是因为 248 的整数倍,所以 sizeof(ZJStruct1) 的结果是 24

2、struct2

1
2
3
4
5
6
7
8
9
10
11
12
// 普通结构体
struct ZJStruct2 {
char a; //占1个字节 起始位置(0)
int b; //占4个字节 1不是4的整数倍 起始位置(4~7)
short c; //占2个字节 8是2的倍数,起始位置(8 9)
double c; //占8个字节 10不是8的倍数 起始位置(16~23)

}struct2;
// 内部需要的大小为:23个字节
// 最大属性:8个字节
// 结构体整数倍:8*3 = 24个字节

我们通过图来分析一下struct1的内存存储情况:

根据内存对齐规则,分析ZJStruct2的内存计算:

  • 变量a:占1个字节,从0开始,此时 0 存储 a
  • 变量b:占4个字节,从1开始,此时1不可以整除4,往后移动到4,此时4可以整除4,即 4-7 存储 b
  • 变量c:占2个字节,从8开始,此时8可以整除2,即 8-9 存储 c
  • 变量d:占8个字节,从10开始,此时10不可以整除2,往后移动到1616可以整除8,即 16-23 存储 d

因此 ZJStruct2 的需要的内存大小为 24 字节,而ZJStruct1中最大变量的字节数为 8,所以 ZJStruct1 实际的内存大小必须是 8 的整数倍,24 向上取整到 24,主要是因为 248 的整数倍,所以 sizeof(ZJStruct2) 的结果是 24

3、struct3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 嵌套结构体
struct ZJStruct3 {
char a; //占1个字节
int b; //占4个字节
short c; //占2个字节
}struct3;
struct ZJStruct4 {
double a; //占8个字节 起始位置(0~7)
int b; //占4个字节 8是4的整数倍[8 4] 起始位置(8 9 10 11)
struct ZJStruct3 s3; // char(12) int(16,17,18,19) short(20,21) 结构体内部要对齐(4*3=12)补22,23
short c; //占2个字节 24是2的整数倍 (24,25)
}struct4;
// 内部需要的大小:25个字节
// 最大属性:8个字节
// 结构体整数倍:8*4 = 32个字节

我们通过图来分析一下struct3的内存存储情况:

根据内存对齐规则,分析ZJStruct3的内存计算:

  • 变量a:占8个字节,从0开始,此时 0-7 存储 a
  • 变量b:占4个字节,从8开始,此时8可以整除4,即 8-11 存储 b
  • 结构体成员s1s1是一个结构体,根据内存对齐原则二,结构体成员要从其内部最大成员大小的整数倍开始存储,而ZJStruct3中最大的成员大小为4,所以s3要从4的整数倍开始,当前是从12开始,所以符合要求,124的整数倍,符合内存对齐原则,所以 12开始
    • 变量a:占1个字节,从12开始,此时12可以整除4,即 12 存储 a
    • 变量b:占4个字节,从13开始,此时13不可以整除4,往后移动到16,此时16可以整除4,即 16-19 存储 b
    • 变量c:占2个字节,从20开始,20可以整除2,即 20-21 存储 c
    • 由于结构体内部的字节对齐原则4的整数倍,后面要补上22,23,此时12-23正好是4的整数倍,即4*3
  • 变量c:占2个字节,从24开始,24可以整除2,即 24-25 存储 c

因此ZJStruct4的需要的内存大小为 26 字节,而ZJStruct4中最大变量的字节数为 8,所以 ZJStruct4 实际的内存大小必须是 8 的整数倍,26 向上取整到 32,主要是因为 328 的整数倍,所以 sizeof(ZJStruct4) 的结果是 32

4、我们通过上面的分析来实际验证一下结果:

1
2
3
4
5
// 打印结构体内存大小
NSLog(@"%lu-%lu-%lu-%lu",sizeof(struct1),sizeof(struct2),sizeof(struct4),sizeof(struct4.s3));

*******打印结果******
2020-09-15 20:23:52.275929+0800 001-内存对齐原则[25197:515659] 24-16-32-12

结果显示我们分析的结果是正确的

内存优化(属性重排)

  • 如果是结构体中数据成员是根据内存 从小到大的顺序定义 的,根据内存对齐规则来计算结构体内存大小,需要增加有较大的内存padding即内存占位符,才能满足内存对齐规则,比较 浪费内存

  • 如果是结构体中数据成员是根据内存 从大到小的顺序定义 的,根据内存对齐规则来计算结构体内存大小,我们只需要补齐少量内存padding即可满足堆存对齐规则,这种方式就是苹果中采用的,利用 空间换时间 ,将类中的属性进行重排,来达到优化内存的目的

以下面这个例子来进行说明 苹果中属性重排,即内存优化:

1、我们通过自定义一个类ZJPerson,并定义几个属性,来分析一下

1
2
3
4
5
6
7
8
@interface ZJPerson : NSObject
@property (nonatomic, copy) NSString * name;
@property (nonatomic, copy) NSString *nickName;
@property (nonatomic, assign) int age;
@property (nonatomic, assign) long height;
@property (nonatomic) char c1;
@property (nonatomic) char c2;
@end

2、在main中创建ZJPerson的实例对象,并对其中的几个属性赋值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#import <Foundation/Foundation.h>
#import <objc/runtime.h>
#import <malloc/malloc.h>
#import "ZJPerson.h"

int main(int argc, const char * argv[]) {
@autoreleasepool {
// insert code here...

// 对象的内存对齐 - 对象的内存对齐来自结构体
ZJPerson * p = [ZJPerson alloc]; //占8字节
p.name = @"ZJ"; //占8个字节
p.nickName = @"小J"; //占8个字节
p.age = 30; //占4个字节
p.c1 = 'a'; //占1个字节
p.c2 = 'b'; //占1个字节

NSLog(@"%@ - %lu - %lu - %lu",p,sizeof(p),class_getInstanceSize([p class]),malloc_size((__bridge const void *)(p)));

}
return 0;
}

********打印结果******
2020-09-13 23:48:30.357717+0800 KCObjc[84742:2078708] <ZJPerson: 0x100660120> - 8 - 40 - 48

3、断点调试p,根据ZJPerson的对象地址,查找出属性的值:

  • 当我们向通过 0x0000001e00006261 地址找出 age 等数据时,发现是 乱码,这里无法找出值的原因是苹果中针对 age、c1、c2 属性的内存进行了重排,因为 age 类型占 4 个字节,c1c2 类型 char 分别占 1 个字节,通过 4+1+1 的方式,按照 8 字节对齐,不足补齐的方式存储在同一块内存中

  • 我们通过po 0x000000010d671058找出name的值ZJ,通过po 0x000000010d671078找出nickName的值小J

4、所以,这里可以总结下苹果中的内存对齐思想:

大部分的内存都是通过固定的内存块进行读取,尽管我们在内存中采用了内存对齐的方式,但并不是所有的内存都可以进行浪费的,苹果会自动 对属性进行重排,以此来 优化内存

字节对齐到底采用多少字节对齐?

到目前为止,我们在前文既提到了 8 字节对齐,也提及了 16 字节对齐,那我们到底采用哪种字节对齐呢?

我们可以通过 objc4 中的源码来进行分析

1、实例对象开辟内存的流程如下:

1)class_getInstanceSize内部实现如下:

1
2
3
4
5
6
7
8
9
10
11
/** 
* Returns the size of instances of a class.
*
* @param cls A class object.
*
* @return The size in bytes of instances of the class \e cls, or \c 0 if \e cls is \c Nil.
*/
OBJC_EXPORT size_t
class_getInstanceSize(Class _Nullable cls)
OBJC_AVAILABLE(10.5, 2.0, 9.0, 1.0, 2.0);

  • class_getInstanceSize内部调用alignedInstanceSize

    1
    2
    3
    4
    // Class's ivar size rounded up to a pointer-size boundary.
    uint32_t alignedInstanceSize() const {
    return word_align(unalignedInstanceSize());
    }
  • word_align内部调用如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
#ifdef __LP64__
# define WORD_SHIFT 3UL
# define WORD_MASK 7UL
# define WORD_BITS 64
#else
# define WORD_SHIFT 2UL
# define WORD_MASK 3UL
# define WORD_BITS 32
#endif

static inline uint32_t word_align(uint32_t x) {
return (x + WORD_MASK) & ~WORD_MASK;
}

由源码可知:(x + WORD_MASK) & ~WORD_MASK是一个运算,代表 8 字节对齐

2、为什么对象真正开辟内存是8字节对齐?系统为对象开辟的是16字节对齐?

apple系统为了 防止一切的容错,采用的是 16 字节对齐的内存,主要是因为采用 8 字节对齐时,两个对象的内存会紧挨着,显得 比较紧凑,而 16 字节 比较宽松,利于苹果以后的扩展。

8字节内存计算

由断点可知此时的x为40,WORD_MASK为7,我们来计算一下:(x + WORD_MASK) & ~WORD_MASK

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2^5 = 32 2^4 = 16 2^3 = 8 2^2 = 4 2^1 = 2 2^0 = 1

40 + 7 = 47 = 32 + 8 + 4 + 2 + 1 16进制表示
0000 0000 0010 1111

7
0000 0000 0000 0111
7取反
~1111 1111 1111 1000

47 & ~7
0000 0000 0010 1111
1111 1111 1111 1000
-----------------------
0000 0000 0010 1000 = 40

由结果40可知,与class_getInstanceSize获取实例对象的大小40如何吻合,还有谁?

总结

  • class_getInstanceSize:是采用 8 字节对齐,参照的对象的属性内存大小
  • malloc_size:采用 16 字节对齐,参照的整个对象的内存大小,对象实际分配的内存大小必须是 16 的整数倍
  • Post title:OC底层原理06:内存对齐探索
  • Post author:张建
  • Create time:2020-09-14 02:05:22
  • Post link:https://redefine.ohevan.com/2020/09/14/OC底层原理/OC底层原理06:内存对齐探索/
  • Copyright Notice:All articles in this blog are licensed under BY-NC-SA unless stating additionally.