ios对象内存对齐及calloc源码分析

1,175 阅读9分钟

前言

除了taggedPointer类型外,oc实例对象instance本质是一个struct结构体,存储了isa和实例变量(instance variables)。这些数据在内存中是如何分布的?又是如何决定了oc对象的size呢?本文将对这个问题进行探索和讨论

一、预备知识

既然要探索对象的内存布局,自然需要使用LLDB的相关指令来查看对象地址以及对应的内存数据

1.对象地址获取

  1. p:打印变量,输出变量类型和值
  2. po:调用oc对象的description方法输出,默认会输出类和指针地址信息,非oc对象输出nil
(lldb) p person
(LGPerson *) $16 = 0x0000600002562610
(lldb) po person
<LGPerson: 0x600002562610>

(lldb) p a
(int) $0 = 0
(lldb) po a
<nil>

2. 内存读取

  1. x address:以16进制格式,以字节为单位,输出地址对应的内存数据。因为ios是小端字节序,打印的结果需要从右向左,很不方便。
(lldb) x person
0x6000011f1bc0: d0 b7 df 0e 01 00 00 00 61 62 00 00 12 00 00 00  ........ab......
0x6000011f1bd0: 38 90 df 0e 01 00 00 00 58 90 df 0e 01 00 00 00  8.......X.......
  1. x/[n]gx address:以16进制格式,8字节为单位,从指定的address开始,输出n组内存数据。ios中的内存变量多为8字节的指针,使用这种方法就大大增加可读性。
(lldb) x/4gx 0x6000011f1bc0
0x6000011f1bc0: 0x000000010edfb7d0 0x0000001200006261
0x6000011f1bd0: 0x000000010edf9038 0x000000010edf9058
  1. 除了LLDB指令外,还可以通过xcode的菜单项:Debug->Debug WorkFlow->view Memeory来查看内存数据

二、代码

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

NS_ASSUME_NONNULL_BEGIN

@interface LGPerson : NSObject

@property (nonatomic, copy) NSString *name;
@property (nonatomic, copy) NSString *nickName;
@property (nonatomic, assign) int age;
@property (nonatomic, assign) long height;
@property (nonatomic, assign) char a;
@property (nonatomic, assign) short s;
@end
@implementation LGPerson
@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        LGPerson *person = [LGPerson alloc];
        person.name      = @"Cooci";
        person.nickName  = @"KC";
        person.age = 18;
        person.a = 'A';
        person.s = 10;
        person.height = 180;
        NSLog(@"%@ - %lu - %lu - %lu",person,sizeof(person),class_getInstanceSize([LGPerson class]),malloc_size((__bridge const void *)(person)));
    }
    return 0;
}


NS_ASSUME_NONNULL_END

1. 代码解释

我们声明一个LGPerson对象,并给一些属性赋值,使用三种方法在控制台输出:

  • sizeof: c语言保留字,操作符,在编译时期就可以计算出变量类型的size
  • class_getInstanceSize(Class cls): runtime运行时API,返回类的实例对象真正需要的内存size, 需包含头文件<objc/runtime.h>
  • malloc_size(const void * ): 返回指针指向的实际分配内存size,需要头文件<malloc/malloc.h>

2.控制台输出

<LGPerson: 0x10062e2e0> - 8 - 40 - 48
  • person是指针类型,占据8字节,sizeof输出为8,符合预期
  • class_getInstanceSize输出为40,即LGPerson对象的数据需要40字节。
  • malloc_size输出48,即实际上分配了48个字节 class_getInstanceSize和malloc_size不一致, why?

3.查看内存数据

(lldb) p person
(LGPerson *) $0 = 0x000000010050c430
(lldb) x/6gx person
0x10050c430: 0x001d8001000033e5 0x00000012000a0041
0x10050c440: 0x0000000100002010 0x0000000100002030
0x10050c450: 0x00000000000000b4 0x0000000000000000

(lldb) po 0x0000000100002010
Cooci

(lldb) po 0x0000000100002030
KC

(lldb) p 0x00000000000000b4
(int) $5 = 180

分析可知:

+ 第1段是isa
+ 第28个字节包含三部分:000000121816进制),000a为1016进制),0041为字符A的ASCII码,无论是值和所占字节数都正好和属性'age','s''a'对应
+ 第34组分别是name和nickName
+ 第5组为height的值180

4.改变属性声明顺序

将name和nickName声明到最后,查看内存可知:对应的NSString指针存储位置也放到了最后面。

(lldb) x/6gx person
0x1031a1660: 0x001d8001000033e5 0x00000012000a0041
0x1031a1670: 0x00000000000000b4 0x0000000100002010
0x1031a1680: 0x0000000100002030 0x0000000000000000
(lldb) po 0x0000000100002010
Cooci

(lldb) po 0x0000000100002030
KC

5. 结论和疑问

  1. 属性的声明顺序会影响对象的内存布局,但是有不是严格按照声明的顺序分布的,具体是什么规律呢?
  2. 实际分配的内存size有可能有富裕,超出了实例存储成员变量的需要,why?

三、内存对齐原则和内存优化

1. 结构体内存对齐原则

实例对象的本质是结构体,需要遵循struct的内存对齐规则:

+ 数据成员对齐规则:按照声明顺序,结构体的第一个数据成员放在offset为0的地方,后面的成员的起始位置的offset要是该成员大小的整数倍
+ 结构体作为成员:该结构体的起始位置的offset要是该结构体的内部最大成员的size的整数倍
+ 收尾工作:整个结构体的总大小要是该结构体内部最大成员的整数倍,不足的要补齐

2. 代码验证和分析

typedef struct LGStruct1 {
    double a;   // 8 [0-7]
    char b;     // 1 [8]
    int c;      // 4 [12, 15]
    short d;    // 2 [16, 17]
}struct1;

// [0, 17],18字节 -> 最大成员long8字节,最后补齐8的整数倍,24字节

typedef struct LGStruct2 {
    double a;   //8 [0, 7]
    int b;      //4 [8, 11]
    char c;     //1 [12]
    short d;    //2 [13, 14]
}struct2;
// [0, 14], 15字节 -> 最后补齐8的整数倍, 16字节

typedef struct LGStruct3 {
    double a;   //[0, 7]
    int b;      //[8, 11]
    struct2 str2; //[16, 31], struct2中最大成员是long,8字节,从8的倍数开始,即16
}struct3;
// [0, 31], 32字节, 8的整数倍,不用补齐

int main(int argc, char * argv[]) {
    NSString * appDelegateClassName;
    @autoreleasepool {
        struct2 st2;
        st2.a = 21;
        st2.b = 22;
        st2.c = 'c';
        st2.d = 24;
        
        struct3 st3;
        st3.a = 31;
        st3.b = 32;
        st3.str2 = st2;
        
        NSLog(@"%ld-%ld-%ld", sizeof(struct1), sizeof(struct2), sizeof(struct3));
    }
   	return 0
}

控制台输出:24-16-32

3. 内存优化

上面的结构体struct1和struct2,包含的成员类型完全一致,但是由于声明顺序和字节对齐的原因导致结构体的总体大小不一致,struct1有明显的空间浪费。因此,系统会对属性布局顺序进行优化,以最大化的利用内存空间,并不会完全按照声明的顺序。这也就是为什么我们看到的对象内存布局顺序与声明顺序不一致的原因

4. class_getInstanceSize

对象实例中最大的数据类型应该是指针(实际的开发中,用的最多的也是指针),长度是8字节。根据内存对齐原则,对象的总体大小应该是8的倍数,也就是8字节对齐。即:创建一个实例对象需要的内存大小应该是8的倍数。我们可以通过class_getInstanceSize源码实现来看下

size_t class_getInstanceSize(Class cls)
{
    if (!cls) return 0;
    return cls->alignedInstanceSize();
}

关键语句: alignedInstanceSize()

uint32_t alignedInstanceSize() const {
    return word_align(unalignedInstanceSize());
}


// May be unaligned depending on class's ivars.
uint32_t unalignedInstanceSize() const {
    ASSERT(isRealized());
    return data()->ro()->instanceSize;
}
// 对于非运行时的class,对象实例的大小在编译期即已经计算好,放在class的ro部分,即只读数据中

关键word_align:

#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;
}
static inline size_t word_align(size_t x) {
    return (x + WORD_MASK) & ~WORD_MASK;
}

分析: 在64位架构下,WORD_MASK是7。而 word_align实现的是将size按照8的倍数向上取整,这就确保了创建实例对象时,需要的size是8的倍数。 即class_getInstanceSize返回的是实例对象真正需要的内存大小,也就是经过结构体内存对齐和系统的属性重排优化后,实例对象存储内部变量所需要的内存大小

四、实际空间分配

上面我们看到了内存对齐和优化,知道了创建一个oc对象的实例需要的内存size,即class_getInstanceSize()。但是通过malloc_size()发现实际分配的空间出现了超过了class_getInstanceSize的大小的情况,这就需要分析alloc的源码中是如何申请和开辟空间的。

1. _class_createInstanceFromZone分析

在对象alloc流程中,关键的开辟内存是在_class_createInstanceFromZone函数中, 其源码中涉及开辟空间的大小的有两点:

  1. 获取需要开辟的size
size = cls->instanceSize(extraBytes);
  1. 使用alloc真正的开辟空间,并返回指针
// alloc 开辟内存的地方
obj = (id)calloc(1, size);

2. instanceSize分析

size_t instanceSize(size_t extraBytes) const {
    if (fastpath(cache.hasFastInstanceSize(extraBytes))) {
        return cache.fastInstanceSize(extraBytes);
    }

    size_t size = alignedInstanceSize() + extraBytes;
    // CF requires all objects be at least 16 bytes.
    if (size < 16) size = 16;
    return size;
}

断点调试发现:进入的是cache.fastInstanceSize(extraBytes);

size_t fastInstanceSize(size_t extra) const
{
    ASSERT(hasFastInstanceSize(extra));

    if (__builtin_constant_p(extra) && extra == 0) {
        return _flags & FAST_CACHE_ALLOC_MASK16;
    } else {
        size_t size = _flags & FAST_CACHE_ALLOC_MASK;
        // remove the FAST_CACHE_ALLOC_DELTA16 that was added
        // by setFastInstanceSize
        return align16(size + extra - FAST_CACHE_ALLOC_DELTA16);
    }
}

断点调试进入:align16()

static inline size_t align16(size_t x) {
    return (x + size_t(15)) & ~size_t(15);
}

可知:返回的值是将size按照16的倍数向上取整,所以实际开辟空间的时候,调用calloc传入的size是16的倍数,即16字节取整,且最少16字节,也就是对同一个实例对象,malloc_size和class_getInstanceSize有可能不同的原因

3.calloc分析

那么calloc是如何开辟空间的呢,我们一步步分析

3.1 calloc源码在哪里?

断点调试,无法step in obj = (id)calloc(1, size);, 右键jump to Definition跳到头文件,可以发现头文件在"malloc"模块中,在opensoure中搜索'malloc'关键字,找到mallloc源码

3.2 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;
}
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;
}

此处说明一点:通过jump to definition可知,zone为结构体指针,calloc为其成员,是一个函数指针,无法找到函数源码时,可以尝试使用LLDB打印信息,查看是否有线索

(lldb) po zone->calloc
(.dylib`default_zone_calloc at malloc.c:331)

(lldb) p zone->calloc
(void *(*)(_malloc_zone_t *, size_t, size_t)) $0 = 0x0000000100385a53 (.dylib`default_zone_calloc at malloc.c:331)

可知代码在malloc.c 331行:

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

继续:

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: 注意一个代码阅读技巧:对于一个包含很多行代码的陌生函数,可以先抓住主要的分支流程,弄清楚主要的脉络,而不是一开始就逐行分析
分析上面函数可知,主要脉络:计算大小->尝试分配空间->判断是否有效->无效则进行下一次分配尝试->有效则初始化为0,返回指针。进一步可知,分配的大小是slot_bytes决定的,那么关键就看

size_t slot_bytes = segregated_size_to_fit(nanozone, size, &slot_key); 

继续segregated_size_to_fit源码:

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

可知关键语句:

k = (size + NANO_REGIME_QUANTA_SIZE - 1) >> SHIFT_NANO_QUANTUM; // round up and shift for number of quanta
slot_bytes = k << SHIFT_NANO_QUANTUM;

涉及到两个宏定义:

#define SHIFT_NANO_QUANTUM		4
#define NANO_REGIME_QUANTA_SIZE	(1 << SHIFT_NANO_QUANTUM)	// 16

结合宏定义可知:slot_bytes最终为16的向上的整倍数,即16字节对齐。
结论:calloc在分配空间时,是按照16字节对齐进行分配的

4. 对象实际内存分配总结

在实际为对象分配内存空间时,通过以下两部保证了16字节对齐

  1. 计算要开辟的大小时,通过对cls->instanceSize, 进行16字节对齐
  2. calloc的实现本身也保证了16字节对齐

五 小结

  1. 对象内存布局在结构体内存对齐原则基础上进行了属性重排的系统优化,尽量减少内存浪费
  2. 实例对象真正需要的内存size是以8字节对齐的,class_getInstanceSize()可以获取
  3. 在alloc中真正开辟内存时,对size进行了16字节对齐的两次矫正:调用calloc前的size参数和calloc本身的实现机制