iOS进阶— alloc底层原理探索

前言

作为一名iOS开发人员,平时最熟悉的莫过于对象的创建和初始化方法 allocinit 了。但是最近遇到的一道题,却让我觉得它们就像最熟悉的陌生人,想要一探究竟。
Snip20210606_1.png

按照我的猜想,p1、p2、p3变量地址不同,指向地址也不相同。但实际的执行结果出乎我的意料。

Snip20210606_3.png

p1、p2、p3打印的对象及指向地址完全相同,所不同的仅仅是指针变量本身的地址。为什么会有这样的结果呢,由此不妨先猜想,实际上 alloc 才会发生对象的内存开辟,init 并不会影响内存。下面我们就一起探索一下,来验证这个猜想。

一、alloc及init探索

1.1 定位源码

首先我们打开 XCocde 的汇编调试,Debug –> Debug overflow –> Always show Disassembly,在 allocinit (即13行 和 14行)分别打上断点,通过单步执行,我们发现这两个方法都指向同一个库libobjc。

Snip20210607_4.png

Snip20210607_5.png

虽然iOS系统并没有开源,但是苹果也提供了一些部分源码开源,以供开发者学习研究,我们可以通过苹果开源网站 opensource 来找到并下载源码,本次我们探索的是 macOS 11.2下的 objc 818 源码,可以通过如下地址下载和配置。

源码地址: 源码地址

源码配置可参考大神的文章:源码调试配置

1.2 验证猜想

在 BPPerson 中,我们并未定义 allocinit 方法,因此可以推测这两个方法在父类中,而且通过汇编调试结果看,也指向了父类 NSObject ,因此我们可以在源码中搜索这两个方法,查看其源码实现。

打开源码工程,全局搜索 allocinit ,我们可以在 NSObject.mm 找到这两个方法的定义如下:

+ (id)alloc {
    return _objc_rootAlloc(self);
}

id
_objc_rootAlloc(Class cls)
{
    return callAlloc(cls, false/*checkNil*/, true/*allocWithZone*/);
}

复制代码
+ (id)init {
    return (id)self;
}

- (id)init {
    return _objc_rootInit(self);
}

id
_objc_rootInit(id obj)
{
    // In practice, it will be hard to rely on this function.
    // Many classes do not properly chain -init calls.
    return obj;
}
复制代码

从源码可以看出,init 方法最终执行 _objc_rootInit 函数,且返回了对象本身,而 alloc 方法最终执行了 callAlloc 方法,在此方法中进行了对象的开辟。由此执行结果和源码实现都可以验证我们的猜想。

init 方法返回自身,为何还要实现并调用呢?
原因是这样实现可以让子类重写该方法,在这个方法中作自己需要的操作,同时也方便进行方法的扩展。所以init方法还是有必要实现并调用的。

二、alloc 流程

验证了 allocinit 的方法实现,我们还可以来探索一下 alloc 方法的实现流程,具体看下在对象开辟过程中都做了哪些事情。

2.1 alloc的符号绑定

2.1.1 符号绑定的汇编验证

在上面的探索中,当调用 [BPPerson alloc] 时,按照我们的经验,此时会进入 NSObject+ (id)alloc 方法,因为这仅仅是类方法的简单调用。但在实际调试中,当断点执行到 [BPPerson alloc] 时,进行汇编的单步调试 (ctrl + 控制台单步执行按钮) 时,却发现下一步走到了如下地方

Snip20210607_8.png

Snip20210607_7.png

我们发现,代码并没有直接走到 + (id)alloc 方法,而是到了 objc_alloc 中,对即将跳转的地址 0x0000000100003eb6 进行读汇编,可以发现这里这里最终会调用 dyld 的 dyld_stub_binder 函数,也就是说这里可能是在进行一个符号的绑定。

2.1.2 Mach-O 文件查看

通过查看Mach-O 文件的 lazy-symbolSymbol table 我们可以发现,在编译完成后,并未生成 alloc 的符号,而是只有 _objc_alloc 的符号。

Snip20210607_9.png
Snip20210607_10.png

2.1.3 符号绑定源码验证

由汇编和Mach-O的探索可以发现调用 alloc 后,实际会先调用 objc_alloc,对此我们可以在源码中进行验证。在 objc 工程中全局搜索 objc_alloc,在58个结果中一一查看,我们可以发现如下一段代码:

/***********************************************************************
* fixupMessageRef
* Repairs an old vtable dispatch call site. 
* vtable dispatch itself is not supported.
**********************************************************************/
static void 
fixupMessageRef(message_ref_t *msg)
{    
    msg->sel = sel_registerName((const char *)msg->sel);

    if (msg->imp == &objc_msgSend_fixup) { 
        if (msg->sel == @selector(alloc)) {
            msg->imp = (IMP)&objc_alloc;
        } else if (msg->sel == @selector(allocWithZone:)) {
            msg->imp = (IMP)&objc_allocWithZone;
        } else if (msg->sel == @selector(retain)) {
            msg->imp = (IMP)&objc_retain;
        } else if (msg->sel == @selector(release)) {
            msg->imp = (IMP)&objc_release;
        } else if (msg->sel == @selector(autorelease)) {
            msg->imp = (IMP)&objc_autorelease;
        } else {
            msg->imp = &objc_msgSend_fixedup;
        }
    } 
    else if (msg->imp == &objc_msgSendSuper2_fixup) { 
        msg->imp = &objc_msgSendSuper2_fixedup;
    } 
    else if (msg->imp == &objc_msgSend_stret_fixup) { 
        msg->imp = &objc_msgSend_stret_fixedup;
    } 
    else if (msg->imp == &objc_msgSendSuper2_stret_fixup) { 
        msg->imp = &objc_msgSendSuper2_stret_fixedup;
    } 
#if defined(__i386__)  ||  defined(__x86_64__)
    else if (msg->imp == &objc_msgSend_fpret_fixup) { 
        msg->imp = &objc_msgSend_fpret_fixedup;
    } 
#endif
#if defined(__x86_64__)
    else if (msg->imp == &objc_msgSend_fp2ret_fixup) { 
        msg->imp = &objc_msgSend_fp2ret_fixedup;
    } 
#endif
}
复制代码

在这段代码中我们可以发现,当调用 alloc 方法,即 msg->sel == @selector(alloc) 下时,实际会将IMP 重新赋值为 objc_alloc,由此从源码上验证了符号绑定的猜想。

2.2 alloc流程分析

2.2.1 关键函数定位

既然会先调用 objc_alloc,那我们就看下 objc_alloc 函数做了哪些事情。在源码中搜索 objc_alloc,可以找到如下的代码

// Calls [cls alloc].
id
objc_alloc(Class cls)
{
    return callAlloc(cls, true/*checkNil*/, false/*allocWithZone*/);
}
复制代码

对比 alloc 的源码,我们可以发现两个函数最终都会调用 callAlloc,因此,我们进入关键函数 callAlloc 中看一下,其源码如下:

static ALWAYS_INLINE id
callAlloc(Class cls, bool checkNil, bool allocWithZone=false)
{
#if __OBJC2__
    if (slowpath(checkNil && !cls)) return nil;
    if (fastpath(!cls->ISA()->hasCustomAWZ())) {
        return _objc_rootAllocWithZone(cls, nil);
    }
#endif

    // No shortcuts available.
    if (allocWithZone) {
        return ((id(*)(id, SEL, struct _NSZone *))objc_msgSend)(cls, @selector(allocWithZone:), nil);
    }
    return ((id(*)(id, SEL))objc_msgSend)(cls, @selector(alloc));
}
复制代码

我们分别在 alloc、 objc_alloc、 callAlloc 打上断点,通过断点调试源码我们发现一个调用顺序 objc_alloc -> callAlloc -> alloc -> _objc_rootAlloc -> callAlloc

  • 在这个流程中 callAlloc 被调用了两次,第一次在调用 objc_alloc 之后进入,之后通过 objc_msgSend 消息发送,调用了 + alloc 方法
  • 在调用 + alloc 后,进入 callAlloc 则会走进 _objc_rootAllocWithZone 函数中。

接下来我们就可以进入 _objc_rootAllocWithZone,进入其中查看对象创建的流程。

Tips: slowpath() 和 fastpath() 是编译器优化,分别表示 条件很小可能执行 和 条件很大可能执行。因此,编译器在加载指令时,遇到slowpath时,可以暂不加载,等到条件触发了再加载。从而优化指令加载时机,提高效率。

2.2.2 _objc_rootAllocWithZone函数分析

_objc_rootAllocWithZone 函数中只调用 _class_createInstanceFromZone 函数,继续跟进去可以发现这就是对象创建的函数

/***********************************************************************
* class_createInstance
* fixme
* Locking: none
*
* Note: this function has been carefully written so that the fastpath
* takes no branch.
**********************************************************************/
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)
{
    ASSERT(cls->isRealized());

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

    size = cls->instanceSize(extraBytes); // 内存对齐,计算实例的大小
    if (outAllocatedSize) *outAllocatedSize = size;
    
    // 创建对象
    id obj;
    if (zone) {
        obj = (id)malloc_zone_calloc((malloc_zone_t *)zone, 1, size);
    } else {
        obj = (id)calloc(1, size);
    }
    if (slowpath(!obj)) {
        if (construct_flags & OBJECT_CONSTRUCT_CALL_BADALLOC) {
            return _objc_callBadAllocHandler(cls);
        }
        return nil;
    }

    // isa 相关
    if (!zone && fast) {
        obj->initInstanceIsa(cls, hasCxxDtor);
    } else {
        // Use raw pointer isa on the assumption that they might be
        // doing something weird with the zone or RR.
        obj->initIsa(cls);
    }

    if (fastpath(!hasCxxCtor)) {
        return obj;
    }

    construct_flags |= OBJECT_CONSTRUCT_FREE_ONFAILURE;
    return object_cxxConstructFromClass(obj, cls, construct_flags);
}
复制代码

这个函数中的条件判断比较多,但主要的流程可以按照 获取对象大小、创建对象、初始化isa、返回对象 来分析。创建对象和isa的初始化相关的方法,如 calloc、initIsa 等本篇文章暂不探索,本次先看一下获取内存大小及内存对齐的部分。

2.2.3 内存对齐

在开辟对象前,先要获取对象的大小,即 size = cls->instanceSize(extraBytes); 这段代码,跟进去可以看到

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,否则调用 alignedInstanceSize,并且对象大小最小不低于16。

在获取大小时,会进行内存对齐,进入这两个方法会发现 word_alignalign16 的调用。

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

#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
复制代码

通过源码可以看出,内存对齐在64位机器上是以 8 字节对齐,在32位机器以 4 字节对齐。

Q1: 为什么要进行内存对齐?

  • 假定一个对象分别包含 int、double、指针类型的成员变量,CPU在读取内存时,分别按照各自类型的大小去读,则每读去一个变量就要重新计算要读取多大的空间,这样势必会影响效率
  • 如果每次都读取定长的空间,则不需要再进行计算。即以空间换时间,可以提升效率。
  • 虽然以8字节对齐会浪费一定的空间,但是系统也会进行优化,比如一个int 和 char类型变量,两者加起来也不足8字节,则适当时可以两则合并在同一个 8 字节空间中存储。

Q2: 为什么是8字节对齐?

  • 在基本类型中最长即为8字节,如果是结构体则可以存8字节,不够时再开辟8个字节。

三、总结

本文主要总结了 alloc 的大致流程以及内存对齐相关知识,最后再来总结 alloc 的流程图如下:

alloc 流程.png

本篇文章中还有部分内容没有探究到,关于对象创建和isa初始化会在之后的文章中总结。对于文章中不正确的地方还欢迎大家指正。

文章参考:

iOS 底层 – OC 对象的创建流程

关于alloc初探中alloc进入objc_alloc的原因

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享