iOS内存对齐

2,196 阅读7分钟

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

内存对齐的规则

  1. 数据成员对齐规则:结构(struct)(或联合(union))的数据成员,第一个数据成员放在offset为0的位置,以后每个数据成员存储的位置要从min(该成员大小或者该成员子成员大小(只要该成员有子成员,比如说数据,结构体),当前平台的对齐系数)的整数倍开始地址开始存储。
  2. 结构体作为成员:如果一个结构体里有某些结构体成员,则结构体成员要从min(内部最大元素大小或者子结构体最大元素大小,当前平台的对齐系数)的整数倍地址开始存储。
  3. 收尾工作:结构体的总大小,也就是sizeof的结果,必须是min(其内部最大成员或最大子成员大小,当前平台的对齐系数)中的整数倍,不足的要补齐。
iOS平台默认对齐系数为8
#pragma pack(8)
struct ZHYStruct1 {
    double b;   // 8 从初始位置开始排 占0~7位
    int c;      // 4 从4的倍数开始排 占8~11位
    char a;     // 1 从1的倍数开始排 占第12位
    short d;    // 2 从2的倍数开始排 占14~15位
} MyStruct1;

struct ZHYStruct2 {
    char a;     // 1 0
    short b;    // 2 2-3
    int c;      // 4 4-7
    double d;   // 8 8-15
} MyStruct2;

struct ZHYStruct3 {
    char a;     // 1 从初始位置开始排 占0位
    char b;     // 1 从1的倍数开始排  占1位
    struct ZHYStruct1 c;    //16 从8的倍数开始排 占8-23位
    double d;      //8 从8的倍数开始排 占24-32位
    int e;      //4 从4的倍数开始 占36-39位
    long long f; //8 从8的倍数开始 占40-48位
} MyStruct3;

struct ZHYStruct4 {
    char a;     // 1 0
    double d;   // 8 8-15
    short b;    // 2 16-17
    int c;      // 4 20-23
} MyStruct4;

#pragma pack(1)
struct ZHYStruct5 {
    char a;     // 1 0
    double d;   // 8 1-8
    short b;    // 2 9-10
    int c;      // 4 11-15
} MyStruct5;

#pragma pack(4)
struct ZHYStruct6 {
    char a;     // 1 0
    double d;   // 8 4-11
    short b;    // 2 12-13
    int c;      // 4 16-19
} MyStruct6;


MyStruct1---16
MyStruct2---16
MyStruct3---48
MyStruct4---24
MyStruct5---15
MyStruct6---20

内存对齐的原因

经过内存对齐之后可以发现,size反而变大了。那为什么还要进行内存对齐呢?因为cpu存取内存并不是以byte为单位的,而是以块为单位,每块可以为2/4/8/16字节,每次内存存取都会产生一个固定的开销,减少内存存取次数将提升程序的性能。所以 CPU 一般会以 2/4/8/16/32 字节为单位来进行存取操作。我们将上述这些存取单位也就是块大小称为(memory access granularity)内存存取粒度。如果没有内存对齐,会大大增加cpu在存取过程中的消耗。

为了说明内存对齐背后的原理,我们通过一个例子来说明从未地址与对齐地址读取数据的差异。这个例子很简单:在一个存取粒度为 4 字节的内存中,先从地址 0 读取 4 个字节到寄存器,然后从地址 1 读取 4 个字节到寄存器。

当从地址 0 开始读取数据时,是读取对齐地址的数据,直接通过一次读取就能完成。当从地址 1 读取数据时读取的是非对齐地址的数据。需要读取两次数据才能完成。

读取内存1

而且在读取完两次数据后,还要将 0-3 的数据向上偏移 1 字节,将 4-7 的数据向下偏移 3 字节。最后再将两块数据合并放入寄存器。

读取1-4位的内存 对一个内存未对齐的数据进行了这么多额外的操作,这对 CPU 的开销很大,大大降低了CPU性能。

iOS对于内存对齐的优化

首先声明一个类如下所示:

@interface ZYPerson : NSObject

@property (nonatomic, copy) NSString *name;
@property (nonatomic, copy) NSString *nickName;
@property (nonatomic, copy) NSString *hobby;

@property (nonatomic, assign) long height;

@property (nonatomic) char c1;
@property (nonatomic) char c2;
@property (nonatomic) char c3;

@property (nonatomic, assign) int age1;
@property (nonatomic, assign) int age2;
@property (nonatomic, assign) short age3;
@end

main函数中进行实例化:

ZYPerson *person = [ZYPerson alloc];
person.name      = @"Cooci";
person.nickName  = @"KC";
person.age1      = 18;
person.age2      = 19;
person.age3      = 20;
person.c1        = 'a';
person.c2        = 'b';
person.c3        = 'c';
person.height    = 100;
person.hobby     = @"hobby";
        
NSLog(@"%lu-%lu-%lu", sizeof(person), class_getInstanceSize(ZYPerson.class), malloc_size((__bridge const void*)person));

打印结果如下:

8-56-64
  1. sizeofC/C++中的一个操作符(operator),简单的说其作用就是返回一个对象或者类型所占的内存字节数,sizeof的计算发生在编译时刻,所以它可以被当作常量表达式使用。

    在本例中因为person是一个指针,因此sizeof(person)返回的值为8

  2. class_getInstanceSize具体实现如下:

/** 
 * 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.
 */
size_t class_getInstanceSize(Class cls)
{
    if (!cls) return 0;
    return cls->alignedInstanceSize();
}

// May be unaligned depending on class's ivars.
uint32_t unalignedInstanceSize() const {
	ASSERT(isRealized());
    return data()->ro()->instanceSize;
}

// Class's ivar size rounded up to a pointer-size boundary.
uint32_t alignedInstanceSize() const {
	return word_align(unalignedInstanceSize());
}

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

__LP64__	define WORD_MASK 7UL

该方法获得的是一个按8位对齐的大小,本例返回的是56。为什么是56呢?我们通过lldb打印一下实例person在内存中的情况如下:

(lldb) x/8gx person
0x60000132c640: 0x000000010cdd49a8 0x0000001400636261
0x60000132c650: 0x0000001300000012 0x000000010cdd2038
0x60000132c660: 0x000000010cdd2058 0x000000010cdd2078
0x60000132c670: 0x0000000000000064 0x0000000000000000
继续打印其中的值:
(lldb) po 0x000000010cdd49a8 & 0x00007ffffffffff8ULL
ZYPerson
证明0x000000010cdd49a8对应的是person实例的isa
(lldb) po 0x00000014
20  --- 对应age3
(lldb) po (char)0x63
'c' --- 对应c3
(lldb) po (char)0x62
'b' --- 对应c2
(lldb) po (char)0x61
'a' --- 对应c1
(lldb) po 0x00000013
19  --- 对应age2
(lldb) po 0x00000012
18  --- 对应age1
(lldb) po 0x000000010cdd2038
Cooci --- 对应name
(lldb) po 0x000000010cdd2058
KC --- 对应nickName
(lldb) po 0x000000010cdd2078
hobby --- 对应hobby
(lldb) po 0x0000000000000064
100 --- 对应height

可以发现person实例在内存中确实占用了56个字节,而且和我们的属性赋值是一一对应的。需要注意的是,虽然实例最终会转化成objc_object结构体,每一个属性在内存中的排放顺序和我们的属性声明顺序没有必然的关系,这是因为苹果做了一部分优化,调整了结构体中各个属性的位置。

struct PersonStruct {
    void *isa;
    char c1;
    char c2;
    char c3;
    int age3;
    int age1;
    int age2;
    NSString *name;
    NSString *nickName;
    NSString *hobby;
    long height;
} personStruct;

struct PersonStruct* personStructPtr = (__bridge struct PersonStruct*)person;

经过苹果的优化之后,person实例的结构体各个属性的顺序如上所示。

  1. malloc_size函数返回的是在内存中实际开辟的空间大小。 在alloc流程分析中,一共有两个地方判断了决定了实际开辟空间的大小。
  • cls->instanceSize(extraBytes)--> align16
static inline size_t align16(size_t x) {
    return (x + size_t(15)) & ~size_t(15);
}

该方法获取的size会作为参数传递到calloc方法中。

  • segregated_size_to_fit
#define SHIFT_NANO_QUANTUM		4
#define NANO_REGIME_QUANTA_SIZE	(1 << SHIFT_NANO_QUANTUM)	// 16
static MALLOC_INLINE size_t segregated_size_to_fit(nanozone_t *nanozone, size_t size, size_t *pKey)
{
	size_t k, slot_bytes;

	if (0 == size) {
		size = NANO_REGIME_QUANTA_SIZE; // Historical behavior
	}
	k = (size + NANO_REGIME_QUANTA_SIZE - 1) >> SHIFT_NANO_QUANTUM; // round up and shift for number of quanta
	slot_bytes = k << SHIFT_NANO_QUANTUM;							// multiply by power of two quanta size
	*pKey = k - 1;													// Zero-based!

	return slot_bytes;
}

一个是改变了calloc方法的参数,一个是在calloc方法内部调用流程中做了处理。

参考: iOS内存字节对齐