[iOS] OC底层探索——对象原理之内存分配&对齐

178 阅读3分钟

前言

前文讲到,alloc是oc中创建对象并分配内存的函数,其源码最终会调用calloc函数。

本文将继续探索如何确定对象创建所需内存大小与内存分配的原理。

对象创建所需内存大小

调试分析

示例代码

@interface EmptyFoo : NSObject
@end

@interface PropertyFoo : NSObject
@property (nonatomic, strong) NSString *name;
@property (nonatomic, assign) int age;
@end

@interface MemberFoo : NSObject
{
    NSString *name;
    int age;
}
@end

@interface FuncFoo : NSObject
- (void)test;
+ (void)test2;
@end

int main() {
    EmptyFoo *o = [EmptyFoo alloc];
    NSLog(@"objectSize:%lu mallocSize:%lu", class_getInstanceSize(EmptyFoo.class), malloc_size((__bridge void *)o));
    PropertyFoo *o1 = [PropertyFoo alloc];
    NSLog(@"objectSize:%lu mallocSize:%lu", class_getInstanceSize(PropertyFoo.class), malloc_size((__bridge void *)o1));
    MemberFoo *o2 = [MemberFoo alloc];
    NSLog(@"objectSize:%lu mallocSize:%lu", class_getInstanceSize(MemberFoo.class), malloc_size((__bridge void *)o2));
    FuncFoo *o3 = [FuncFoo alloc];
    NSLog(@"objectSize:%lu mallocSize:%lu", class_getInstanceSize(FuncFoo.class), malloc_size((__bridge void *)o3));
}

运行结果:

EmptyFoo    objectSize:8  mallocSize:16
PropertyFoo objectSize:24 mallocSize:32
MemberFoo   objectSize:24 mallocSize:32
FuncFoo     objectSize:8  mallocSize:16

关于class_getInstanceSizemalloc_size的区别可见:「类与对象」如何准确获取对象的内存大小?

通过本地调试,我们简单确认了以下几点:

  • 成员变量和属性都会影响对象的大小,类方法和实例方法不会。
  • 空类(即NSObject)的对象本身就有8字节的大小(ISA)。
  • 对象本身大小和内存分配给对象的大小不一致,前者是8字节对齐,后者是16字节对齐。

这里对象本身大小和分配内存大小如何理解:如下图所示,一个空NSObject对象在内存中占用16字节大小,但对象本身只使用了8字节大小。

20220215-140039.png

源码分析

回顾一下OC中对象创建的底层源码如下,可发现申请内存的大小是由cls->instanceSize计算所得。

static ALWAYS_INLINE id
_class_createInstanceFromZone(Class cls, size_t extraBytes, void *zone,
                              int construct_flags = OBJECT_CONSTRUCT_NONE,
                              bool cxxConstruct = true,
                              size_t *outAllocatedSize = nil)
{
    ...
    size_t size;
    // !!计算创建对象需要的大小 会进行字节对齐!!
    size = cls->instanceSize(extraBytes);
    ...
    id obj = (id)calloc(1, size);
    ...
    return obj;
}

cls->instanceSize()

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

当缓存中有实例大小时走fastInstanceSize,可以注意到返回值执行了align16,说明进行了16字节对齐。

size_t fastInstanceSize(size_t extra) const
{
    ...
    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);

}

没有缓存时通过alignedInstanceSize()计算实例大小,64位机下会进行8字节对齐。

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

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

综上,有两个问题需要关注

  1. 走缓存和直接计算所需的实例大小时不一样的,前者16字节对齐,后者8字节对齐,这里是不是有什么问题?

    回顾上文,计算出的实例大小是用于id obj = (id)calloc(1, size);,实际上calloc函数在申请内存大小时本身是16字节对齐的,所以走缓存或直接计算所得的size不一致也不会对内存分配造成影响。这里后文会有更详细的讲解。

  2. 为什么第一次运行cache.hasFastInstanceSize()也为true,即走有缓存的逻辑?

    这个问题可通过下图回答,_read_images可以理解为在程序最开始初始化阶段执行。

instanceSize流程.png

内存分配原理

调试分析

上文讲到,内存分配最终会调用calloc函数,且calloc函数申请的内存会进行16字节对齐。

示例代码

void *p = calloc(1, 40);
NSLog(@"%lu",malloc_size(p));    // 运行结果输出48

源码分析

calloc

void * calloc(size_t num_items, size_t size) {
    return _malloc_zone_calloc(default_zone, num_items, size, MZ_POSIX);
}

MALLOC_NOINLINE
static void *
_malloc_zone_calloc(malloc_zone_t *zone, size_t num_items, size_t size,
		malloc_zone_options_t mzo) {
    ...
    void *ptr;
    ptr = zone->calloc(zone, num_items, size);
    return ptr;
}

到这里会发现zone->calloc()无法继续直接跳转,我们通过断点进入实际执行的函数。

// 此函数为_malloc_zone_calloc中zone->calloc()实际执行函数
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);
}

// 此函数即default_zone_calloc中zone->calloc()执行的函数
static void *
nano_calloc(nanozone_t *nanozone, size_t num_items, size_t size)
{
    size_t total_bytes;
    ...
    if (total_bytes <= NANO_MAX_SIZE) {
        // !!重点函数!!
        void *p = _nano_malloc_check_clear(nanozone, total_bytes, 1);
        if (p) {
            return p;
        }
    }
    ...
}

最终我们来到了底层的关键函数_nano_malloc_check_clear,其中我们主要关注计算实际分配内存大小的函数segregated_size_to_fit,具体分配内存的代码讲解感兴趣的可见:iOS 高级之美(六)—— malloc分析

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);
    }
    // 初始化内存为0
    if (cleared_requested && ptr) {
        memset(ptr, 0, slot_bytes); // TODO: Needs a memory barrier after memset to ensure zeroes land first?
    }
    return ptr;
}

segregated_size_to_fit

由源码可见,对传入的size进行了16字节对齐。

#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;
    ...
    // 16字节对齐(+15 右移4位 左移4位)
    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
    ...
    return slot_bytes;
}

综上,内存分配的calloc函数为什么进行了16字节对齐已经水落石出。

至于为什么OC对calloc要进行16字节对齐,网上能找到的说法主要是为了有容错空间,因为一个ISA是8字节,如果实例分配按8字节对齐,只要内存地址偏移一点就容易访问到别的对象上。16字节对齐兼顾了内存利用率和容错率。

我自己理解可能也与兼容Swift有关,Swift的对象基础大小就是16字节。

引用文章