H5

OC对象底层探索(本质、创建流程、内存对齐及空间大小)

2020-12-16  本文已影响0人  iOS发呆君

目录

作为一个开发者,有一个学习的氛围跟一个交流圈子特别重要,这是一个我的iOS交流群:196800191,加群密码:112233,不管你是小白还是大牛欢迎入驻 ,分享BAT,阿里面试题、面试经验,讨论技术, 大家一起交流学习成长!

1. 概述

每个iOS开始人员对OC语言并不陌生,虽然现在苹果提倡swift开发,但是OC还是入门的必修课,平时开发的时候,我们通常就是调用各种API,很少探究其底层的原理,苹果是如何在底层进行封装的呢?作为入行几年的开发者,还是有必要一探究竟。
OC是面向对象的语言,在代码中最常见的就是创建一个对象了,那么对象是什么,底层的结构又是什么呢,是如何创建出来的呢?带着这些问题,我们来开始分析。

2. 对象是什么

对象在底层到底是个什么样子呢?
在项目中创建一个GYMPerson类,里面定义个name属性和一个成员变量hobby,如下:

@interface GYMPerson : NSObject{
    NSString *hobby;
}
@property (nonatomic, copy) NSString *name;
@end

然后通过命令行将main.m文件转成c++文件main.cpp.

clang -rewrite-objc main.m -o main.cpp

转换完成后打开main.cpp文件,此时找到了:

struct GYMPerson_IMPL {
    struct NSObject_IMPL NSObject_IVARS;
    NSString *hobby;
    NSString *_name;
};

这个就是GYMPerson在底层的形式,一个结构体,并且继承了父类的所有属性。
另外我们注意到hobby没有下划线,而name则有下划线,我们都知道成员变量在底层保持不变,不会生成一个带下划线的成员变量的,而name是一个属性,在底层是会生成一个带下划线的成员变量的,而且还会增加getter和setter方法,如下:

static NSString * _I_GYMPerson_name(GYMPerson * self, SEL _cmd) { return (*(NSString **)((char *)self + OBJC_IVAR_$_GYMPerson$_name)); }
static void _I_GYMPerson_setName_(GYMPerson * self, SEL _cmd, NSString *name) { objc_setProperty (self, _cmd, __OFFSETOFIVAR__(struct GYMPerson, _name), (id)name, 0, 1); }

所以对象的本质是什么?毫无疑问,结构体。

3. 对象创建流程

我们在代码中调用alloc方法创建对象的时候,通常会经历以下几个步骤,简易图如下:



当在代码中调用alloc的时候,例如[GYMPerson alloc],那么在底层,代码会先去哪里呢?
毫无疑问,当第一次创建GYMPerson对象的时候,是会来到下面这个方法的:

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

其实这个方法没什么,那么再来看看callAlloc这个方法

static ALWAYS_INLINE id
callAlloc(Class cls, bool checkNil, bool allocWithZone=false)
{
    if (slowpath(checkNil && !cls)) return nil;

#if __OBJC2__
    if (fastpath(!cls->ISA()->hasCustomAWZ())) {
        // No alloc/allocWithZone implementation. Go straight to the allocator.
        // fixme store hasCustomAWZ in the non-meta class and 
        // add it to canAllocFast's summary
        if (fastpath(cls->canAllocFast())) {
            // No ctors, raw isa, etc. Go straight to the metal.
            bool dtor = cls->hasCxxDtor();
            id obj = (id)calloc(1, cls->bits.fastInstanceSize());
            if (slowpath(!obj)) return callBadAllocHandler(cls);
            obj->initInstanceIsa(cls, dtor);
            return obj;
        }
        else {
            // Has ctor or raw isa or something. Use the slower path.
            id obj = class_createInstance(cls, 0);
            if (slowpath(!obj)) return callBadAllocHandler(cls);
            return obj;
        }
    }
#endif

    // No shortcuts available.
    if (allocWithZone) return [cls allocWithZone:nil];
    return [cls alloc];
}

在这个方法中有个OBJC2判断,现在底层的代码全都是objc2的了,宏定义的值为1.
随后遇到 fastpath(!cls->ISA()->hasCustomAWZ()) 判断,因为这个类是第一次创建对象,类还没有初始化(懒加载),因此无法判断该类是否实现了allocWithZone方法,因而判断也不成立,所以直接跳到下面allocWithZone的判断,但是callAlloc在调用的时候,传入的allocWithZone是false,因此直接走到return,调用 [cls alloc] 。

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

这一看,这也没什么啊,别着急,继续往下看

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

感觉被欺骗了,怎么又回来了?请注意,参数值不一样了,此时的allocWithZone是true了。
此时如果没有实现allocWithZone方法,那么 fastpath(!cls->ISA()->hasCustomAWZ()) 判断则成立,进入内部的 fastpath(cls->canAllocFast()) 判断,而这个判断永远是false,如下:

bool canAllocFast() {
     assert(!isFuture());
     return bits.canAllocFast();
}
bool canAllocFast() {
     return false;
}

因此代码会走到 id obj = class_createInstance(cls, 0) 创建对象。
那么如果用户实现了allocWithZone方法,第二次调用callAlloc传入的allocWithZone参数为true,此时 fastpath(!cls->ISA()->hasCustomAWZ()) 判断不成立,代码直接走到 [cls allocWithZone:nil] 方法中,然后调用allocWithZone方法,随后调用 _class_createInstanceFromZone 方法。

上面我们提到了一个创建对象的方法class_createInstance(cls, 0),那我们看看这个方法:

id class_createInstance(Class cls, size_t extraBytes)
{
    return _class_createInstanceFromZone(cls, extraBytes, nil);
}

真是万变不离其中啊,最终又回到了我们实现allocWithZone方法后,底层调用的统一方法 _class_createInstanceFromZone,下面我们来看一下这个方法。

static __attribute__((always_inline)) 
id
_class_createInstanceFromZone(Class cls, size_t extraBytes, void *zone, 
                              bool cxxConstruct = true, 
                              size_t *outAllocatedSize = nil)
{
    if (!cls) return nil;

    assert(cls->isRealized());

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

    size_t size = cls->instanceSize(extraBytes);
    if (outAllocatedSize) *outAllocatedSize = size;

    id obj;
    if (!zone  &&  fast) {
        obj = (id)calloc(1, size);
        if (!obj) return nil;
        obj->initInstanceIsa(cls, hasCxxDtor);
    } 
    else {
        if (zone) {
            obj = (id)malloc_zone_calloc ((malloc_zone_t *)zone, 1, size);
        } else {
            obj = (id)calloc(1, size);
        }
        if (!obj) return nil;

        // Use raw pointer isa on the assumption that they might be 
        // doing something weird with the zone or RR.
        obj->initIsa(cls);
    }

    if (cxxConstruct && hasCxxCtor) {
        obj = _objc_constructOrFree(obj, cls);
    }

    return obj;
}

这个方法中zone为nil, fast经判断后是true,因此代码会走到下面的代码中:

if (!zone  &&  fast) {
        obj = (id)calloc(1, size);
        if (!obj) return nil;
        obj->initInstanceIsa(cls, hasCxxDtor);
    }

这个if分支中就做了两件事情,第一:在内存中给这个对象开辟空间,相当于在小区里面申请了一套房子;第二:初始化isa,绑定对应的类信息,相当于给这套房子弄个房本,里面有具体的信息。
至于calloc和isa以后会讲到,还有一个很重要的方法 cls->instanceSize(extraBytes) 马上就会讲到,继续往下看哦!

以上则是一个对象的初始化过程,现在我们将上面的简易图复杂化一下:


4. 对象空间大小及内存对齐

上面我们主要探究了对象创建的流程,现在我们说一下对象所需要空间的大小,以及字节对齐问题。
还记得刚才说过的方法吗?

size_t instanceSize(size_t extraBytes) {
        size_t size = alignedInstanceSize() + extraBytes;
        // CF requires all objects be at least 16 bytes.
        if (size < 16) size = 16;
        return size;
    }

这个方法主要计算对象所需要的内存空间的大小,在了解如何计算之前,我们先看一下内存对齐原则:

  1. 结构(struct)(或联合(union))的数据成员,第⼀个数据成员放在位置为0的地⽅,以后每个数据成员存储的起始位置要从该成员⼤⼩或者成员的⼦成员⼤⼩(只要该成员有⼦成员,⽐如说是数组,结构体等)的整数倍开始(⽐如int为4字节,则要从4的整数倍地址开始)存储。

  2. 如果⼀个结构⾥有某些结构体成员,则结构体成员要从其内部最⼤元素⼤⼩的整数倍地址开始存储.(struct a⾥存有struct b,b⾥有char,int ,double等元素,那b应该从8的整数倍开始储)。

  3. 结构体的总⼤⼩,必须是其内部最⼤成员的整数倍.不⾜的要补齐。
    听起来是不是有些乱呢,来,还是看代码理解吧,下面有四个结构体:

struct Struct1 {
    char a;
    int b;
    double c;
    short d;
} Struct1;

struct Struct2 {
    double ;
    int b;
    char c;
    short d;
} Struct2;

struct Struct3 {
    int a;
    double b;
    char c;
    short d;
    struct Struct2 e;
} Struct3;

struct Struct4 {
    int a;
    double b;
    char c;
    short d;
    struct Struct1 e;
} Struct4;

- (void)instanceSizeFunction {
    NSLog(@"Struct1 size = %lu", sizeof(Struct1));
    NSLog(@"Struct2 size = %lu", sizeof(Struct2));
    NSLog(@"Struct3 size = %lu", sizeof(Struct3));
    NSLog(@"Struct4 size = %lu", sizeof(Struct4));
}

我们调用instanceSizeFunction方法查看一下结果:

GYMDemo[41131:3568504] Struct1 size = 24
GYMDemo[41131:3568504] Struct2 size = 16
GYMDemo[41131:3568504] Struct3 size = 40
GYMDemo[41131:3568504] Struct4 size = 48

首先我们分析一下原则1,将其简化成一个公式:min(position, size),如果是存储第一个元素,那么直接放到0的位置,从第二个元素开始,采用这个公式,position是第二个及以后元素存储的最小开始位置,size则是元素的大小(比如int为4字节),公式的原理就是取position是size的最小整数倍的值作为存储某一元素的开始位置。
我们看Struct1结构体:

struct Struct1 {
    char a;  // 1 字节
    int b;   // 4 字节
    double c;// 8 字节
    short d; // 2 字节
} Struct1;

将a存入0位置的时候,只占用了1个字节,此时position指向下一个可存储的起始位置,也就是1,而下一个元素b是4字节,那么position就往后移动,当为4的时候(4为int(4字节)的整数倍),存入b,则b存在4 5 6 7四个位置,此时position为8,下一个元素c,8个字节,position正好是整数倍,于是开始存c,则c存在8 9 10 11 12 13 14 15八个位置,此时position为16,正好是元素d(2字节的整数倍),则d存在16 17两个位置,这么一算结构体Struct1一共占用了18个字符,但是别忘了原则3,结构体整体大小是其内部最大元素大小的整数倍,iOS64位下最大的数据类型占8字节,所以结构体总大小应是8的整数倍,那么比18大的最小整数倍即为24,所以Struct1的总大小为24字节。
如下入所示:



Struct2内存对齐如下:

struct Struct2 {
    double a;  // 8 字节
    int b;     // 4 字节
    char c;    // 1 字节
    short d;   // 2 字节
} Struct2;

Struct3内存对齐:

struct Struct3 {
    int a;      // 4 字节
    double b;   // 8 字节
    char c;     // 1 字节
    short d;    // 2 字节 
    struct Struct2 e; // 16 字节
} Struct3;

由结构体定义可知,Struct3中有个结构体成员e,这涉及到了内存对齐的第二个原则。
其内存对齐如下图:



至于Struct4,感兴趣的朋友可以自己算一算。

上面说完了内存对齐的原则以及结构体内存对齐,下面回过头看看创建对象时内存大小是如何计算的。在创建对象的过程中,最后在calloc方法之前,调用了 instanceSize(size_t extraBytes) 方法计算了对象申请的内存空间大小,见下面的方法:

/**
方法中则调用 **alignedInstanceSize()** 方法进行计算,另外请注意下面还有个if判断,如果计算出来的size<16,那么size就为16.
*/
size_t instanceSize(size_t extraBytes) {
    size_t size = alignedInstanceSize() + extraBytes;
    // CF requires all objects be at least 16 bytes.
    if (size < 16) size = 16;
    return size;
}
/**
方法中将未内存对齐的类的属性的总大小传入 **word_align()** 方法中进行对齐计算。
*/
uint32_t alignedInstanceSize() {
    return word_align(unalignedInstanceSize());
}
// 返回类的ivar中所有属性的总大小。
uint32_t unalignedInstanceSize() {
    assert(isRealized());
    return data()->ro->instanceSize;
}
// 计算内存对齐后对象需要的空间大小,详见下面讲解:
static inline uint32_t word_align(uint32_t x) {
    return (x + WORD_MASK) & ~WORD_MASK;
}

方法解析:

我们知道NSObject对象有一个属性,那就是isa,是一个指针类型,所占空间大小为8字节,如果创建一个NSObject,我们看看这个方法如何计算的。
首先看一下宏定义:
#define WORD_MASK 7UL
很显然在64位下,WORD_MASK为7.
当传入的x为8的时候,那么x + WORD_MASK为15,其二进制为:0000 1111
WORD_MASK的二进制为:0000 0111, 那么~WORD_MASK的二进制为:1111 1000
那么15 & ~7的计算为:
    0000 1111
&   1111 1000
=   0000 1000
0000 1000的十进制结果为8,那么当传入x值为8的时候,经过计算后得到的结果为8字节。

是不是感觉有些巧合,都是8,好,那么假设传入x=9,我们在计算一遍。

当传入的x为9的时候,那么x + WORD_MASK为16,其二进制为:0001 0000
那么16 & ~7的计算为:
    0001 0000
&   1111 1000
=   0001 0000
0001 0000的十进制结果为16,由此可知,类的属性总空间大小为9,经过对齐后需要的空间为16.

由上面的分析可以,对象申请内存空间的大小是8字节对齐计算的。

经过上面这一波计算,我们得到的内存对齐后的数值就是对象创建的时候,向内存申请的空间大小,那么计算机真的是按照这个数值开辟的空间吗?请看下面章节。

5. 系统开辟空间大小

计算机系统真的是按照对象申请的空间大小来开辟空间吗?
答案:不是
系统在calloc方法中,对于开辟多大的空间,有自己的算法。在探索calloc底层源码的时候,有一个很重要的方法,如下:

segregated_size_to_fit(nanozone_t *nanozone, size_t size, size_t *pKey)
{
    size_t k, slot_bytes;
    if (0 == size) {
        size = NANO_REGIME_QUANTA_SIZE; // Historical behavior
    }
    k = (size + NANO_REGIME_QUANTA_SIZE - 1) >> SHIFT_NANO_QUANTUM; // round up and shift for number of quanta
    slot_bytes = k << SHIFT_NANO_QUANTUM;                           // multiply by power of two quanta size
    *pKey = k - 1;                                                  // Zero-based!

    return slot_bytes;
}

还有两个关键的宏定义:

#define SHIFT_NANO_QUANTUM      4
#define NANO_REGIME_QUANTA_SIZE (1 << SHIFT_NANO_QUANTUM) // 16

方法解析:

假如方法中传入的size为24(对象经过内存对齐后申请空间的大小),我们看一下这行:
k = (size + NANO_REGIME_QUANTA_SIZE - 1) >> SHIFT_NANO_QUANTUM;
将宏替换掉后:
k = (24 + 16 - 1) >> 4;
即:k = 39 >> 4, 二进制表示为:0010 0111 >> 4 = 0000 0010 = 2
再k计算完后,又进行了:
slot_bytes = k << SHIFT_NANO_QUANTUM; 
即 slot_bytes = k << 4,二进制表示为:0000 0010 << 4 = 0010 0000 = 32
最终得到的slot_bytes为32,也就是系统为这个对象开辟的实际空间的大小。

由上可知,在callloc底层,系统将传入的size右移4位,再左移4位,也就是16字节对齐。

下面举个例子:
定义一个GYMDeveloper类,继承GYMPerson,GYMPerson

@interface GYMPerson : NSObject

@end
@interface GYMDeveloper : GYMPerson
// isa // 8字节
@property (nonatomic, copy) NSString *name; // 8字节
@property (nonatomic, assign) int age; //4字节
@property (nonatomic, assign) long height;  // 8字节
@property (nonatomic, copy) NSString *selfIntroduce; // 8字节
@end

对于GYMDeveloper,如果要创建一个GYMDeveloper的实例对象,很容易就会算出该对象所需要的空间大小,即40字节,不要忘了老祖宗NSObject还有isa指针,占8字节呢。
我们通过下面的方法测试一下:

- (void)instanceSizeFunction {
    GYMDeveloper *developer = [GYMDeveloper alloc];
    NSLog(@"对象申请的空间是:%lu字节, 系统开辟的空间是:%lu字节", class_getInstanceSize([developer class]), malloc_size((__bridge const void *)(developer)));
}

输出结果为:

GYMDemo[51386:4021321] 对象申请的空间是:40字节, 系统开辟的空间是:48字节

综上所述:对象申请空间的大小是8字节对齐计算的,而系统为对象开辟空间是16字节对齐计算的。

写在最后:写文章不容易,如果您觉得好就给个赞,如果文章有问题还请指正,转载的话,请标注原文地址哦!

原文作者:Daniel_Coder

原文地址:https://blog.csdn.net/guoyongming925/article/details/108859202

上一篇下一篇

猜你喜欢

热点阅读