iOS进阶之路 (二)OC对象的原理 - malloc & 内存对齐

2,685 阅读9分钟

alloc底层上文中讲了OC对象创建的流程,本文开始学习OC对象在内存中的布局。

1. 代码调试

代码准备,开始调试。

#import <Foundation/Foundation.h>
#import <objc/runtime.h>
#import <malloc/malloc.h>

@interface Person : NSObject

@property (nonatomic, assign) NSInteger age;
@property (nonatomic, copy) NSString *name;
@property (nonatomic, assign) long height;
@property (nonatomic, assign) char c1;
@property (nonatomic, assign) char c2;
@property (nonatomic, copy) NSString *sex;

@end

@implementation Person

@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        
        Person *person = [Person alloc];
        person.name = @"akironer";
        person.age = 20;
        person.height = 180;
        person.c1 = 'a';
        person.c2 = 'b';
//        person.sex = @"男"; // sex不赋值
        
        NSLog(@"sizeof——%lu\nclass_getInstanceSize——%lu\nmalloc_size——%lu",
        sizeof([person class]),
        class_getInstanceSize([person class]),
        malloc_size((__bridge const void *)(person))
        );
        
    }
    return 0;
}

1.1 LLDB调试

  1. Xcode查看内存地址:Debug->Debug Workflow->view memory
  2. x 对象:以16进制打印对象内存地址(x表示16进制)
    iOS是小端模式,需要倒着读数据.(详解大端模式和小端模式)

大端模式:是指数据的高字节保存在内存的低地址中,而低子节数据保存在内存的高地址中。
小端模式:是指数据的高字节保存在内存的高地址中,而数据的低字节保存在内存的低地址中。

  1. x/6gx 对象:打印6个16进制的8字节内存地址(x表示16进制,4表示4个,g表示8字节为单位)
(lldb) x/6gx person
0x10064e5e0: 0x001d8001000033dd 0x0000000000006261
0x10064e5f0: 0x0000000000000014 0x0000000100002010
0x10064e600: 0x00000000000000b4 0x0000000000000000
(lldb) po 0x001d8001000033dd
8303516107944925
(lldb) po 0x0000000000000014
20
(lldb) po 0x0000000100002010
akironer
(lldb) po 0x00000000000000b4
180
  • 第一段对象的isa指针(需要同ISA_MASK经过一次按位与运算才能得到正确值,下章会讲)
  • 第二段中61、62分别是a、b的ASCII编码
  • 第三段中的14是20的十六进制
  • 第四段是akironer
  • 第五段是180
  • 第六段是未赋值的sex,对象创建了未赋值属性会得到内存假地址
  1. 控制台输出
sizeof——8  
class_getInstanceSize——48
malloc_size——48
  1. 注释掉sex属性后,控制台输出
sizeof——8
class_getInstanceSize——40
malloc_size——48
  1. 注释掉Person所有声明属性后,控制台输出
sizeof——8
class_getInstanceSize——8
malloc_size——16
  • sizeof:sizeof是操作符,不是函数,它的作用对象是数据类型,主要作用于编译时。因此,它作用于变量时,也是对其类型进行操作。得到的结果是该数据类型占用空间大小,即size_t类型。
  • class_getInstanceSize:依赖于<objc/runtime.h>,创建对象申请的内存大小
  • malloc_size:依赖于<malloc/malloc.h>,系统为该对象实际开辟的内存大小

1.2 问题来了

  1. 对象申请的内存和系统开辟的内存 有什么不一样?
  2. 不是说对象最少为16字节,为什么class_getInstanceSize还能等于8?

2. malloc

通过探索malloc底层,分析系统开辟内存malloc_size为什么由40->48?

2.1 calloc

在libmalloc源码中,调用obj = (id)calloc(1, size)方法

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

int main(int argc, const char * argv[]) {
    @autoreleasepool {
		
		void *person = calloc(1, 40);
		NSLog(@"%lu",malloc_size(person));
		
    }
    return 0;
}

2.2 malloc_zone_calloc

void *
calloc(size_t num_items, size_t size)
{
	void *retval;
	retval = malloc_zone_calloc(default_zone, num_items, size);
	if (retval == NULL) {
		errno = ENOMEM;
	}
	return retval;
}

根据返回值return retval推测,retval = malloc_zone_calloc(default_zone, num_items, size);是核心代码,进入malloc_zone_calloc方法继续跟进。

2.3 zone_calloc

void *
malloc_zone_calloc(malloc_zone_t *zone, size_t num_items, size_t size)
{
	MALLOC_TRACE(TRACE_calloc | DBG_FUNC_START, (uintptr_t)zone, num_items, size, 0);

	void *ptr;
	if (malloc_check_start && (malloc_check_counter++ >= malloc_check_start)) {
		internal_check();
	}

	ptr = zone->calloc(zone, num_items, size);
	
	if (malloc_logger) {
		malloc_logger(MALLOC_LOG_TYPE_ALLOCATE | MALLOC_LOG_TYPE_HAS_ZONE | MALLOC_LOG_TYPE_CLEARED, (uintptr_t)zone,
				(uintptr_t)(num_items * size), 0, (uintptr_t)ptr, 0);
	}

	MALLOC_TRACE(TRACE_calloc | DBG_FUNC_END, (uintptr_t)zone, num_items, size, (uintptr_t)ptr);
	return ptr;
}

根据返回值return ptr推测,ptr = zone->calloc(zone, num_items, size);是核心代码。

zone->calloc可知calloc是zone的属性,这里如果继续找calloc,会与第一步的calloc循环。那么如何快速找到zone->calloc的内容?

  • 直接p zone->calloc,打印函数地址
(lldb) p zone->calloc
(Void *(*))(_malloc_zone_t *, size_t)) $0 = 0x0000000100381a57 (.dylib`default_zone_calloc at malloc.c:249)

进入malloc.c 249行的default_zone_calloc方法

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);
}
  • 继续p zone->calloc,打印函数地址
(lldb) p zone->calloc
(Void *(*))(_malloc_zone_t *, size_t)) $1 = 0x000000010038303a (.dylib`nano_calloc at nano_malloc.c:878)

进入nano_malloc.c 878行的nano_calloc方法

查看源码的过程中,经常遇到属性函数或者宏定义,让我们难以继续代码跟进,这时候打印函数地址是好的解决方法。

2.4 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);
}

根据返回值return p推测,void *p = _nano_malloc_check_clear(nanozone, total_bytes, 1);是核心代码,进入_nano_malloc_check_clear方法继续跟进。

2.5 _nano_malloc_check_clear

static void *
_nano_malloc_check_clear(nanozone_t *nanozone, size_t size, boolean_t cleared_requested)
{
	MALLOC_TRACE(TRACE_nano_malloc, (uintptr_t)nanozone, size, cleared_requested, 0);

	void *ptr;
	size_t slot_key;
	size_t slot_bytes = segregated_size_to_fit(nanozone, size, &slot_key); // Note slot_key is set here
	mag_index_t mag_index = nano_mag_index(nanozone);

	nano_meta_admin_t pMeta = &(nanozone->meta_data[mag_index][slot_key]);

	ptr = OSAtomicDequeue(&(pMeta->slot_LIFO), offsetof(struct chained_block_s, next));
	if (ptr) {
	... 省略大量容错代码
	} else {
		ptr = segregated_next_block(nanozone, pMeta, slot_bytes, mag_index);
	}

	if (cleared_requested && ptr) {
		memset(ptr, 0, slot_bytes); // TODO: Needs a memory barrier after memset to ensure zeroes land first?
	}
	return ptr;
}

还记得我们查看malloc源码的目的么?分析系统开辟内存malloc_size为什么由40->48。 这里多了size_t的定义,感觉已经很接近了。

根据size_t slot_bytes = segregated_size_to_fit(nanozone, size, &slot_key);,size是我们传进来的40,而slot_bytes刚好是我们的目标malloc_size = 48,那我们就来看下40->48是怎么来的?

(lldb) po size
40
(lldb) po slot_bytes
48

2.6 segregated_size_to_fit 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; 
	}
	k = (size + NANO_REGIME_QUANTA_SIZE - 1) >> SHIFT_NANO_QUANTUM; 
	slot_bytes = k << SHIFT_NANO_QUANTUM;	
	*pKey = k - 1;		
	
	
	// size = 40;NANO_REGIME_QUANTA_SIZE = 16; SHIFT_NANO_QUANTUM = 4;
	// slot_bytes =  ((40 + 16 - 1) >> 4 << 4) = 48
	
	return slot_bytes;
}

size 是 40,slot_bytes = ((40 + 16 - 1) >> 4 << 4) = 48,也就是16的整数倍,即16字节对齐。

2.7 对比class_getInstanceSize,malloc_size,sizeOf

  • class_getInstanceSize

这个是一个runtime提供的API,用于获取实例对象中成员变量内存大小。

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

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;
}
  • malloc_size 这是libmalloc提供的API,用于获取系统实际分配的内存大小。
malloc_size = (instanceSize + 16 - 1) >> 4 << 4

对象占用内存大小:8字节对齐,最小16(class_getInstanceSize)。
系统实际分配的内存大小:16字节对齐(malloc_size)

  • sizeOf

sizeof是操作符,不是函数,它的作用对象是数据类型,得到的结果是该数据类型占用空间大小。sizeof 只会计算类型所占用的内存大小,不会关心具体的对象的内存布局;

例如:在64位架构下,自定义一个NSObject对象,无论该对象有多少个成员变量,最后得到的内存大小都是8个字节。

  • 面试题:一个NSObject对象占用多少内存?

在64位架构下, 系统分配了16个字节给NSObject对象(通过malloc_size函数获得);

但NSObject对象内部只使用了8个字节的空间(可以通过class_getInstanceSize函数获得)。

3. 内存对齐

3.1 什么是内存对齐

在计算机中,内存大小的基本单位是字节,理论上来讲,可以从任意地址访问某种基本数据类型。

但是实际上,计算机并非按照字节大小读写内存,而是以2、4、8的倍数的字节块来读写内存。因此,编译器会对基本数据类型的合法地址作出一些限制,即它的地址必须是2、4、8的倍数。那么就要求各种数据类型按照一定的规则在空间上排列,这就是对齐。

在iOS开发过程中,编译器会自动的进行字节对齐的处理,并且在64位架构下,是以8字节进行内存对齐的。

3.2 内存对齐的原则

  1. 数据成员对齐规则,结构体或者联合体的第一个成员放在offset为0的地方,以后每个数据成员存储的起始位置要从该成员的大小或者该成员的子成员的大小的整数倍开始
  2. 结构体作为成员:如果一个结构体A中有结构体B作为子成员,B中存放有char,int,double等元素,那么B应该从double也就是8的整数倍开始存储
  3. 结构体的总体大小,即sizeof的结果,必须是其内部最大成员的整数倍,不足的需要补齐.

32位下采用4字节对齐,64位下采用8字节对齐

struct StructA {
    int a;      // 4字节,0,1,2,3
    char b;     // 1字节,4, 补5,6,7
    double c;   // 8字节,8,9,10,11,12,13,14,15
    int *p;     // 8字节,16,17,18,19,20,21,22,23      大小24字节
} strA;

struct StructB {
    int a;      // 4字节,0,1,2,3, 补4,5,6,7
    int *p;     // 8字节,8,9,10,11,12,13,14,15
    char b;     // 1字节,16, 补17,18,19,20,21,22,23
    double c;   // 8字节,24,25,26,27,28,29,30,21,32  大小32字节
} strB;

struct StructC {
    int a;      // 4字节,0,1,2,3
    char b;     // 1字节,4, 补5,6,7
    double c;   // 8字节,8,9,10,11,12,13,14,15
    int *p;     // 8字节,16,17,18,19,20,21,22,23
    struct StructB b;    //32字节,sizeof(structB) = 32      大小56字节 
} strC;

// 输出
NSLog(@"strA = %lu,strB = %lu,strC = %lu", sizeof(strA), sizeof(strB), sizeof(strC));
strA = 24,strB = 32,strC = 56

3.3 内存对齐的原因

  • 性能上的提升

从内存占用的角度讲,对齐后比未对齐有些情况反而增加了内存分配的开支,是为了什么呢?

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

  • 跨平台

有些硬件平台并不能访问任意地址上的任意数据的,只能处理特定类型的数据,否则会导致硬件层级的错误。

有些CPU(如基于 Alpha,IA-64,MIPS,和 SuperH 体系的)拒绝读取未对齐数据。当一个程序要求这些 CPU 读取未对齐数据时,这时 CPU 会进入异常处理状态并且通知程序不能继续执行。

举个例子,在 ARM,MIPS,和 SH 硬件平台上,当操作系统被要求存取一个未对齐数据时会默认给应用程序抛出硬件异常。所以,如果编译器不进行内存对齐,那在很多平台的上的开发将难以进行。

4. 参考资料

Cooci-malloc分析

搜狐技术团队-关于NSObject对象的内存布局,看我就够了