iOS之深入解析内存对齐的底层原理

CPU 存取原理

一、“存”示例
  • CPU 并不是以字节为单位存取数据的。CPU把内存当成是一块一块的,块的大小可以是2,4,8,16字节大小,因此CPU在读取内存时是一块一块进行读取的。每次内存存取都会产生一个固定的开销,减少内存存取次数将提升程序的性能
  • CPU 一般会以 2/4/8/16/32 字节为单位来进行存取操作,我们将这些存取单位也就是块大小称为(memory access granularity)内存存取粒度
  • CPU的数据总线宽度决定了CPU对数据的吞吐量。
  • 64位 CPU 一次处理64 bit也就是8个字节的数据,32位同理,每次处理4个字节的数据。
    假设有以下数据:
    在这里插入图片描述
    那么 CPU 存储之后如下: 在这里插入图片描述
二、“取”示例

在一个存取粒度为 4 字节的内存中,先从地址 0 读取 4 个字节到寄存器,然后从地址 1 读取 4 个字节到寄存器:

  • 当从地址 0 开始读取数据时,是读取对齐地址的数据,直接通过一次读取就能完成;当从地址 1 读取数据时读取的是非对齐地址的数据,需要读取两次数据才能完成。
    iOS之深入解析内存对齐的底层原理_第1张图片

  • 在读取完两次数据后,还要将 0-3 的数据向上偏移 1 字节,将 4-7 的数据向下偏移 3 字节,最后再将两块数据合并放入寄存器。
    iOS之深入解析内存对齐的底层原理_第2张图片

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

内存对齐简介

一、概念
① 什么是内存对齐?
  • 计算机内存都是以字节为单位划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但是实际的计算机系统对基本类型数据在内存中存放的位置有限制,它们会要求这些数据的首地址的值是某个数k(通常它为4或8的倍数),这就是所谓的内存对齐
  • 内存对齐是一种在计算机内存中排列数据(表现为变量的地址) 、访问数据(表现为CPU读取数据)的一种方式。
  • 内存对齐包含了两种相互独立又相互关联的部分:基本数据对齐和结构体数据对齐
② 为什么要进行内存对齐?
  • 平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
  • 性能原因:数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。
③ 内存对齐原则

在 iOS 中,对象的属性需要进行内存对齐,而对象本身也需要进行内存对齐。内存对齐有三原则:

  • 数据成员对齐原则: 结构( struct )(或联合( union ))的数据成员,第一个数据成员放在 offset 为 0 的地方,以后每个数据成员存储的起始位置要从该成员大小或者成员的子成员大小
  • 结构体作为成员: 如果一个结构里有某些结构体成员,则结构体成员要从其内部最大元素大小的整数倍地址开始存储。(如:struct a⾥存有struct b,b⾥有char、int 、double等元素,那b应该从8的整数倍开始存储)
  • 收尾工作: 结构体的总大小,也就是 sizeof 的结果,必须是其内部最大成员的整数倍,不足的要补⻬

简而言之:

  • 前面的地址必须是后面的地址正数倍,不是就补齐;
  • 结构体里面的嵌套结构体大小要以该嵌套结构体最大元素大小的整数倍;
  • 整个 Struct 的地址必须是最大字节的整数倍。
④ 特别说明
  • 在字节对齐算法中,对齐的主要是对象,而对象的本质则是一个 struct objc_object 的结构体;
  • 结构体在内存中是连续存放的,所以可以利用这点对结构体进行强转;
  • 苹果早期是8字节对齐,现在是16字节对齐。
二、C/OC 基本数据类型的内存大小(字节)
C OC 32位 64位
bool BOOL(64位) 1 1
signed char (_signed char)int8_t、BOOL(32位) 1 1
unsigned char Boolean 1 1
short int16_t 2 2
unsigned short unichar 2 2
int、int32_t NSInteger(32位)、boolean_t(32位) 4 4
unsigned int NSUInteger(32位)、boolean_t(64位) 4 4
long NSInteger(64位) 4 8
unsigned long NSUInteger(64位) 4 8
long long int64_t 8 8
float CGFloat(32位) 4 4
double CGFloat(64位) 8 8

结构体的内存对齐

一、常规结构体
  • 定义一个结构体如下:
typedef struct YDWTeacher {
    char name;
    bool sex;
    int age;
    float height;
    double level;  
}teacher;
  • 通过上文中的“C/OC 基本数据类型的内存大小”表中可以查看:YDWTeacher这个结构体中的每一个变量占据的内存如下:
    char name:1字节;
    bool sex:1字节;
    int age:4字节;
    float height:4字节;
    double level: 8字节;
typedef struct YDWTeacher {
    char name;    // 1字节
    bool sex;     // 1字节
    int age;      // 4字节
    float height;  // 4字节
    double level; // 8字节
}teacher;
  • 打印NSLog(@"%lu",sizeof(teacher));如下:
	iOS之内存对齐[25483:1817236] 24
  • 那么,YDWTeacher在系统内存中的存储地址如下(对齐系数默认为成员最大元素大小):
    在这里插入图片描述
  • 内存分析如下:
	char name => [0], offset = 1;
	bool sex 长度为 1 个字节,此时 offset 需要 +3,才是 4 的倍数,offset = 4, b => [4, 4], offset = 5, 需要 + 3 = 8;
	int age 长度为 4 个字节, offset = 8 满足,因此 int age => [8, 11], offset = 12
	float height 长度为 4 个字节, offset = 12 满足,因此 float height => [12,15], offset = 16;
	double level 长度为 8 个字节,offset = 16 满足 8 的倍数,因此 double score => [16,23];
	此时共占用字节 24,由于当前结构体中,最长数据类型 sizeof(double) = 8, 248 的倍数,因此对齐后取值为 24
二、嵌套结构体
  • 定义一个结构体如下:
typedef struct YDWStudent {
    char name;      // 1字节
    int age;        // 4字节
    bool sex;       // 1字节
    double score;   // 8字节
}student;
  • 打印NSLog(@"%lu",sizeof(student));如下:
	iOS之内存对齐[25751:1843819] 24
  • 那么,YDWStudent在系统内存中的存储地址如下(对齐系数默认为成员最大元素大小):
    iOS之深入解析内存对齐的底层原理_第3张图片
  • 内存分析如下:
	char name => [0], offset = 1
	int age 长度为 4 个字节,此时 offset 需要 +3,才是4的倍数,offset = 4, b => [4, 7], offset = 8
	bool sex 长度为 1 个字节, offset = 8 满足,因此 c => [8], offset = 9
	double score 长度为 8 个字节,offset + 7 = 16 才是 8 的倍数,因此 d => [16,23]
	此时共占用字节 24,由于当前结构体中,最长数据类型 sizeof(double) = 8, 248 的倍数,因此对齐后取值为 24
  • 接下来,我们就将这两个结构体嵌套:
typedef struct YDWTeacher {
    char name;    // 1字节
    bool sex;     // 1字节
    int age;      // 4字节
    float height;  // 4字节
    double level; // 8字节
}teacher;

typedef struct YDWStudent {
    char name;      // 1字节
    int age;        // 4字节
    bool sex;       // 1字节
    double score;   // 8字节
    teacher *teacher;
}student;
  • 此时NSLog(@"%lu",sizeof(student));变成了:
	iOS之内存对齐[25862:1856001] 32
  • 那么此时,YDWStudent在系统内存中的存储地址如下(对齐系数默认为成员最大元素大小):
    在这里插入图片描述
  • 内存分析如下:
	char name => [0], offset = 1
	int age 长度为 4 个字节,此时 offset 需要 +3,才是4的倍数,offset = 4, b => [4, 7], offset = 8
	bool sex 长度为 1 个字节, offset = 8 满足,因此 c => [8], offset = 9
	double score 长度为 8 个字节,offset + 7 = 16 才是 8 的倍数,因此 d => [16,23]
	teacher *teacher 长度为 24 字节,但是 teacher 中最长数据类型为 sizeof(double) = 8, 此时 offset = 24 正好是 8 的倍数, teacher => [24, 31], offset = 32
	此时共占用字节 32,由于当前结构体中,最长数据类型 sizeof(double) = 8, 328 的倍数,因此对齐后取值为 32

OC对象申请内存和系统开辟内存

  • 在Xcode中导入#import 与#import ,然后打印以下函数(获取内存大小):
    • sizeof:即编译时确定大小,获得该数据类型占用空间的大小;
    • class_getInstanceSize:获取类的实例对象所占用的内存大小;
    • malloc_size:获取系统实际分配的内存大小;
	YDWBoy *boy = [YDWBoy alloc];
	boy.name = @"YDW";
	boy.nickName = "handsome";
    boy.age = 18;
    boy.height = 175.0;
    NSLog(@"%lu - %lu - %lu", sizeof(boy), class_getInstanceSize([YDWBoy class]), malloc_size((__bridge const void *)(boy)));
  • 得到对应的内存大小字节数,结果如下:
	2020-09-08 00:00:58.621860+0800 iOS之内存对齐[26116:1877306] 8 - 40 - 48
  • 可以发现对象自己申请的内存大小与系统实际给开辟的大小时不一样的,这里对象申请的内存大小是 40 个字节,而系统开辟的是 48 个字节。
  • 40 个字节不难理解,是因为当前对象 boy 有 4 个属性,有三个属性为 8 个字节,有一个属性为 4个字节,再加上 isa 的 8 个字节,就是 32 + 4 = 36 个字节,然后根据内存对齐原则,36 不能被 8 整除,36 往后移动刚好到了 40 就是 8 的倍数,所以内存大小为 40。
  • class_getInstanceSize 和 malloc_size 对同一个对象返回的结果不一样的,原因是 malloc_size 是直接返回的 calloc 之后的指针的大小。
size_t instanceSize(size_t extraBytes) {
    size_t size = alignedInstanceSize() + extraBytes;
    // CF requires all objects be at least 16 bytes.
    if (size < 16) size = 16;
    return size;
}
  • 通过instanceSize计算的内存大小,向内存中申请 大小 为 size的内存,并赋值给obj,因此 obj是指向内存地址的指针;在未执行 calloc 时,po obj 为 nil,执行后,再 po obj ,返回一个16进制的地址。
	obj = (id)calloc(1, size);
  • 而 class_getInstanceSize 内部实现是:也就是说 class_getInstanceSize 会输出 8 个字节,malloc_size 会输出 16 个字节,当然前提是该对象没有任何属性。
size_t class_getInstanceSize(Class cls) {
    if (!cls) return 0;
    return cls->alignedInstanceSize();
}

calloc 的内存对齐

  • 从 calloc 函数出发,虽然不能直接在 libObjc 的源码中找到其对应实现,但是通过观察 Xcode 可以去找 libMalloc 源码。

iOS之深入解析内存对齐的底层原理_第4张图片

  • libObjc 和 libMalloc 是相互独立的,所以在 libMalloc 源码中,没必要去走 calloc 前面的流程了,可以通过断点调试 libObjc 源码,发现第二个参数是 40: (这是因为当前发送 alloc 消息的对象有 4 个属性,每个属性 8 个字节,再加上 isa 的 8 个字节,所以就是 40 个字节)

iOS之深入解析内存对齐的底层原理_第5张图片

  • 打开 libMalloc 的源码,在新建的 target 中直接手动声明如下的代码:
	void *p = calloc(1, 40);
	NSLog(@"%lu",malloc_size(p));
  • 运行之后,会有提示以下错误:

iOS之深入解析内存对齐的底层原理_第6张图片

  • 直接 Command + Shift + F 进行全局搜索对应的符号,但是会发现找不到,我们再仔细观察,这些符号都是位于 .o 文件里面的,所以我们可以去掉符号前面的下划线再进行搜索,这个时候就可以把对应的代码注释然后重新运行了。运行之后一直沿着源码断点下去,会来到这么一段代码:
	ptr = zone->calloc(zone, num_items, size);
  • 如果直接去找 calloc,就会递归了,所以需要点进去,然后就会发现一个很复杂的东西出现了:

iOS之深入解析内存对齐的底层原理_第7张图片

  • 可以直接在断点处使用 LLDB 命令打印这行代码来看具体实现是位于哪个文件中:
	p zone->calloc
	输出: (void *(*)(_malloc_zone_t *, size_t, size_t)) $1 = 0x00000001003839c7 (.dylib`default_zone_calloc at malloc.c:249)
  • 也就是说 zone->alloc 的真正实现是在 malloc.c 源文件的249行处;
static void *
default_zone_calloc(malloc_zone_t *zone, size_t num_items, size_t size)
{
    zone = runtime_default_zone();
    
    return zone->calloc(zone, num_items, size);
}
  • 但是又发现这里又是一次 zone->calloc,接着再次使用 LLDB 打印内存地址:
	p zone->calloc
	输出: (void *(*)(_malloc_zone_t *, size_t, size_t)) $0 = 0x0000000100384faa (.dylib`nano_calloc at nano_malloc.c:884)
  • 再次来到 nano_calloc 方法
static void *
nano_calloc(nanozone_t *nanozone, size_t num_items, size_t size)
{
    size_t total_bytes;

    if (calloc_get_size(num_items, size, 0, &total_bytes)) {
        return NULL;
    }

    if (total_bytes <= NANO_MAX_SIZE) {
        void *p = _nano_malloc_check_clear(nanozone, total_bytes, 1);
        if (p) {
            return p;
        } else {
            /* FALLTHROUGH to helper zone */
        }
    }
    malloc_zone_t *zone = (malloc_zone_t *)(nanozone->helper_zone);
    return zone->calloc(zone, 1, total_bytes);
}
  • 简单分析,应该往 _nano_malloc_check_clear 里面继续走,然后再次发现 _nano_malloc_check_clear 里面内容非常多,此时要明确一点,目的是找出 48 是怎么算出来的,经过分析之后,我们来到 segregated_size_to_fit
static MALLOC_INLINE size_t
segregated_size_to_fit(nanozone_t *nanozone, size_t size, size_t *pKey)
{
    // size = 40
    size_t k, slot_bytes;

    if (0 == size) {
        size = NANO_REGIME_QUANTA_SIZE; // Historical behavior
    }
    // 40 + 16-1 >> 4 << 4
    // 40 - 16*3 = 48

    //
    // 16
    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;
}
  • 这里就可以看出:进行的是 16 字节对齐,那么也就是说传入的 size 是 40,在经过 (40 + 16 - 1) >> 4 << 4 操作后,结果为48,也就是16的整数倍。
  • 最后得出结论:
    • 对象的属性是进行的 8 字节对齐,对象自己进行的是 16 字节对齐;
    • 因为内存是连续的,通过 16 字节对齐规避风险和容错,防止访问溢出;
    • 同时,也提高了寻址访问效率,也就是空间换时间;
  • calloc内存计算的示意流程如下:
    iOS之深入解析内存对齐的底层原理_第8张图片

你可能感兴趣的:(iOS高级进阶,Swift高级进阶,Objective-C底层原理,内存对齐,结构体嵌套,对象申请内存,系统开辟内存,calloc)