iOS alloc 底层原理

423 阅读7分钟

前言

这篇文章主要是用来记录一下对alloc底层原理的探索。

源码:objc4

alloc & init & new

探索之前先大概了解一下alloc、init、new分别做了什么

  • alloc:创建新的对象
  • init:返回对象本身,没做其他任何处理,
  • new:等价于alloc+init
    NSObject * obj = [NSObject alloc];
    NSObject * obj1 = [obj init];
    NSObject * obj2 = [NSObject new];
    
    NSLog(@"obj :%p----%p",&obj,obj);
    NSLog(@"obj1:%p----%p",&obj1,obj1);
    NSLog(@"obj2:%p----%p",&obj2,obj2);

打印结果为:

 obj: 0x16b0e92d8----0x1c4010f10
 obj1:0x16b0e92d0----0x1c4010f10
 obj2:0x16b0e92c8----0x1c4010fd0

根据打印结果可以看出:

  • obj、obj1、obj2是一串连续的间隔8字节的指针地址,地址从高到底的顺序排列。由此得以验证:栈区是一段连续的内存空间,内存从高到低,64位下指针占用8字节的空间。
  • init直接返回原对象,所以obj、obj1指向的对象相同,而new则会重新创建一个对象,所以obj2会指向一个新的对象。

alloc源码探索

1、定位到源码库

1.1、在需要调试的位置打上断点。

9B042E65-F869-4AE5-9610-5ABEF8118307.png

  • Step Into 单步执行,遇到子函数就进入而且继续单步执行。
  • Step Over 在单步执行时,在函数内遇到子函数时不会进入子函数内单步执行,而是将整个子函数做为一个总体,至关于一条语句,只执行一步。
  • 上述两种单步调试配合control使用则变成汇编单步调试。

1.2、按住control键+step into进行单步调试进入到汇编,可以看到alloc将会调用到objc_alloc 截屏2021-06-07 上午1.43.18.png 1.3、接下来点击xcode左下角➕,选择Symbolic Breakpoint选项

截屏2021-06-07 上午1.30.21.png 1.4、输入需要添加断点的方法,比如objc_alloc(alloc会有很多次调用,最好先把符号断点设为disable,到我们需要的位置再打开,以避免干扰。) 截屏2021-06-07 上午1.47.30.png 截屏2021-06-07 上午2.58.36.png 1.5、根据符号断点可知,alloc流程源码位于libobjc.A.dylib中,我们可以在opensource下载objc4源码。

除此之外还可以打上断点,并勾选Debug -> Debug Workflow -> Always Show Disassembly。 截屏2021-06-07 上午1.35.56.png 运行程序到断点位置,就会直接进入到汇编代码。 截屏2021-06-07 上午1.55.29.png

2、分析alloc执行流程

2.1、首先在main函数中创建所需对象。

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        NSObject * obj = [NSObject alloc];
        JLObject * obj0 = [JLObject alloc];//继承于NSObject
        JLObject * obj1 = [JLObject alloc];//偶然发现第一次调用会进入到NSObject的alloc,第二次调用将会直接进入_objc_rootAllocWithZone
    }
    return 0;
}

2.2通过可编译objc4运行会发现,而是会进入objc_alloc函数,在汇编代码中我们看到了同样的结果。

截屏2021-06-07 下午4.06.49.png

2.3、跳转进入到callAlloc的源码:

callAlloc(Class cls, bool checkNil, bool allocWithZone=false)
{
#if __OBJC2__
    if (slowpath(checkNil && !cls)) return nil;
    /*
     * class或superclass中是否存在默认的alloc/allocWithZone实现。
     */
    if (fastpath(!cls->ISA()->hasCustomAWZ())) {
        return _objc_rootAllocWithZone(cls, nil);
    }
#endif
    if (allocWithZone) {
        return ((id(*)(id, SEL, struct _NSZone *))objc_msgSend)(cls, @selector(allocWithZone:), nil);
    }
    return ((id(*)(id, SEL))objc_msgSend)(cls, @selector(alloc));
}
  • __OBJC2__ 用来判断objc版本是不是objc2,而当前我们使用的都是新版本objc2.0。
  • slowpath告诉编译器这里大概率为假,fastpath告诉编译器这里大概率为真,用来支持编译器对代码进行优化。

2.4、进入到_objc_rootAllocWithZone,发现会跳转到_class_createInstanceFromZone方法中。

_objc_rootAllocWithZone(Class cls, malloc_zone_t *zone __unused)
{
    // allocWithZone under __OBJC2__ ignores the zone parameter
    return _class_createInstanceFromZone(cls, 0, nil,
                                         OBJECT_CONSTRUCT_CALL_BADALLOC);
}

2.5、_class_createInstanceFromZone里面包含了alloc的核心操作。

_class_createInstanceFromZone(Class cls, size_t extraBytes, void *zone,
                              int construct_flags = OBJECT_CONSTRUCT_NONE,
                              bool cxxConstruct = true,
                              size_t *outAllocatedSize = nil)
{
    ......
    bool fast = cls->canAllocNonpointer();
    size_t size;
    //计算实例大小,实际占用空间8字节对齐,外部16字节对齐
    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 (!zone && fast) {
        //是否有C++或Objc的析构器
        //nonpointer
        obj->initInstanceIsa(cls, hasCxxDtor);
    } else {
        //纯地址指针,
        obj->initIsa(cls);
    }
    ......
    return object_cxxConstructFromClass(obj, cls, construct_flags);
}

3、alloc的核心方法

  • instanceSize计算需要的内存空间大小;
  • calloc根据计算的大小开辟内存;
  • initInstanceIsa初始化实例的isa;

4、分析objc_alloc

  • 问题:断点到alloc调用的行,通过汇编与运行源码发现调用alloc后并不会进入到alloc的实现,而是会进入到objc_alloc中,这是如何做到的呢?
  • 猜测:既然在调用alloc时会进入到objc_alloc的实现,那首先想到的就是sel所对应的imp在某一个时间被替换了。

alloc的实现:

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

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

4.1、根据上述的猜测,首先对objc_alloc进行全局的搜索,果然找到了fixupMessageRef方法。打断点并运行,发现全程并未计入到断点。

截屏2021-06-13 下午1.25.03.png 4.2、根据fixupMessageRef的调用向上追溯,发现是在_read_images中进行了调用,它的作用是对objc_msgSend进行修复,而这里并没有执行,alloc的imp指向却变了,而在源码中我们没有再找到其他的修改alloc imp的地方。

4.3、既然不是运行时修改的,那会不会是在编译阶段就进行了替换呢,打开llvm,全局搜索objc_alloc,在tryGenerateSpecializedMessageSend中找到了我们想要的结果,进入到EmitObjCAlloc,

截屏2021-06-13 下午1.52.37.png

4.4、如图就是在这里llvm将alloc的imp修改到了objc_alloc。 截屏2021-06-13 下午1.54.37.png

5、分析hasCustomAWZ()

我们回过头来分析一下hasCustomAWZ()方法,从这里开始NSObject与其子类调用流程会有所不同

!hasCustomAWZ()

  • NSObject会直接进入到_objc_rootAllocWithZone中,
  • JLObject首次调用回进入objc_msgSend流程,调用NSObject的alloc,然后执行_objc_rootAlloc之后又再次调用callAlloc。
  • JLObject第二次调用会直接进入到_objc_rootAllocWithZone,此处与NSObject相同。 猜测可能JLObject第一次调用时,通过慢速方法查找流程,将父类中的方法加入到了cache中,JLObject第二次调用的时候方法在cache中已经存在,所以就不需要再次进入到慢速查找流程,从而直接进入_objc_rootAllocWithZone。

下面我们去验证一下这个猜想。

第一步:进入hasCustomAWZ() 我们发现如下代码:

bool hasCustomAWZ() const {
    return !cache.getBit(FAST_CACHE_HAS_DEFAULT_AWZ);
}

其中FAST_CACHE_HAS_DEFAULT_AWZ是一个宏定义

截屏2021-06-10 下午7.10.43.png

第二步:进入getBit,flags是cache_t的一个成员,用来以二进制的方式记录一些信息,将FAST_CACHE_HAS_DEFAULT_AWZ和_flags做与操作进行验证是否实现了alloc/allocWithZone。

bool getBit(uint16_t flags) const {
    return _flags & flags;//
}

分析:能否进入_objc_rootAllocWithZone则是由_flags来决定,JLObject第一次不能直接进入,而第二次调用却可以,那说明_flags的AWZ标记位在JLObject第一次调用后被置为1。

第三步:全局搜索FAST_CACHE_HAS_DEFAULT_AWZ发现只在三个地方出现,其中包含一个get和两个set方法,hasCustomAWZ方法运行过之后在set方法打上断点,发现会进入到setHasDefaultAWZ()

截屏2021-06-10 下午7.28.24.png

第四步:查看堆栈信息

截屏2021-06-10 下午7.31.57.png

第五步:根据堆栈信息,我们进入到objc_class::setInitialized()从注释可以看出,NSObject默认就会被标记为AWZ,所以NSObject第一次就可以进入到_objc_rootAllocWithZone中。

截屏2021-06-10 下午7.35.17.png

第六步:继续沿着调用栈我们发现lookUpImpOrForward(),这是在消息流程,继续往回追溯又一次进入到了callAlloc方法。进入到callAlloc中,发现是在通过objc_msgSend调用alloc,到了这里思路大概就清晰了。 截屏2021-06-10 下午7.39.24.png

结论:

  • NSObject默认标记AWZ为1,所以第一次if (fastpath(!cls->ISA()->hasCustomAWZ()))为真。直接进入了_objc_rootAllocWithZone
  • JLObject第一次if (fastpath(!cls->ISA()->hasCustomAWZ()))为假。所以会跳过_objc_rootAllocWithZone方法。
  • allocWithZone = nil,跳过allocWithZone,运行到消息查找alloc,从而进入到lookUpImpOrForward()
  • alloc/allocWithZone在NSObject已经实现,所以消息查找一定可以找到并存到cache中。
  • 然后经过一系列的方法调用到了 setHasDefaultAWZ() ,将AWZ标记改为1。
  • 第二次再调用时因为已经标记了AWZ,所以if (fastpath(!cls->ISA()->hasCustomAWZ()))为真,直接进入到_objc_rootAllocWithZone中。

流程图

截屏2021-06-08 上午12.58.15.png

补充

init源码探索

接着我们再去看一看init的源码实现

截屏2021-06-10 下午8.09.17.png

点击进入_objc_rootInit,没有做其他任何事情,直接返回了对象本身。

截屏2021-06-10 下午8.09.44.png

其实init是一个构造方法,用于给用户提供构造方法入口,是工厂方法模式的一种运用。

new源码探索

进入到new的源码,发现调用到了callAlloc,这与alloc->objc_alloc->callAlloc是一致的,只是参数不同,allocWithZone参数不传的话默认为false所以此参数两个方法是一致的,只有checkNil不一致。 截屏2021-06-10 下午8.17.37.png 截屏2021-06-10 下午8.27.14.png

点进到callAlloc发现checkNil只参与了一个判断检测cls是否为空,为空直接return,而且是一个小概率的事件,所以我们可以认为在callAlloc方法的调用上,两者是基本相同的。不同之处在于new方法中直接调用了init。 截屏2021-06-10 下午8.29.13.png

建议:new会自动帮我们调用init,看起来很方便,但是一般并不提倡使用new,因为开发过程中重写init是很常见的,而形式千变万化,例如initWithxxx,如果使用了new进行初始化,将走不到自定义的initWithxxx中。