《iOS内卷》:从最熟悉的alloc和init聊起

·  阅读 614

.png

一、从最熟悉的代码入手

iOS开发者接触的第一行代码,除了NSLog(@"Hello workd!")外,大概就是[[NSObject alloc] init]了,这应该是我们最熟悉的代码。但是,对于alloc init,你真的够熟悉吗?

思考以下代码的输出:

Person *p1 = [Person alloc] init];
Person *p2 = [p1 init];
Person *p3 = [p1 init];

NSLog(@"%@-%p-%p", p1, p1, &p1);
NSLog(@"%@-%p-%p", p2, p2, &p2);
NSLog(@"%@-%p-%p", p3, p3, &p3);
复制代码

控制台输出:

<Person: 0x6000003000f0> - 0x6000003000f0 - 0x7ffeee282028
<Person: 0x6000003000f0> - 0x6000003000f0 - 0x7ffeee282020
<Person: 0x6000003000f0> - 0x6000003000f0 - 0x7ffeee282018
复制代码

看完输出是不是觉得[[NSObject alloc] init]没那么熟悉了?
为什么p1、p2、p3的值是一样的?init咋看起来没啥卵用?allocinit到底做了什么?
按住Control键,想要查看alloc源码,却发现啥也没有,该如何解决?

带着以上问题,我们开始今天的探索之旅。

二、探索的三种思路

符号断点调试

在哪里打断点?断点符号有哪些? 既然我们不知道要将断点打到哪,我们将先对已知的alloc打一个断点,断点类型选择Symbolic Breakpoint

image.png

在断点处按住control + step into,你会发现最后来到了这个地方:

image.png

objc_alloc就是我们需要下的另一个断点。

循环上述步骤,把过程中出现的函数,都打上符号断点,就可以继续探索。

汇编跟踪

汇编跟踪是最直观的方式。虽然有些晦涩难懂,但是关键方法的调用都可以看到。 在[Person alloc] init];处打上普通断点,然后勾选Debug -> Debug Workflow -> Always Show Disassembly

image.png

可以看到objc_alloc是下一个要执行的函数。

运行源码

无论是符号断点调试,还是汇编跟踪,调试起来其实都相当麻烦,所以有没有更好的方法? 很多开发者以为苹果是闭源的,但实际上,苹果已逐渐将部分源码开放出来。

我们需要的是Objc4的源码:

image.png

GitHub上有可以直接运行的版本:Objc4

通过运行源码,跟踪调用流程,我们完全摆脱意淫式的底层探索。

三、alloc的主线调用流程

image.png

alloc的调用开始,我们可以在源码中大概找到这些核心方法:
1、alloc

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

2、_objc_rootAlloc

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

3、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));
}
复制代码

4、_objc_rootAllocWithZone

NEVER_INLINE
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);
}
复制代码

5、_class_createInstanceFromZone

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;

    //计算obj需要使用的内存空间大小
    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);
    }
    
    //此时obj还没有和类绑定在一起,只是单纯的内存区域
    if (slowpath(!obj)) {
        if (construct_flags & OBJECT_CONSTRUCT_CALL_BADALLOC) {
            return _objc_callBadAllocHandler(cls);
        }
        return nil;
    }

    //obj和类绑定
    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);
}
复制代码

需要注意的是,虽然我们从源码中看到调用顺序如上面所示,但是实际上的调用顺序要复杂的多。原因是:有一些方法被苹果hook住,做了方法交换,用于苹果自身的某些功能实现。例如:埋点、统计等。

主要关注fixupMessageRef这个方法:

image.png 可以看到,当msgselalloc的时候,它的imp会被替换成objc_alloc。这就解释了,为什么我们调用的明明是alloc,但是通过汇编看到的却是objc_alloc

四、对象大小的计算

在上面的_class_createInstanceFromZone方法中,有这么一句代码:size = cls->instanceSize(extraBytes);,它用来计算初始化当前实例变量需要占用多少内存空间。

1、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;
}
复制代码

if (size < 16) size = 16;:规定内存分配,最少分配16个字节。

2、alignedInstanceSize

8字节对齐。

// Class's ivar size rounded up to a pointer-size boundary.
uint32_t alignedInstanceSize() const {
    return word_align(unalignedInstanceSize());
}
复制代码

注释写得很明白:类的ivar大小向上舍入到指针大小边界。意思是:类ivar的大小最小都是8个字节。
unalignedInstanceSize:未做对齐的实例变量字节大小。

3、word_align

8字节对齐的实现。

static inline uint32_t word_align(uint32_t x) {
    return (x + WORD_MASK) & ~WORD_MASK;
}
复制代码
#   define WORD_MASK 7UL
复制代码

x做8字节对齐:如果x是7,那么对齐之后就是8,如果输入的是12,那么对齐之后就是16。

五、字节对齐算法

我们详细剖析一下这句公式:(x + WORD_MASK) & ~WORD_MASK

假设我们要对7做8字节对齐。从宏定义中已知WORD_MASK7,代入公式得:

(7 + 7) & ~7
复制代码

即:14 & ~7

用二进制表示:

   0000 1110
&  1111 1000
------------ = 8
   0000 1000
复制代码

得到结果8
这里最巧妙的就是~7(1111 1000)。任何数按位与~7之后,都会把低三位舍弃掉,这个数就会变成8的倍数了。 同理,后续如果我们需要对x16字节对齐,只需要 (x + 15) & ~15就行了。

除了(x + 7) & ~7外,8字节对齐还有另外一种写法:

(x + 7) >> 3 << 3
复制代码

也是同样的原理,将低三位的数字进行舍弃。

为什么以8为倍数对齐?

有些同学可能会有疑问,为什么以8为倍数对齐,而不是以16、以32对齐呢?又或者为什么不以7为倍数对齐呢?

这个问题其实可以拆分成两个小问题:

1、为什么要对齐?
计算机的IO操作是非常消耗资源的。如果计算机每次读取数据时,都要根据数据类型的大小进行读取,那么对IO操作无疑雪上加霜。如果我们我们将内存空间分割成一个个小格子,将不同大小的数据都放进这些小格子,那么计算机读取数据时,只需要以格子为单位进行读取就行了,而不需要判断具体数据类型的大小。字节对齐的作用可以理解为:将数据放进这些固定大小的格子里,虽然有些格子的空间可能有些浪费,但是这将大大提高读取的速度,这就是典型的以空间换时间

2、为什么是以8字节对齐,而不是以16字节对齐?
Arm64中,数据的类型有char(1字节),int(4字节),long long(8字节)等等,这些数据类型最大也不过是8字节。而最关键的点在于,使用最频繁的指针,大小是8个字节。以8字节对齐,足以囊括所有基本数据类型
因此,没必要以16或32为倍数,浪费额外的内存空间。

六、alloc init与new的区别

alloc init

从最开始的例子可以看到,我们对p1调用了两次init,但是对p1的内存地址没有任何影响。 init是不是啥也没做?

- (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,所以苹果没有在init里面做任何额外的工作。唯一的作用,可能就是給开发者重写,让开发者做额外的设置。

new

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

new方法的本质,等同于alloc init。所以,平时使用newalloc init,本质上没有有什么差别,但更推荐alloc init的形式,扩展性、定制性更好。

七、总结

本节主要讨论了:如何探索iOS的底层、alloc的主线流程、以及主线流程中出现的一些技术细节。除了我们已经讨论过的,还有大部分技术细节没有讲述到。探索allocinit,只是万里长征的第一步,但是既然选择了iOS这条路,就坚定的走下去吧 ~

道阻且长,行则将至,行而不辍,未来可期

分类:
iOS
标签:
分类:
iOS
标签:
收藏成功!
已添加到「」, 点击更改