关于内存对齐那些事

116 阅读12分钟

前言

前些天一直在讲runtime相关话题,属实有点疲倦了,今天换一个话题,找新鲜感,我们讲讲iOS中 内存对齐的那些事情。

内存对齐

什么是内存对齐

内存对齐是一种在计算机内存中 排列数据 (表现为变量的地址)、 访问数据 (表现为CPU读取数据)的一种方式。内存对齐对于提升内存访问效率非常重要,因为现代的CPU访问内存时,对数据的地址有特定的要求。

为什么需要内存对齐

性能优化:对齐的数据可以让CPU以最优的方式访问内存。对于大多数平台,读写非对齐的内存地址会比对齐的地址慢很多,甚至在某些硬件平台上访问非对齐的内存可能导致硬件异常。 硬件要求:有些硬件系统只能在对齐的内存地址进行数据访问,不支持非对齐的内存访问,否则会引起异常。

系统标准:编译器和操作系统需要在某种程度上标准化内存布局,以确保不同的编译器或者库之间能够互相兼容。

内存对齐的具体例子: 如果一个整型(int)需要4字节,并假设它必须在地址是4的倍数的地方开始(也就是说它的地址必须是4、8、12等),那么这个整型就是4字节对齐的。假如系统中有一段起始地址为0x1005的内存,而整型变量申请内存时,为了满足4字节对齐,实际上可能会从0x1008地址开始分配这个整型变量的内存。

在内存分配时,为了保证对齐,编译器或者内存分配器会在必要的时候添加额外的空字节,这样就能保证每个数据类型都从对应的对齐边界开始。

内存对齐的最小单位(称为对齐粒度)通常取决于编译器和硬件平台。在32位系统中,很多类型的对齐粒度是4字节,而在64位系统中,对粒度则可能是8字节。这就是类、结构或者对象大小往往是对齐粒度的整数倍的原因。

内存对齐原则

OS内存对齐的几个关键原则:

  1. 对齐边界:内存中的每个变量都应当从它的类型所要求的对齐边界开始。例如,一个int类型的变量(假设为4字节大小)通常应当从一个4字节对齐的地址开始。这意味着其地址应该是4的倍数。也就是说 前面的地址必须是后面的地址正数倍,不是就补齐;

  2. 数据类型的自然大小:数据类型一般要求以其自然大小来进行对齐。这是为了确保CPU读取数据时能够一次性读到所需的所有字节,而不需要读取两次。例如,4字节的数据类型要求4字节对齐,8字节的数据类型要求8字节对齐。

  3. 结构体对齐:结构体及其成员应该按照结构体中对齐要求最高的成员对齐。这可能导致结构体的大小不仅仅是所有成员大小的和,因为可能会在成员之间或结构体的末尾添加填充。 结构体里面的嵌套结构体大小要以该嵌套结构体最大元素大小的整数倍;

  4. 对齐粒度:每个架构可能有不同的默认对齐粒度。在iOS开发中,对齐粒度取决于使用的CPU架构(如ARMv8)和编译器设置。默认情况下,编译器会尽可能地自动处理内存对齐。

  5. 性能考虑:在确保内存对齐的同时,也要考虑内存使用效率和性能。做到内存对齐通常能够提升数据访问速度,但也可能产生额外的内存开销。所以,合理的对齐旨在平衡这两者。

  6. 编译器优化:编译器通常会自动对数据进行内存对齐,避免程序员需要手动进行对齐操作。但是,在某些情况下,编译器也允许开发者通过特定的属性(attributes)或#pragma指令来自定义内存对齐的方式。

  7. 内存管理:在动态分配内存时(如使用mallocnew关键字),分配的内存块通常是自动对齐的,分配器会确保它满足任何类型的对齐要求。

内存对齐的具体细节可能因编译器和硬件架构的不同而有所差异,iOS开发者通常不需要手动管理这些细节,除非在进行底层的内存操作或优化时需要特别关注。

注意事项

1.在字节对齐算法中,对齐的主要是对象,而对象的本质则是一个objc_object的结构体    

2.结构体在内存中是连续存放的,所以可以利用这点对结构体进行强转    

3.苹果早期是8字节对齐,现在是16字节对齐     

实践

下面我们通过代码来实践一下。我们定义一个LZQSize 的类 我们对他进行输出字节输出


#import <Foundation/Foundation.h>



NS_ASSUME_NONNULL_BEGIN

@interface LZQSize : NSObject

@end

NS_ASSUME_NONNULL_END

前置

我们先了解几个函数的概念

  1. sizeof() : 是一个指向类实例的指针大小,所以sizeof(size)将返回指针变量在当前平台下占用的字节数。在64位系统中,指针的大小为8字节。
  2. class_getInstanceSize() : 函数返回类实例所需的内存大小,这包括类本身所定义的实例变量以及继承自超类的实例变量。内存实际存储的大小
  3. malloc_size() : 函数返回分配给特定对象的内存块的字节数。这个大小通常大于或等于class_getInstanceSize返回的大小,因为它包括了运行时为对象分配的整个内存块,这可能会包含一些额外的字节用于内存管理和对齐

例子解读

根据我们前置解释的几个函数的作用,我们顺着可以得出 当我们 运行一下代码 输出的结果为:

LZQSize *size = [[LZQSize alloc]init];

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

sizeof(size)返回类实例的指针大小,所以这里为8;class_getInstanceSize()返回真实类实例所需的内存大小。因为除了isa 没有任何属性。所以这里也是8。malloc_size 是数据对齐以后得结果。目前iOS64位,所以最小对齐字节为16补齐8个字节。结果为

LZQSize---8 - 8 - 16

为了理解透彻我们在进行一些扩展如果LZQSize 继承 LZQSuperSize输出结果为什么呢


// LZQSuperSize.h
#import <Foundation/Foundation.h>



NS_ASSUME_NONNULL_BEGIN

@interface LZQSuperSize : NSObject

@property(nonatomic,strong) NSString * name;

@end

NS_ASSUME_NONNULL_END


// LZQSize.h


NS_ASSUME_NONNULL_BEGIN

@interface LZQSize : LZQSuperSize

@end

运行以后结果输出

LZQSize--8 - 16 - 16

LZQSuperSize--8 - 16 - 16

LZQSize 继承了 LZQSuperSize 的属性,他们开辟的内存大小相同.

当我们生成一个局部变量呢

#import <Foundation/Foundation.h>
#import "LZQSuperSize.h"
NS_ASSUME_NONNULL_BEGIN

@interface LZQSize : LZQSuperSize {
    NSString * newStr;
}
@end

NS_ASSUME_NONNULL_END

输出结果为

LZQSize---8 - 24 - 32

字节总长度为24,目前父类中存在一个NSString,在当前类中我们声明了一个局部属性,加上isa 3*8=24 补齐以后必须为16的倍数所以为32。

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface LZQSuperSize : NSObject {
    char str;
}

@property(nonatomic,assign)int age;
@property(nonatomic,assign)int rank;
@property(nonatomic,assign)int grade;
@end

NS_ASSUME_NONNULL_END

输出结果为

LZQSuperSize---8 - 24 - 32

很明显可以看出,class_getInstanceSize字节数为8的倍数,最后一个例子实际的为4 + 4 +4 +8 + 1 = 21,这里自动补齐了3个字节,而 malloc_size 必须是16的倍数所以结果是8 - 24 - 32

如果我们在 LZQSize 写上方法呢?

#import "LZQSuperSize.h"

@implementation LZQSuperSize

-(void)addSize {

    NSLog(@"addSize");

}
@end

输出结果为

LZQSuperSize---8 - 24 - 32

我们发现输出结果和上面没有区别,这也正好验证了上一篇我们讲过是我问题,为了节省内存,方法申明和实现不在对象占用的内存大小内,对象的大小是实现内部属性的大小。

不同数据类型在32位和64位情况下占用的字节数情况

我们发现

只有long 和 unsigned long在32位下是4个字节,64位下是8个字节,其他的都是一致的

image.png

结构体

对齐原则

每个特定平台上的编译器都有字节的默认对齐系数。在iOS中,默认为8字节对齐。

1 结构体(struct)或者联合体(union)的数据成员,第一个数据成员放在offset为0的地方,以后每个数据成员存储的起始位置要从自己(数据成员)大小的整数倍开始。 可以理解为min(m, n) 的公式, 其中 m表示当前成员的开始位置n表示当前成员所需要的位数。如果满足条件 m 整除 n ,  n 从 m 位置开始存储, 反之继续检查m+1 能否整除 n, 直到可以整除。

2 如果一个结构体里有某些结构体成员,则该结构体成员要从其内部最大元素大小的整数倍地址开始存储,比如struct a里有struct b, b里面有char, int, double, short,那么b应该从8的整数倍地址开始存储,即最大成员为double 8字节。如果只有char, int, short,那么b从4的整数倍地址开始存储,即最大成员为int 4字节

且这里的8和4视为b的自身长度作为外部结构体的成员变量的内存大小,即b这个结构体可以视作8或者4字节这样的结构体成员,参与结构体总大小必须是内部最大成员的整数倍的计算

3 结构体内存的总大小,必须是结构体中最大成员内存的整数倍,不足的需要补齐

上面看起来没有例子结合可能会有一些拗口生涩。

举例

普通结构体

我们定义两个结构体


struct Struct1  {
    double a; // 8
    char b; // 1
    int c; // 4
    short d; // 2
    long e; // 8
} struct1;

struct Struct2  {
    double a; // 8
    int c; // 4
    char b; // 1
    short d; // 2
    long e; // 8
} struct2;

NSLog(@"struct ---%lu - %lu", **sizeof**(struct1), **sizeof**(struct2));

输出结果为

struct ---32 - 24

首先我们从上面代码以及输出可以得出

两个结构体c和b交换了顺序,得出来的结构体内存长度是不一样的那为什么它们占用的内存大小不相等呢?

我们根据上面的原则1和3,我们逐个分析

struct1

a 0----7

b 8

c 占用的字节大小为4 根据原则1 9 10 11 不符合 所以从12 开始 12-15

d 16-17

e 占用的字节大小为8 根据原则1 18 19 20 2 1 22 23 不符合 所以从24 开始 24-31
根据原则3得出 最大成员是为8字节,占用32 无需补齐

struct1 同理分析 可以得出 24

嵌套结构体

struct Struct3  {
    double a; // 8
    char b; // 1
    int c; // 4
    short d; // 2
    long e; // 8
    struct Struct2 f; // 结构体作为成员变量,LGStruct2内部最大成员的整数倍地址开始存储,也就是double 8的整数
} struct3; 

NSLog(@"struct ---%lu - %lu - %lu", **sizeof**(struct1), **sizeof**(struct2),**sizeof**(struct3));

结果输出

struct ---32 - 24 - 56

我们根据上面的原则1、2和3,我们逐个分析

  1. double a 需要 8 字节对齐,占用 8 字节。
  2. char b 需要 1 字节,占用 1 字节,但为了 int c 对齐,需要 3 个填充字节。
  3. int c 需要 4 字节,占用 4 字节。
  4. short d 需要 2 字节,占用 2 字节,为了 long e 对齐需要 2 个填充字节。
  5. long e 需要 8 字节,占用 8 字节。
  6. struct Struct2 f 需要 8 字节对齐,占用 Struct2 的 24 字节。
8double a) + 1char b)+ 3(填充)+ 4int c)+ 2short d)+ 2(填充)+ 8long e) + 24struct Struct2) = 52

由于 Struct3 的最大对齐边界为 8 字节,52 需调整为 8 字节的倍数,故 Struct3 实际大小为 56 字节。

内存优化(属性重排)

1.结构体的大小和结构体成员内存大小的顺序有关系

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

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

举例

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface MyModel : NSObject
{
    char h;
}
@property (nonatomic, copy) NSString *a;
@property (nonatomic, copy) NSString *b;
@property (nonatomic, assign) int c;
@property (nonatomic, assign) long d;
@property (nonatomic) char e;
@property (nonatomic) char f;
- (void)setCharValue:(char)c;

@end

NS_ASSUME_NONNULL_END
// main

MyModel *model = [MyModel alloc];

model.a = @"hello";

model.b  = @"world";

model.c = 100;

model.d = 200;

model.e = 'e';

model.f = 'f';

[model setCharValue:'h'];

image.png 从上图我们可以看出,这个对象占用 8 * 6 = 48字节 后面有8字节是自动补上的所以 最后一个位nil
我们从上面看出 0x0000006400666568是一串乱码这怎么回事呢?

image.png

image.png 从上面可以看出 c e f h存在同一个地址空间。 所以我们从上面举例可以看出 大部分的内存都是通过固定的内存块进行读取,
尽管我们在内存中采用了内存对齐的方式,但并不是所有的内存都可以进行浪费的,苹果会自动对属性进行重排,以此来优化内存