OC对象的本质(上) —— OC对象的底层实现原理

OC对象的本质(上):OC对象的底层实现原理
OC对象的本质(中):OC对象的种类
OC对象的本质(下):详解isa&superclass指针

一个NSObject对象占用多少内存?

Objective-C的本质

平时我们编写的OC代码,底层实现都是C/C++代码

Objective-C --> C/C++ --> 汇编语言 --> 机器码

所以Objective-C的面向对象都是基于C/C++的数据结构实现的,所以我们可以将Objective-C代码转换成C/C++代码,来研究OC对象的本质。

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        NSObject *obj = [[NSObject alloc] init];
    }
    return 0;
}

我们在main函数里面定义一个简单对象,然后通过 clang -rewrite-objc main.m -o main.cpp命令,将main.m文件进行重写,即可转换出对应的C/C++代码。但是可以看到一个问题,就是转换出来的文件过长,将近10w行。


因为不同平台支持的代码不同(Windows/Mac/iOS),那么同样一句OC代码,经过编译,转成C/C++代码,以及最终的汇编码,是不一样的,汇编指令严重依赖平台环境。
我们当前关注iOS开发,所以,我们只需要生成iOS支持的C/C++代码。因此,可以使用如下命令

xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc <OC源文件> -o <输出的cpp文件>
-sdk:指定sdk
-arch:指定机器cpu架构(模拟器-i386、32bit、64bit-arm64 )
如果需要链接其他框架,使用-framework参数,比如-framework UIKit
一般我们手机都已经普及arm64,所以这里的架构参数用arm64,生成的cpp代码如下


接下来,我们查看一下main_arm64.cpp源文件,如果熟悉这个文件,你将会发现这么一个结构体

struct NSObject_IMPL {
    Class isa;
};

我们再来对比看一下NSObject头文件的定义

@interface NSObject <NSObject> {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wobjc-interface-ivars"
    Class isa  OBJC_ISA_AVAILABILITY;
#pragma clang diagnostic pop
}
@end

简化一下,就是

@interface NSObject  {
    Class isa ;
}
@end

是不是猜到点什么了?没错,struct NSObject_IMPL其实就是NSObject的底层结构,或者说底层实现。换个角度理解,可以说C/C++的结构体类型支撑了OC的面相对象。

点进Class的定义,我们可以看到 是typedef struct objc_class *Class;

Class isa; 等价于 struct objc_class *isa;

所以NSObject对象内部就是放了一个名叫isa的指针,指向了一个结构体 struct objc_class

总结一:一个OC对象在内存中是如何布局的?


猜想:NSObject对象的底层就是一个包含了一个指针的结构体,那么它的大小是不是就是8字节(64位下指针类型占8个字节)?
为了验证猜想,我们需要借助runtime提供的一些工具,导入runtime头文件,class_getInstanceSize ()方法可以计算一个类的实例对象所实际需要的的空间大小

#import <Foundation/Foundation.h>
#import <objc/runtime.h>
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        NSObject *obj = [[NSObject alloc] init];
        size_t size = class_getInstanceSize([NSObject class]);
        NSLog(@"NSObject对象的大小:%zd",size);
    }
    return 0;
}

结果是



完美验证,it's over,let's go home!







等等,就这么简单?确定吗?答案是否定的~~~
介绍另一个库#import <malloc/malloc.h>,其下有个方法 malloc_size(),该函数的参数是一个指针,可以计算所传入指针 所指向内存空间的大小。我们来用一下

#import <Foundation/Foundation.h>
#import <objc/runtime.h>
#import <malloc/malloc.h>

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        NSObject *obj = [[NSObject alloc] init];
        size_t size = class_getInstanceSize([NSObject class]);
        NSLog(@"NSObject实例对象的大小:%zd",size);
        size_t size2 = malloc_size((__bridge const void *)(obj));
        NSLog(@"对象obj所指向的的内存空间大小:%zd",size2);
    }
    return 0;
}

结果是16,如何解释呢?

想要真正弄清楚其中的缘由,就需要去苹果官方的开源代码里面去一探究竟了。苹果的开源代请看这里
先看一下class_getInstanceSize的实现。我们需要进到objc4/文件里面下载一份最新的源码,我当前最新的版本是objc4-750.1.tar.gz。下载解压之后,打开工程,就可以查看runtime的实现源码。
搜索class_getInstanceSize找到实现代码

size_t class_getInstanceSize(Class cls)
{
    if (!cls) return 0;
    return cls->alignedInstanceSize();
}

再点进alignedInstanceSize方法的实现

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

可以看到该方法的注释说明Class's ivar size rounded up to a pointer-size boundary.,意思就是获得类的成员变量的大小,其实也就是计算类所对应的底层结构体的大小,注意后面的这个rounded up to a pointer-size boundary指的是系统在为类的结构体分配内存时所进行的内存对齐,要以一个指针的长度作为对齐系数,64位系统指针长度(字长)是8个字节,那么返回的结果肯定是8的最小整数倍。为什么需要用指针长度作为对齐系数呢?因为类所对应的结构体,在头部的肯定是一个isa指针,所以指针肯定是该结构体中最大的基本数据类型,所以根据结构体的内存对齐规则,才做此设定。如果对这里有疑惑的话,请先复习一下有关内存对齐的知识,便一目了然了。
所以class_getInstanceSize方法,可以帮我们获取一个类的的实例对象所对应的结构体的实际大小。

我们再从alloc方法探究一下,alloc方法里面实际上是AllocWithZone方法,我们在objc源码工程里面搜索一下,可以在Object.mm文件里面找到一个_objc_rootAllocWithZone方法。

id _objc_rootAllocWithZone(Class cls, malloc_zone_t *zone)
{
    id obj;

#if __OBJC2__
    // allocWithZone under __OBJC2__ ignores the zone parameter
    (void)zone;
    obj = class_createInstance(cls, 0);
#else
    if (!zone) {
        obj = class_createInstance(cls, 0);
    }
    else {
        obj = class_createInstanceFromZone(cls, 0, zone);
    }
#endif

    if (slowpath(!obj)) obj = callBadAllocHandler(cls);
    return obj;
}

再点进里面的关键方法class_createInstance的实现看一下

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

继续点进_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;
}

这个方法有点长,有时分析一个方法,不要过分拘泥细节,先针对我们寻找的问题,找到关键点,像这个比较长的方法,我们知道,它的主要功能就是创建一个实例,为其开辟内存空间,我们可以发现中间的这句代码obj = (id)calloc(1, size);,是在分配内存,这里的size是需要分配的内存的大小,那这句应该就是为对象开辟内存的核心代码,再看它里面的参数size,我们能在上两行代码中找到size_t size = cls->instanceSize(extraBytes);,于是我们继续点进instanceSize看看

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

翻译一下这句注//CF requires all objects be at least 16 bytes.我们就明白了,CF作出了硬性的规定:当创建一个实例对象的时候,为其分配的空间不能小于16个字节,为什么这么规定呢,我个人目前的理解是这可能就相当于一种开发规范,或者对于CF框架内部的一些实现提供的规范。
这个size_t instanceSize(size_t extraBytes)返回的字节数,其实就是为 为一个类创建实例对象所需要分配的内存空间。这里我们的NSObject类创建一个实例对象,就分配了16个字节。
我们在点进上面代码中的alignedInstanceSize方法

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

这不就是我们上面分析class_getInstanceSize方法里面看到的那个alignedInstanceSize嘛。

总结二:class_getInstanceSize&malloc_size的区别
  • class_getInstanceSize:获取一个objc类的实例的实际大小,这个大小可以理解为创建这个实例对象至少需要的空间(系统实际为这个对象分配的空间可能会比这个大,这是出于系统内存对齐的原因)。
  • malloc_size:得到一个指针所指向的内存空间的大小。我们的OC对象就是一个指针,利用这个函数,我们可以得到该对象所占用的内存大小,也就是系统为这个对象(指针)所指向对象所实际分配的内存大小。
    sizeof():获取一个类型或者变量所占用的存储空间,这是一个运算符。
  • [NSObject alloc]之后,系统为其分配了16个字节的内存,最终obj对象(也就是struct NSObject_IMPL结构体),实际使用了其中的8个字节内存,(也就是其内部的那个isa指针所用的8个字节,这里我们是在64位系统为前提下来说的)

关于运算符和函数的一些对比理解

  • 函数在编译完之后,是可以在程序运行阶段被调用的,有调用行为的发生
  • 运算符则是在编译按一刻,直接被替换成运算后的结果常量,跟宏定义有些类似,不存在调用的行为,所以效率非常高

更为复杂的自定义类

我们开发中会自定义各种各样的类,基本上都是NSObject的子类。更为复杂的子类对象的内存布局又是如何的呢?我们新建一个NSObject的子类Student,并为其增加一些成员变量

@interface Student : NSObject
{
   @public
    int _age;
    int _no;
}

@end

@implementation Student

@end

使用我们之前介绍过的方法,查看一下这个类的底层实现代码

struct NSObject_IMPL {
    Class isa;
};

struct Student_IMPL {
    struct NSObject_IMPL NSObject_IVARS;
    int _age;
    int _no;  
    
};

我们发现其实Student的底层结构里,包含了它的成员变量,还有一个NSObject_IMPL结构体变量,也就是它的父类的结构体。根据我们上面的总结,NSObject_IMPL结构体需要的空间是8字节,但是系统给NSObject对象实际分配的内存是16字节,那么这里Student的底层结构体里面的成员变量NSObject_IMPL应该会得到多少的内存分配呢?我们验证一下。

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        NSObject *obj = [[NSObject alloc] init];
        //获取`NSObject`类的实例对象的成员变量所占用的大小
        size_t size = class_getInstanceSize([NSObject class]);
        NSLog(@"NSObject实例对象的大小:%zd",size);
        //获取obj所指向的内存空间的大小
        size_t size2 = malloc_size((__bridge const void *)(obj));
        NSLog(@"对象obj所指向的的内存空间大小:%zd",size2);
        
        Student * std = [[Student alloc]init];
        size_t size3 = class_getInstanceSize([Student class]);
        NSLog(@"Student实例对象的大小:%zd",size3);
        size_t size4 = malloc_size((__bridge const void *)(std));
        NSLog(@"对象std所指向的的内存空间大小:%zd",size4);
    }
    return 0;
}


从结果可以看出,Student类的底层结构体等同于

struct Student_IMPL {
    Class isa;
    int _age;
    int _no;      
};

总结一下就是,一个子类的底层结构体,相当于 其父类结构体里面的所有成员变量 + 该子类自身定义的成员变量 所组成的一个结构体。
出于严谨,我又给Student类多加了几个成员变量,验证我的猜想。

@interface Student : NSObject
{
   @public
    int _age;
    int _no;
    int _grade;
}
image.png

貌似是对的了,但是为什么用malloc_size得到std所被分配的内存是32?再来一发试试

@interface Student : NSObject
{

   @public
    //父类的isa还会占用8个字节
    int _age;//4字节
    int _no;//4字节
    int _grade;//4字节
    int *p1;//8字节
    int *p2;//8字节 
}

Student结构体所有成员变量所需要的总空间为 36字节,根据内存对齐原则,最后结构体所需要的空间应该是8的倍数,那应该就是40,我们看一下结果

从结果看没错,但是同时也发现了一个规律,随着std对象成员变量的增加,系统为Student对象std分配的内存空间总是以16的倍数增加(16~32~48......),我们之前分析源码好像没看到有做这个设定


其实上面这个方法只是可以用来计算一个结构体对象所实际需要的内存大小。 [update]其实instanceSize()-->alignedInstanceSize()只是可以用来计算一个结构体对象理论上(按照内存对其规则)所需要分配的内存大小。

真正给实例对象完成分配内存操作的是下面这个方法calloc()


这个方法位于苹果源码的libmalloc文件夹中。但是里面的代码再往下深究,介于我目前的知识储备以及专业出身(数学专业),还是困难比较大。好在从一些大神那里得到了指点。
刚才文章开始,我们讨论到了结构体的内存对齐,这是针对数据结构而言的。从系统层面来说,就以苹果系统而言,出于对内存管理和访问效率最优化的需要,会实现在内存中规划出很多块,这些块有大有小,但都是16的倍数,比如有的是32,有的是48,在libmalloc源码的nano_zone.h里面有这么一段代码

#define NANO_MAX_SIZE    256 /* Buckets sized {16, 32, 48, 64, 80, 96, 112, ...} */

NANO是源码库里面的其中一种内存分配方法,类似的还有frozenlegacymagazinepurgeable

这些是苹果基于各种场景优化需求而设定的对应的内存管理相关的库,暂时不用对其过分解读。
上面的NANO_MAX_SIZE解释中有个词Buckets sized,就是苹果事先规划好的内存块的大小要求,针对nano,内存块都被设定成16的倍数,并且最大值是256。举个例子,如果一个对象结构体需要46个字节,那么系统会找一块48字节的内存块分配给它用,如果另一个结构体需要58个字节,那么系统会找一块64字节的内存块分配给它用。
到这里,应该就可以基本上解释清楚,为什么刚才student结构需要40个字节的时候,被分配到的内存大小确实48个字节。至此,针对一个NSObject对象占用内存的问题,以及延伸出来的内存布局,以及其子类的占内存问题,应该就都可以得到解答了。


OC对象的本质(上):OC对象的底层实现
OC对象的本质(中):OC对象的分类
OC对象的本质(下):详解isa&superclass指针

面试题解答

  • 一个NSObject对象占用多少内存?
    1)系统分配了16字节给NSObject对象(通过malloc_size函数可以获得)
    2)NSObject对象内部只使用了8个字节的空间,用来存放isa指针变量(64位系统下,可以通过class_getInstanceSize函数获得)

推荐阅读更多精彩内容