iOS探索 -- 内存对齐原理分析

1,615 阅读11分钟

iOS 探索系列相关文章 :

iOS 探索 -- alloc、init 与 new 的分析

iOS 探索 -- 内存对齐原理分析

iOS 探索 -- isa 的初始化和指向分析

iOS 探索 -- 类的结构分析(一)

iOS 探索 -- 类的结构分析(二)

iOS 探索 -- 消息查找流程(一)

iOS 探索 -- 消息查找流程(二)

iOS 探索 -- 动态方法决议分析

iOS 探索 -- 消息转发流程分析

iOS 探索 -- 离屏渲染

iOS 探索 -- KVC 原理分析

前言

之前在探索 alloc流程 的时候有关内存对齐 方面的内容没有去详细分析, 接下来在本文中着重对内存对齐方面的内容进行补充和继续探索。

内存对齐的概念

首先我们要搞清楚什么是内存对齐 ?

内存对齐 (Memory alignment) , 也叫做字节对齐。计算机中的内存空间都是按照 byte 划分的, 从理论上讲对任何类型的变量的访问可以从任何地址开始, 但是实际情况下在访问特定类型变量的时候经常在特定的内存地址访问, 这就需要各类型数据按照一定的规则在空间上排列, 而不是按顺序的一个一个排放, 这就是内存对齐。参考自百度百科

为什么要进行内存对齐 ?

为了减少CPU访问内存的次数, 提高计算机性能, 一些计算机硬件平台要求存储在内存中的变量需要按照自然边界对齐。

  1. 性能提升

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

数据结构 (尤其是栈) 应该尽可能地在自然边界上对齐, 为了访问未对齐的内存, 处理器就需要做两次内存访问, 而对齐的内存访问只需要一次访问。重要的是提高内存系统的性能。(以空间换取时间

  1. 跨平台

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

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

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

获取对象内存大小的方法

在开始研究内存对齐之前, 首先我们需要了解一下下面这三个方法的具体作用:

  • sizeof

他是一个操作符, 不是函数, 作用对象是数据类型, 主要作用于编译时。因此, 它作用于变量时, 同样是对其类型进行操作, 得到的结果是该数据类型占用空间的大小。

struct test
{
  int a;     //4
  double b;  //8
} MyTest;
NSLog(@"%zd", sizeof(MyTest));
//
// 上面的结果得到 16 个字节, 需要考虑内存对齐问题, 关于内存对齐规则后面提到

sizeof 只会计算类型所占用的内存大小, 不会关心具体的内存布局。(例如, 64位结构下, 我们自定义一个 NSObject 对象, 里面无论有多少个成员变量, 最后的结果都是 8 个字节)

  • class_getInstanceSize

这是 runtime 提供的一个API, 用于获取类的实例对象所占用的内存大小, 返回具体的字节数。

我们通过在之前获取到的 objc4 源码中搜索该方法, 在 objc-class.mm 中找到了该方法的实现:

size_t class_getInstanceSize(Class cls)
{
    if (!cls) return 0;
    return cls->alignedInstanceSize();
}
// Class's ivar size rounded up to a pointer-size boundary.
uint32_t alignedInstanceSize() {
  return word_align(unalignedInstanceSize());
}
//
static inline uint32_t word_align(uint32_t x) {

  // 7    0000 0111
  // ~7   1111 1000
  // 假设 x 为 4,那么就是
  // 
  // 0000 0100  + 7
  // 0000 1011  11
  // &
  // 1111 1000
  // 
  // 0000 1000  8

  return (x + WORD_MASK) & ~WORD_MASK; // WORD_MASK 为 7
}
//
#define WORD_MASK 7UL

此方法在获取对象所占的内存大小时,会去调用一个 word_align 方法,在该方法中通过将传进来的数值进行 (x + 7) & ~7 的操作将数值中非8的部分(也就是 <8 的部分对齐到了8)进行了 8 字节对齐操作,所以此方法获取的大小是对象成员变量经过8字节对齐后所占的总大小。

  • malloc_size

这个函数获取的是系统实际分配的内存大小, 也就是经过对象内存对齐后的大小(对象 16字节对齐后的大小),所以 malloc_size 与 class_getInstanceSize 的结果是不一样的, 具体的实现可以在libmalloc源码中找到。代码如下:

size_t malloc_size(const void *ptr)
{
	size_t size = 0;
	if (!ptr) {
		return size;
	}
	(void)find_registered_zone(ptr, &size);
	return size;
}

//
static inline malloc_zone_t *
find_registered_zone(const void *ptr, size_t *returned_size)
{
	// Returns a zone which contains ptr, else NULL
	if (0 == malloc_num_zones) {
		if (returned_size) {
			*returned_size = 0;
		}
		return NULL;
	}
	// first look in the lite zone
	if (lite_zone) {
		malloc_zone_t *zone = lite_zone;
		size_t size = zone->size(zone, ptr);
		if (size) { // Claimed by this zone?
			if (returned_size) {
				*returned_size = size;
			}
			// Return the virtual default zone instead of the lite zone - see <rdar://problem/24994311>
			return default_zone;
		}
	}
	// The default zone is registered in malloc_zones[0]. There's no danger that it will ever be unregistered.
	// So don't advance the FRZ counter yet.
	malloc_zone_t *zone = malloc_zones[0];
	size_t size = zone->size(zone, ptr);
	if (size) { // Claimed by this zone?
		if (returned_size) {
			*returned_size = size;
		}
		// Asan and others replace the zone at position 0 with their own zone.
		// In that case just return that zone as they need this information.
		// Otherwise return the virtual default zone, not the actual zone in position 0.
		if (!has_default_zone0()) {
			return zone;
		} else {
			return default_zone;
		}
	}
	int32_t volatile *pFRZCounter = pFRZCounterLive;   // Capture pointer to the counter of the moment
	OSAtomicIncrement32Barrier(pFRZCounter); // Advance this counter -- our thread is in FRZ
	unsigned index;
	int32_t limit = *(int32_t volatile *)&malloc_num_zones;
	malloc_zone_t **zones = &malloc_zones[1];
	// From this point on, FRZ is accessing the malloc_zones[] array without locking
	// in order to avoid contention on common operations (such as non-default-zone free()).
	// In order to ensure that this is actually safe to do, register/unregister take care
	// to:
	//
	//   1. Register ensures that newly inserted pointers in malloc_zones[] are visible
	//      when malloc_num_zones is incremented. At the moment, we're relying on that store
	//      ordering to work without taking additional steps here to ensure load memory
	//      ordering.
	//
	//   2. Unregister waits for all readers in FRZ to complete their iteration before it
	//      returns from the unregister call (during which, even unregistered zone pointers
	//      are still valid). It also ensures that all the pointers in the zones array are
	//      valid until it returns, so that a stale value in limit is not dangerous.
	for (index = 1; index < limit; ++index, ++zones) {
		zone = *zones;
		size = zone->size(zone, ptr);
		if (size) { // Claimed by this zone?
			goto out;
		}
	}
	// Unclaimed by any zone.
	zone = NULL;
	size = 0;
out:
	if (returned_size) {
		*returned_size = size;
	}
	OSAtomicDecrement32Barrier(pFRZCounter); // our thread is leaving FRZ
	return zone;
}

内存对齐的原则

  • 数据成员对齐规则

    结构(struct)(或联合(union))的数据成员,第一个数据成员放在offset为0的地方,以后每个数据成员存储的起始位置要从成员大小或者成员的子成员大小(只要该成员有子成员,比如说是数组,结构体等)的整数倍开始 (比如int在32位机为4字节,则要从4的整数倍地址开始存储。) 举例如下:

    struct MyStruct {
        int a;			// 0-3  补位 4,5,6,7
        double b;		// 8-15
        char c;			// 16		部位 17,18,19
        short d;		// 20-24
    } struct1;
    //
    NSLog(@"%lu",sizeof(struct1));
    // 打印结果
    2020-02-18 20:41:12.974406+0800 LGTest[36584:2131553] 24
    

    注意: 测试环境均为 64位 环境, 32位环境下感兴趣的同学可以自行测试。

    分析上面的结果, 首先 a 为 int 类型占4位, 所以 a 所在的区域为0-3位。接下来的 b 为 double 类型占8位, 因为内存对齐原则起始位置应该为8的整数倍, 所以起始位置应该为8, 前面的4位补齐。然后 c 的起始位置就是16, 因为 c 只需要1位, 那么 d 的起始位置就是17, 17如果需要是 4 的倍数需要变成 20, 所以前面补齐3位, d 的起始位置为20, 20加上 d 的4位就得出最终的结果24。

    还可以用下面的方式去理解:

    我们把内存对齐原则理解为 min(m, n) 的公式, 其中 m表示当前成员的开始位置, n表示当前成员所需要的位数。如果满足条件 m 整除 n 的话, 就让 n 从 m 位置开始存储, 否则继续检查 m+1 能否整除 n, 直到可以整除, 从而就确定了当前成员的开始位置。

    上面结构体中的 b 就可以看做, min(4, 8) , 直到 min(8, 8) 时满足条件, 所以 b 的存储区域为 8-15 这8位

    然后 c 就是 min(16, 1) , 可以直接整除, c 的区域就是 16, 占1位

    最后 dmin(17, 4) , 直到 min(20, 4) 时满足条件, 可以得出, d 所在的区域为 20-23

  • 结构体作为成员对齐规则

    如果一个结构里有某些结构体成员, 则结构体成员要从其内部最大元素大小的整数倍地址开始存储。(struct a 里存有 struct b, b 里有 char, int , double等元素, 那 b 应该从8的整数倍开始存储。) 可以看一下下面的例子:

    struct MyStruct4
    {
      double a;
      short b;
        struct MyStruct5 {
            int c;
            double d;
        } struct5;
    } struct4;
    //
    NSLog(@"%lu", sizeof(struct4));
    // 打印结果
    2020-02-18 21:45:20.741445+0800 LGTest[38755:2172577] 32
      
    // 按照原则1                           // 遵循原则2
    // 0-7                                //  0-7
    // 8-11                               //  8-11
    // 12-15  min(12, 4)                  //  16-19  min(12, 8)
    // 16-23  min(16, 8)                  //  24-31  min(20, 8)
    

    可以看到, 如果我们继续按照上面的方式去存储的话, 得到的结果应该是 24。根据结构体作为成员的规则要求, struct5 的最大元素大小为 8 位, 所以 struct5 的第一个数据成员的起始位置应该是 8 的整数倍, 也就是 min(12, 8) , 然后再继续往下存储, 最后得出结果为32。

  • 收尾工作

    结构体的总大小, 也就是 sizeof 的结果, 必须是其内部最大成员的整数倍, 不足的话需要补齐。实例如下:

    struct MyStruct2
    {
      int a;
      char b;
    } struct2;
    struct MyStruct3
    {
      double a;
      char b;
    } struct3;
    //
    NSLog(@"%lu", sizeof(struct2));
    NSLog(@"%lu", sizeof(struct3));
    // 打印结果
    2020-02-18 21:25:22.008413+0800 LGTest[38015:2158667] 8
    2020-02-18 21:25:22.008605+0800 LGTest[38015:2158667] 16
    

    假如仅凭第一条的规则, 我们可以得出, struct2 的打印结果应该为 5, struct3 的结果应该为 9。但是我们的打印结果是 8 和 16, 这就验证了我们这一条的原则, struct2 的结果必须是 4 的倍数, 所以结果是 8; struct3 的结果应该是 8 的倍数, 所以结果是 16。

对象的内存对齐

1. 属性的内存对齐

了解了内存对齐的原则, 下面我们再来看一下对象的内存对齐是在什么时候进行的。通过之前的 alloc流程探索 过程中我们知道了对象的创建是在 callAlloc 方法中完成的,

static ALWAYS_INLINE id
callAlloc(Class cls, bool checkNil, bool allocWithZone=false)
{
    if (slowpath(checkNil && !cls)) return nil;

#if __OBJC2__
    if (fastpath(!cls->ISA()->hasCustomAWZ())) {
        // No alloc/allocWithZone implementation. Go straight to the allocator.
        // fixme store hasCustomAWZ in the non-meta class and 
        // add it to canAllocFast's summary
        if (fastpath(cls->canAllocFast())) {
            // No ctors, raw isa, etc. Go straight to the metal.
            bool dtor = cls->hasCxxDtor();
            id obj = (id)calloc(1, cls->bits.fastInstanceSize());
            if (slowpath(!obj)) return callBadAllocHandler(cls);
            obj->initInstanceIsa(cls, dtor);
            return obj;
        }
        else {
            // Has ctor or raw isa or something. Use the slower path.
            id obj = class_createInstance(cls, 0);
            if (slowpath(!obj)) return callBadAllocHandler(cls);
            return obj;
        }
    }
#endif
    // No shortcuts available.
    if (allocWithZone) return [cls allocWithZone:nil];
    return [cls alloc];
}

根据方法名我们就不难发现 id obj = class_createInstance(cls, 0); 这一行代码应该就是创建对象的方法, 下面我们在往里逐一探索:

id class_createInstance(Class cls, size_t extraBytes)
{
    return _class_createInstanceFromZone(cls, extraBytes, nil);
}
//
static __attribute__((always_inline)) 
id
_class_createInstanceFromZone(Class cls, size_t extraBytes, void *zone, 
                              bool cxxConstruct = true, 
                              size_t *outAllocatedSize = nil)
{
    if (!cls) return nil;
    assert(cls->isRealized());

    // Read class's info bits all at once for performance
    bool hasCxxCtor = cls->hasCxxCtor();
    bool hasCxxDtor = cls->hasCxxDtor();
    bool fast = cls->canAllocNonpointer();

    size_t size = cls->instanceSize(extraBytes);	// 
    if (outAllocatedSize) *outAllocatedSize = size;

    id obj;
    if (!zone  &&  fast) {
        obj = (id)calloc(1, size);
        if (!obj) return nil;
        obj->initInstanceIsa(cls, hasCxxDtor);
    } else {
        if (zone) {
            obj = (id)malloc_zone_calloc ((malloc_zone_t *)zone, 1, size);
        } else {
            obj = (id)calloc(1, size);
        }
        if (!obj) return nil;

        // Use raw pointer isa on the assumption that they might be 
        // doing something weird with the zone or RR.
        obj->initIsa(cls);
    }
    if (cxxConstruct && hasCxxCtor) {
        obj = _objc_constructOrFree(obj, cls);
    }
    return obj;
}

来到这里, 发现这一行代码 size_t size = cls->instanceSize(extraBytes); , 继续往下走:

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;
}
//
// Class's ivar size rounded up to a pointer-size boundary.
uint32_t alignedInstanceSize() {
  return word_align(unalignedInstanceSize());
}
//
static inline uint32_t word_align(uint32_t x) {
  return (x + WORD_MASK) & ~WORD_MASK;
}
//
#define WORD_MASK 7UL

这里当我们看到 alignedInstanceSize() 方法时发现此方法就是上面提到过的 class_getInstanceSize 方法的内部实现, 此方法的作用就是用来获取实例对象所占用的内存的大小, 此时就明了了。下面我们来着重看一下这个方法:

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

// 假设 x 为 9, 转换为二进制为  
// x + WORD_MASK (7) = 16 转换为二进制
//
// 0001 0000   (即 16)
// &
// 1111 1000   (& 上 -7的二进制)
// 0001 0000	 (16)
//
// 实际为 (x + 7) >> 3 << 3

根据方法名字可以看出, 该方法主要做的工作就是 字节对齐 , 这正是我们要找的东西。通过上面对算法的模拟, 我们可以看出, 在这里系统对实例对象所占用的内存 (也就是对象属性所需要占用的内存大小) 进行了 8字节对齐 。然后回过头来通过 instanceSize(size_t extraBytes) 该方法的实现得知, 对象的内存大小至少为 16 字节。

2. 对象的内存对齐

通过上面的探索我们知道了对象属性的 8字节对齐 , 并且对象在申请内存空间时至少为16字节。下面来继续验证一下我们的结论是否正确:

这里需要注意: 在计算对象内存大小时不要忽略 isa 的 8 字节大小。

// 自定义类
@property (nonatomic, copy) NSString *name;
@property (nonatomic, assign) int age;
@property (nonatomic, assign) long height;
@property (nonatomic, strong) NSString *hobby;
// 
MCPerson *person = [[MCPerson alloc] init];
NSLog(@"%lu", class_getInstanceSize([MCPerson class]));
NSLog(@"%zd", malloc_size((__bridge const void *)(person)));
// 打印结果
2020-02-18 23:39:29.977673+0800 LGTest[42667:2246768] 40
2020-02-18 23:39:29.979105+0800 LGTest[42667:2246768] 48

通过打印结果可以看出, 对象需要的内存空间为 40, 但是实际开辟的内存空间确实 48。那么到底是在哪里发成了问题:

从上图发现, 调用 calloc 方法时我们申请的内存大小是 40, 这里是没有问题的。继续往下执行,

然后我们打印一下生成的对象的内存大小发现, 结果为 0x0000000000000030 , 转换成10进制为 48 。那么, 问题出在 calloc 方法, 关于 calloc 的分析由于篇幅较长这里不做叙述了, 有兴趣的同学可以去看看 Cooci老师的malloc分析, 下面我们直接进入重点实现:

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
  // #define NANO_REGIME_QUANTA_SIZE	(1 << SHIFT_NANO_QUANTUM)	// 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;
}

通过对 calloc 的一连串探索操作, 最终我们找到了上面的方法, 也找到了对象申请内存大小与实际大小不一样的关键问题。可以看出我们传过来的 size 经过 (size + 16 - 1) >> 16 << 16 之后返回, 这不类似于之前属性的 8字节对齐 吗, 不同点在于这里是 16字节对齐

总结

以上就是这次内存对齐原理的全部内容, 通过以上的内容, 我们可以明白内存对齐的相关概念, 以及对象创建的过程中是怎样进行内存对齐的。首先在获取对象所需要的内存大小的时候进行了 属性的 8字节对齐, 然后在返回时进行了 <16 判断, 最后就是 calloc 申请内存时又进行了一次对象的 16字节对齐通过这两次的字节对齐, 能有防止访问溢出, 同时也能够有效的提高寻址访问效率。