从alloc看起

275 阅读4分钟

从头开始

从事了几年的iOS开发,我们写的最多的或者说最开始写的一句代码,大概就是[[XXX alloc] init]了吧,以前只知道这是创建了一个对象,开辟了一块空间,并且对其初始化,那么到底句代码有做了什么呢,今天我想去看看。

准备工作

为了知道其底层到底做了什么,需要准备一份objc的源码,这里由于笔者的版本是10.15,那么也就去最新的代码里看看,objc-781,这里将地址贴出来objc源码地址下载,那么准备工作做好了,迫不及待的想去看看了。

小例子

正式进入之前先来看一段代码

        KKSearch *p1 = [KKSearch alloc];
        KKSearch *p2 = [p1 init];
        KKSearch *p3 = [p2 init];
        NSLog(@"%@ - %p - %p",p1, p1, &p1);
        NSLog(@"%@ - %p - %p",p2, p2, &p2);
        NSLog(@"%@ - %p - %p",p3, p3, &p3);

来看看结果,可以看出p2p3除了指针地址与p1是不一样的,其实它们指向的都是同一块内存地址,也是同一个对象,那么这里有一个猜想,如果是这样的话,是不是init方法并不影响对象的内存地址,那么init究竟做了什么。 -w1056

init初探

从源码中可以看到init方法中什么也没做,这也就解释了上面的例子中为什么指向了同一块内存地址,那么重点显而易见的的转移到了alloc方法中,看来主要的工作都在这里了。

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

#alloc探索 现将alloc的整个流程图贴出来,让大家对整个过程先有个清晰的认识 alloc流程.004

接下来将从objc源码中一步步的跟进看看究竟发生了什么

  1. alloc进入
+ (id)alloc {
        return _objc_rootAlloc(self);
    }
  1. 进入_objc_rootAlloc
id _objc_rootAlloc(Class cls)
{
    return callAlloc(cls, false/*checkNil*/, true/*allocWithZone*/);
}
  1. 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));
}

这里有两个宏定义需要看一下,slowpathfastpath

#define fastpath(x) (__builtin_expect(bool(x), 1))
#define slowpath(x) (__builtin_expect(bool(x), 0))

__builtin_expect指令由gcc引入

这个指令的作用是允许程序员将最有可能执行的分支告诉编译器 写法为:__builtin_expect(EXP, N),意味着 EXP == N的概率很大 fastpath(x)等价于x为真的可能性很大 slowpath(x)等价于x为假的可能性很大 该指令带来的好处就是:编译器可以对代码进行优化,减少了由于指令跳转带来的性能损耗

cls->ISA()->hasCustomAWZ()这里是判断当前类是否有自定义实现的allocWithZone,这里未实现所以走入_objc_rootAllocWithZone方法 4. _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);
}
  1. 现在跳转至_class_createInstanceFromZone方法中,在这个方法可分为三个重要的步骤
  • `cls->instanceSize`计算内存大小
    
  • `calloc`根据计算结果开辟内存空间
    
  • `obj->initInstanceIsa`将类与isa指针相关联
    

cls->instanceSize

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方法

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

根据断点调试后发现走入else分支,这里涉及到aling16这个16字节对齐的方法

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

16字节对齐的方法清晰可见,将x与15相加的结果再与15取反后相与,这里可以注意到为什么16字节对齐会与15有关,15的二进制是0000 0000 0000 1111取反后得到 1111 1111 1111 0000,可以看到最后四位都是0,那么与任何数都能保持第四位均为0,这样地址上就做到了16字节对齐,那么可见如果是8字节对齐,关键数字则为7。 如果缓存中没有的话会进入alignedInstanceSize()这个方法,在这里同样能看到有字节对齐的方法

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

static inline uint32_t word_align(uint32_t x) {
    return (x + WORD_MASK) & ~WORD_MASK;
}

同样是要进行字节对齐,这里看到WORD_MASK是7,那么说明首先是进行8字节对齐的,但是这种结果如果不足16字节,将统一按照16字节处理。

内存对齐

这里既然谈到了内存对齐,那么有必要去探究一下究竟何为iOS的内存对齐。在iOS中获取内存大小的方式有三种

  1. sizeof
  2. class_getInstanceSize
  3. malloc_size
NSObject *obj = [[NSObject alloc] init];
//    objc对象类型占用的内存大小
NSLog(@"objc对象类型占用的内存大小:%lu",sizeof(objc));
//    成员变量占空间大小(实际使用空间)
NSLog(@"%zu", class_getInstanceSize(NSObject.class));
//    obj所指向的分配的空间大小(实际分配空间)
NSLog(@"%zu", malloc_size((__bridge const void *)(obj)));

最后结果是:8,8,16

总结

sizeof

sizeof计算的是类型占用内存的大小,其中可以计算基本数据类型、对象、指针。对于基本数据类型sizeof指的是数据类型占用的内存大小。那么对于实例对象obj来说,它指的则是objc_object结构体指针的大小,和isa指针没有任何关系。 下面来佐证这个观点,首先定义个结构体指针pst1

struct test_struct
{
    
}str1 = {},*pst1 = &str1;

-w341 可以看到sizeof的结果是8,说明了sizeof计算指针类型时,度量的就是结构体指针的大小,与其中的内容并无关系。 #####class_getInstanceSize 此方法是计算对象实际占用的内存空间大小,是根据类的属性来变化,其源码中是这样:

#   define WORD_MASK 7UL
 uint32_t alignedInstanceSize() const {
        return word_align(unalignedInstanceSize());
    }
static inline uint32_t word_align(uint32_t x) {
    return (x + WORD_MASK) & ~WORD_MASK;
}

可以简单理解为8字节对齐。 #####malloc_size 此方法是计算实际分配给对象的内存空间,那么为什么与class_getInstanceSize结果不同呢?可以参考上面分析到的,最终分配的内存是以16字节对齐的方法来计算的。

calloc

calloc方法仅仅是开辟了一块空间,此时没有任何信息,因为此时obj的地址并未与传入的类cls发生任何关联。 ##obj->initInstanceIsa 主要过程就是初始化一个isa指针,并将isa指针指向申请的内存地址,在将指针与cls类进行关联。

NSObject 的 alloc

上面分析了NSObject的子类的alloc流程,再重新尝试的时候发现NSObjectalloc并不像上面分析的那样,首先并没有走到_objc_rootAlloc方法,what happened,重新进入后开始分析,首先看看函数调用栈 -w299 那么简单了,找到objc_alloc函数并打上断点,发现的确是走到了这里 -w893 可以看到此时cls的确是NSObject,看到这里就想到Person这个类在我们看到走到alloc是真的是直接调起了alloc方法吗?一波操作直接回到之前的分析来看看 -w351 这里就很清楚的可以看到同样Person这个类第一步也是调起了objc_alloc方法,同时这也解释了为什么我们在之前的分析中发现callAlloc函数进入了两次,同样打印栈信息也可以得到 -w1032

那么现在是得到两个问题:

  1. Personalloc为什么走了两遍?
  2. NSObjectalloc为什么走到了objc_alloc方法?

Personalloc为什么走了两遍

对于这个问题,通过以上的调试基本可以确认的是在查找alloc的方法编号的过程中,系统在底层进行了一些操作,最后找到了objc_alloc,这是在llvm级别所做的优化 -w789 大意即是当指定的方法返回为true时将会把alloc方法指定为objc_alloc方法 同时也在llvm的代码中找到依据,详情可见EmitObjCAlloc函数 -w692

-w723

所以这个优化是在llvm级别来完成的。