IOS 底层原理 alloc探究

98 阅读6分钟

前言

作为一名 iOS 程序员,每天忙碌在各个业务线,做过很多功能开发过很多App,却很少有机会去了解 OC底层的一些原理和实现机制。今天就让我们一起去探索alloc的底层流程。在此之前我们做个小测试。下面分别输出对象的内容,对象的地址,以及对象指针的地址代码和打印如下:

    XZPerson * obj = [XZPerson alloc];
    XZPerson * obj1 = [obj init];
    XZPerson * obj2 = [obj init];
     
    XZPerson * newObj = [XZPerson alloc];

    NSLog(@"%@---%p--%p",obj,obj,&obj);
    NSLog(@"%@---%p--%p",obj1,obj1,&obj1);
    NSLog(@"%@---%p--%p",obj2,obj2,&obj2);
    NSLog(@"%@---%p--%p",newObj,newObj,&newObj);
    <XZPerson: 0x2802e8f60>---0x2802e8f60--0x16b185b58
    <XZPerson: 0x2802e8f60>---0x2802e8f60--0x16b185b50
    <XZPerson: 0x2802e8f60>---0x2802e8f60--0x16b185b48
    <XZPerson: 0x2802e8f30>---0x2802e8f30--0x16b185b40

截屏2022-02-23 上午11.00.43.png

经过分析可得出以下结论

  • objobj1obj2打印的内容,对象的地址是一样的,但是对象的指针地址不一样。
  • newObjobjobj1obj2打印的内容,对象的地址,指针的地址都是不一样的。

这就要问一句 why? 原因如下图:

截屏2022-02-23 上午11.11.04.png

总结:

  • alloc具有开辟一块内存功能,而init没有开辟内存的功能。
  • 栈区开辟的内存是地址到地址,堆区则是地址到地址。

请让我们一起来探究在初始化对象时,alloc到底做了什么?

准备工作

  1. 下载源码 objc4-818.2

  2. 编译源码(请各位看官自行搜索一下 “objc4-818.2源码编译”,我也是参考其他博友的)

下面介绍三种探索底层的方法

1. 符号断点

在需要调试的位置打上断点,当断点断住的时候按住control键,然后step into 进入下一步,然后在汇编里面找到方法,然后给这个方法添加符号断点,加完符号断点,continue 单步到符号断点。具体流程如下图

截屏2022-02-23 下午2.55.42.png

截屏2022-02-23 下午2.57.33.png

截屏2022-02-23 下午3.02.31.png

截屏2022-02-23 下午3.02.50.png

2. 汇编

运行程序,当执行到断点的时候,Xcode -> Debug -> Debug Workflow -> Always Show Disassembly 进入汇编找到跳转的方法,此时有两种方式,一种找到方法直接添加断点调试。另一种就是断住跳转的地方,按住control键,然后step into 进入下一步,这种stp into 可以一直走,过程比较繁琐,如下图:

截屏2022-02-23 下午3.26.24.png

截屏2022-02-23 下午3.26.33.png

截屏2022-02-23 下午3.32.43.png

3. 直接加符号断点

原则上需要探究哪个方法就添加那个方法的符号断点。(不过需要注意的是,在程序运行之前一定要先关闭符号断点,程序断在断点位置以后再启用符号断点。不然项目中可能有很多用到符号断点的方法,你会很凌乱,找不到目标和方向。。。)

截屏2022-02-23 下午3.34.47.png

截屏2022-02-23 下午3.34.57.png

截屏2022-02-23 下午3.35.26.png

截屏2022-02-23 下午2.55.42.png

截屏2022-02-23 下午3.44.13.png

编译源码

根据源码工程,点击进入alloc, 流程图如下

截屏2022-02-23 下午4.16.32.png 探索流程如下断点进入 alloc

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

断点进入 _objc_rootAlloc

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

断点进入 callAlloc

    static ALWAYS_INLINE id
    callAlloc(Class cls, bool checkNil, bool allocWithZone=false)
    {
    #if __OBJC2__ //判断是不是 objc2.0版本
        //slowpath(x):x很可能为假,为真的概率很小 
        //fastpath(x):x很可能为真 
        //其实将fastpath和slowpath去掉是完全不影响任何功能,写上是告诉编译器对代码进行优化
        if (slowpath(checkNil && !cls)) return nil;
        //判断该类是否实现自自定义的 +allocWithZone,没有则进入if条件句
        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));
    }


断点进入 _objc_rootAllocWithZone

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


此时走入真正的核心代码,断点进入 _class_createInstanceFromZone

截屏2022-02-23 下午4.39.34.png 从流程图和源代码可以看出 _class_createInstanceFromZone 方法中有核心三个方法需要实现

  • cls->instanceSize : 计算内存大小
  • (id)calloc(1, size) : 开辟内存,返回地址指针
  • obj->initInstanceIsa :初始化指针,和类关联起来

下面就对这三个方法重点分析

instanceSize:计算内存大小

断点进入 instanceSize

    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 (16字节对齐)

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


我们探究下 align16 方法的具体实现 已 align16(10)为例

x = 10 (x + size_t(15)) & ~size_t(15)  ~(取反)
10 + 15 = 25  0001 1001
15  0000 1111 
~15  1111 0000
25 & ~15  0001 1001 & 1111 0000
结果:0001 0000  16

总结:align16 算法实际上就是取16的整数倍。我认为是向下取整,理由我是站在纯算法的角度,(x + 15)16几倍,超过的部分抹去。例如 (20 + 15) = 35 = 16 * 2 + 3,结果是32。这种算法和 >> 4 << 4 是一样的,得出的结果就是16的倍数,不足16的全部抹去。

为什么需要16字节对齐

  • cpu 读取数据是以固定字节块来读取的,这是一个用空间换取时间的做法,如果频繁的读取字节未对齐的数据,降低了cpu的性能和读取速度。
  • 更安全 由于在一个对象中isa指针是占8个字节,如果不进行节对齐 ,对象之间就会紧挨着,容易造成访问混乱。16字节对齐,会预留部分空间,访问更安全

calloc:开辟内存,返回地址指针

首先由 instanceSize 方法 计算出需要的内存大小,然后向系统申请 size 大小的内存,返回给objc,因此objc是指向内存地址的指针,下面我们通过断点打印的方法来验证下

截屏2022-02-24 上午8.56.24.png

从上图中可以看出,obj还没有进行赋值,此时有地址值,说明系统给他分配了一块脏地址,我们继续走到下一个断点

截屏2022-02-24 上午8.56.48.png

上图:执行calloc后打印的是一个16进制的指针地址,说明已经开辟了内存,但是和平常见到的地址指针(<LWPerson: 0x100726d00>)不一样,为什么呢?

  • obj没有和cls进行关联绑定
  • 同时验证了calloc只是开辟了内存

initInstanceIsa:初始化指针 ,和类关联起来

断点进入 initInstanceIsa

    inline void 
    objc_object::initInstanceIsa(Class cls, bool hasCxxDtor)
    {
        ASSERT(!cls->instancesRequireRawIsa());
        ASSERT(hasCxxDtor == cls->hasCxxDtor());

        initIsa(cls, true, hasCxxDtor);
    }

具体的 isa 结构和源码探究,会在后面单独发文

isa指针初始化以后,打印objc

截屏2022-02-24 上午8.57.40.png

上图中打印结果显示:指针已经与类进行了关联,alloc探索也完成了。

alloc流程图

alloc.png

init探究

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

断点进入 _objc_rootInit

    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方法返回的是对象本身
  • init可以提供给开发者更多的自由去自定义 ,通过id实现强转,返回我们需要的类型

new探究

    + (id)new {
        return [callAlloc(self, false/*checkNil*/) init];
    }


源码显示 走了callAlloc的方法流程,然后走了init方法 ,所以 new看做是alloc + init

总结

alloc 的核心作用就是开辟内存,通过isa指针与类进行关联,init方法就是个工厂方法,通过id实现强转,返回我们需要的类型然后提供给对象本身,提供开发者更多的自由即方便我们进行方法重写来完成一些特殊的功能呢需求,new 是对(alloc+init)进行了封装,无法在初始化的时候添加其它的需求。综合来说建议开发者使用 [[Class alloc]init] 去初始化对象。

补充:为什么创建一个对象的时候,要走两次 callAlloc 方法?

通过 llvm 分析,苹果做了插桩处理:

  • 第一次:执行alloc时,会通过方法映射,调用objc_alloc,此时做了 插桩 操作(做标记 receiver),接下来就是第一次调用 callAlloc → objc_msgSend(alloc)
  • 第二次:再次执行alloc,再次执行objc_alloc,发现有标记存在,所以不再执行 objc_alloc方法,而是调用本身的 alloc,进而执行 _objc_rootAlloc→ callAlloc → objc_msgSend(allocWithZone)