美文网首页iOS
iOS - 关于 NSObject 的「本质」

iOS - 关于 NSObject 的「本质」

作者: valentizx | 来源:发表于2019-03-22 00:04 被阅读76次

    NSObject,再熟悉不过,它可以指向任何 Objective-C 对象,也就是说它是一切 Objective-C 类的基类,这和 Java 中的 Object 类很像。

    创建形式为 [[NSObject alloc] init],那么,通过这种形式创建的 NSObject 对象在内存中有多大?究其根源,就要知道一个 NSObject 类型的指针所指向的内存空间布局是怎样的便可得知 NSObject 对象的占用内存的大小。但前提是要首先弄懂,Objective-C 代码的本质。

    Objective-C 代码的本质

    我们所编写的 Objective-C 底层都是 C\C++ 的代码,然后编译器将 C\C++ 代码转换为汇编语言代码,然后转成汇编语言代码又转成只有 1 和 0 的机器码,最终运行在手机上。


    image.png

    这也可得出结论,Objective-C 的相面对象都是基于 C\C++ 的数据结构实现的。

    Objective-C 的代码也能通过命令转为 .cpp 格式的代码,命令如下:

    clang -rewrite-objc 目标文件.m -o 目标文件.cpp
    
    clang Xcode 内置的 LLVM 编译器的前端
    -rewrite-objc 表重写 Objective-C 代码
    -o 表输出

    运行后,目录下已多了一个 cpp 文件,打开这个文件查看内容会发现,即使内容很少的 Objetive-C 代码最终转成 C++ 代码也会高达数十万行,前面的 [[NSObject alloc] init] 最终也转成如下的形式:

    NSObject* obj = ((NSObject *(*)(id, SEL))(void *)objc_msgSend)((id)((NSObject *(*)(id, SEL))(void *)objc_msgSend)((id)objc_getClass("NSObject"), sel_registerName("alloc")), sel_registerName("init"));
    

    但假如要参考平台、架构来将 Objective-C 代码转成 C ++ 代码 可通过如下命令:

    xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc 目标文件.m -o 目标文件.cpp 
    
    xcrun Xcode 内置的 工具
    -sdk iphoneos 表示最终的代码支持 iPhone 的硬件
    -arch 表示不同的架构,如 arm64、armv7、i386

    不同的平台架构,最终得到的代码也是不一样的。

    运行后得到的 cpp 体积和代码量都减少了很多,这份代码便是能够运行在 iPhone 手机上的 arm64 架构的代码。

    NSObject 的本质数据结构

    在代码中,可发现一块有关 NSObject 的结构体,如下:

    struct NSObject_IMPL {
        Class isa;
    };
    

    NSObject_IMPL 由名字可猜想这可能是 NSObject Implementation 的意思。
    那么在查看 NSObject Definition 也能看到:

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

    简化后(去掉协议):

    @interface NSObject {
        Class isa;
    }
    

    对比 C++ 和 Objective-C 有关 NSObject 的两种定义也可再一次验证 Objective-C 类的底层通过结构体来实现。

    Class 的定义为 typedef struct objc_class *Class,是指向结构体的指针。

    既然是指针,在 64 位环境下占 8 个字节,32 位环境下占 4 个字节。那么可猜想,该结构体也可能只占 8 个字节,毕竟该结构体目前只有一个成员。

    借助 runtime 函数验证猜想

    在 runtime 中,通过 class_getInstanceSize(Class _Nullable __unsafe_unretained cls) 从字面意思上理解为可得到一个类的实例的大小。

    此时打印:

    NSLog(@"%zd", class_getInstanceSize([NSObject class]));
    

    得到结果:

    8
    

    此时似乎进一步验证了 8 个字节的猜想,但事实并非如此。

    借助 malloc_size 函数验证猜想

    通过 malloc_size(const void *ptr) 函数可获得指针指向的内存地址的大小。

    此时打印:

    NSObject* obj = [[NSObject alloc] init];
    NSLog(@"%zd", malloc_size((__bridge const void *)(obj)));
    

    (__bridge const void *) 指将 Objective-C 指针转成 C 的指针。

    打印结果:

    16
    

    借助 objc 源码

    关于 Objective-C 某些底层的实现苹果已经开源,开源网址objc4 文件夹就是 Objective-C 部分底层源码。

    image.png

    列表中可找到最新的源码下载。

    class_getInstanceSize

    项目搜索 class_getInstanceSize 可发现在实现中,调用了 alignedInstanceSize() 方法,关于该方法,注释是这么写的:

    Class's ivar size rounded up to a pointer-size boundary.
    大概就是说返回类的成员变量所占据的大小。

    image.png

    那么也就意味着,class_getInstanceSize 方法返回的是实力对象的成员变量的大小。

    image.png

    由上图可知,系统为一个 NSObject 对象分配了 16 个字节,但是真正使用的,即 isa 占用的仅有 8 个字节。

    alloc

    一个 NSObject 对象的实例化都是通过 alloc -> init 实现, 在源码中搜索 alloc 相关可找到 NSObject 的 id _objc_rootAllocWithZone(Class cls, malloc_zone_t *zone) 方法,接下来的调用顺序如下:

    image.png

    由图可知,最终给对象分配内存还是调用 C 语言的 alloc 方法。值得注意的是 instanceSize 方法,整个方法体是这样的:

    size_t instanceSize(size_t extraBytes) {
        size_t size = alignedInstanceSize() + extraBytes;
        if (size < 16) size = 16;
        return size;
    }
    

    一目了然,这个函数中,假如创建的对象「体积」一旦小于 16,则直接分配 16 个字节。也就是说,一个 NSObject 对象,至少有 16 个字节的空间。

    借助 Xcode

    NSObject* objc = [[NSObject alloc] init] 后打上断点,运行到断点后可看到其内存地址,如下:

    image.png

    内存地址为 NSObject: 0x10065d6a0

    然后 Debug -> Debug Workflow -> View Memory 可看到如下面板:

    image.png

    在最下方的 Address 输入 obj 的内存地址值,得到显示:

    image.png

    显示结果中,内存地址是以 16 进制显示,两个数表示一个字节(如 41、A1、B0...)

    一个十六进制数表示 4 个二进制位,两个十六进制数表示 8 个二进制位,8 位表示一个字节,所以如 B0 这样的数表示一个字节。

    上图中第一行即是 obj 的地址:


    image.png

    观察发现,刚好后八位都是 0 ,第二行开始的内存地址已然不是 obj 所属的空间了。41 A1 B0 A3 FF FF 1D 00 就是 isa 所占用的空间。

    在终端借助 memeory read 内存地址 (或者 x 内存地址) LLDB 指令可读取一段内存空间,如下:

    image.png

    从 41 - 00 都是 obj 所占用的内存空间。
    另一种读取内存的 LLDM 指令 x/数量格式字节大小 内存地址表示从该内存地址后的若干地址空间读取。

    数量 表示要打印多少内存地址
    格式 x 表示 16 进制,f 表浮点,d 表示 10 进制
    字节大小 b 表示 1 字节,h 表示 2 字节,w 表示 4 字节,g 表示 8 字节

    如键入 x/3xg 0x10065d6a0 表示从 0x10065d6a0 打印 3 个地址空间,格式为 16 进制,每段地址空间占 8 个字节,如下显示:

    image.png

    第一串为 obj 的内存地址,0x001dffffa3b0a141 位 isa 占用的空间。
    第二串起就不再是 obj 的空间。

    修改内存地址的 LLDB 指令为 memory write 内存地址

    结论

    NSObject 本质 C/C++ 数据结构为结构体,有一个 Class 类型的成员变量 isa,在 64 位的环境当中,系统为其对象分配了 16 个字节的空间,但是实际使用(isa 占用)的只有 8 个字节。

    延伸

    复杂对象的内存结构

    那么,更复杂的对象的内存分布是如何的?在此,本人用自己的名字创建了一个类,包括年龄(int 型)和身高(int 型)两个成员变量。

    @interface Valenti : NSObject
    {
        @public
        int _age;
        int _height;
    }
    @end
    

    转成 C++ 代码后可发现关于 Valenti 类其底层数据结构为下:

    struct Valenti_IMPL {
        struct NSObject_IMPL NSObject_IVARS;
        int _age;
        int _height;
    };
    

    由于 struct NSObject_IMPL NSObject_IVARSNSObject_IMPL 只有一个成员 Class isa 则可转化为:

    struct Valenti_IMPL {
        Class isa;
        int _age;
        int _height;
    };
    

    对其初始化并赋值:

    Valenti* v = [[Valenti alloc] init];
    v -> _age = 26;
    v -> _height = 183;
    

    其内存分配如下:


    image.png

    当我在代码中用重新定义的 Valenti_IMPL 结构体类型的指针指向 Valenti 类型对象并打印结构体中 _age 和 _height 两个成员的时候发现:

    struct Valenti_IMPL* v_struct = (__bridge struct Valenti_IMPL *)(v);
    NSLog(@"%d, %d", v_struct -> _age, v_struct->_height);
    

    打印结果为:

    26, 183
    

    可得出结论 v 所指向的对象本质为 Valenti_IMPL 结构。

    借助 class_getInstanceSizemalloc_size 函数打印大小:

     NSLog(@"%zd", class_getInstanceSize([Valenti class]));
     NSLog(@"%zd", malloc_size((__bridge struct Valenti_IMPL *)(v)));
    

    打印结果为:

    16
    16
    

    可知,v 这个对象在内存中所占用的空间为 16(isa + _age + _height),成员占用情况如下图:


    image.png

    若去掉成员 _height,此时理论上内存空间占用为 isa 8 个 + _age 4 个 = 12 个字节,但在 struct 中有内存对齐法则:结构体占用内存大小必须为最大成员的整数倍,所以在此借用两个函数打印大小结果还是 16(isa * 2)。

    那么,假如再加一个成员变量 int 型的体重打印结果又会如何??

    经过上面的分析,参考内存对齐,我相信答案一定脱口而出为 24(身高 4 + 年龄 4 + 体重 4 + isa 8 = 20,遵循内存对齐结果是 24)但实际打印结果:

    class_getInstanceSize() 的结果为 24
    malloc_size() 的结果为 32
    

    这又是为什么?此时还得回到 allocWithZone 的源码一探究竟。由于前面没有贴过源代码,在这里贴一下 _class_createInstanceFromZone 的源码,因为 allocWithZone() 最终会走到这里并调用 calloc():

    id
    _class_createInstanceFromZone(Class cls, size_t extraBytes, void *zone, 
                                  bool cxxConstruct = true, 
                                  size_t *outAllocatedSize = nil)
    {
        if (!cls) return nil;
    
        assert(cls->isRealized());
    
        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;
            obj->initIsa(cls);
        }
    
        if (cxxConstruct && hasCxxCtor) {
            obj = _objc_constructOrFree(obj, cls);
        }
        return obj;
    }
    

    其中需要值得注意的是 size_t size = cls->instanceSize(extraBytes)obj = (id)calloc(1, size) 两句,其中 extraBytes 是 0,从 _objc_rootAllocWithZone() 内部一路走到这里来可以发现,extraBytes 参数的值一直都是 0,而 instanceSize() 最终调用的是 alignedInstanceSize() 也就是和 class_getInstanceSize() 一样,那么 size 的值就是 24,但到了 obj 这里,obj 的 size 就变成了 32, 那么 calloc() 就很值得考究了,calloc() 为 C 标准库函数。

    开源网址中我们可找到有关内存的源码:

    image.png

    malloc.c 文件中找到 calloc() 函数,发现其内部调用的是 malloc_zone_calloc() ,其内部是现实:

    void *
    malloc_zone_calloc(malloc_zone_t *zone, size_t num_items, size_t size)
    {
        MALLOC_TRACE(TRACE_calloc | DBG_FUNC_START, (uintptr_t)zone, num_items, size, 0);
    
        void *ptr;
        if (malloc_check_start && (malloc_check_counter++ >= malloc_check_start)) {
            internal_check();
        }
    
        ptr = zone->calloc(zone, num_items, size);
        
        if (malloc_logger) {
            malloc_logger(MALLOC_LOG_TYPE_ALLOCATE | MALLOC_LOG_TYPE_HAS_ZONE | MALLOC_LOG_TYPE_CLEARED, (uintptr_t)zone,
                    (uintptr_t)(num_items * size), 0, (uintptr_t)ptr, 0);
        }
    
        MALLOC_TRACE(TRACE_calloc | DBG_FUNC_END, (uintptr_t)zone, num_items, size, (uintptr_t)ptr);
        return ptr;
    }
    

    这是系统的内存分配方式,也有一套内存对齐原则,这和前面的结构体 struct 的分配原则不同,但系统的内存分配原则也遵循着是谁的倍数的原则。内存中的堆空间可能是 16、32、48,最大是 256,源码中有最大空间的定义:

    #define NANO_MAX_SIZE   256 /* Buckets sized {16, 32, 48, ..., 256} */
    

    但在 iOS 系统中创建一个 Objective-C 对象,系统分配内存都是 16 的倍数,所以当 obj 的 size 为 24 的时候,系统会为其分配 32 的内存空间。

    我们看到 libmalloc 源码中有很多 xxx_malloc.c 文件,这些内存分配都有着不同的原则,malloc.c 中的原则是通用的原则。

    所以这里有个结论:

    • class_getInstanceSize() 返回的是一个对象至少需要多少内存空间,这和运算符 sizeof() 很像;
    • malloc_size() 返回得失系统为其分配多少内存空间。

    更复杂的内存结构

    首先声明 Person 类,只有一个 int 型年龄成员:

    @interface Person : NSObject
    {
        @public
        int _age;
        
    }
    @end
    

    再声明 Valenti 类继承自 Person,并只有一个 int 型身高成员:

    @interface Valenti : Person
    {
        @public
        int _height;
    }
    @end
    

    那么,一个 Valenti 实例占用多少内存空间?理论上为 Person 的 16 个字节 + _height 4个字节 = 20,再根据内存对齐法则可得占用空间为 16 的倍数 32,但当使用 class_getInstanceSizemalloc_size 函数打印大小结果为:

    16
    16
    

    因为在 Person 的 16 个字节中,有 4 个是空余的,所以 Valenti 类中的 int 型身高刚好可以放到这 4 个空间内,所以最终结果为 16。

    关于内存对齐

    关于内存对齐,objc 源码中也有体现,上面提到过的 alignedInstanceSize() 中也是调用并返回了 word_align(unalignedInstanceSize()) 的结果, word_align() 便已经「对齐」了内存。

    相关文章

      网友评论

        本文标题:iOS - 关于 NSObject 的「本质」

        本文链接:https://www.haomeiwen.com/subject/jwlkvqtx.html