OC 底层 对象的本质

1,037 阅读5分钟

前言

探寻OC对象的本质,我们平时编写的Objective-C代码,底层实现其实都是C\C++代码。

OC的对象都是通过基础C\C++的结构体实现的。

OC 转换为 C++

我们通过创建OC对象,并将OC文件转化为C++文件来探寻OC对象的本质,如下代码:

#import <Foundation/Foundation.h>
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        NSObject *objc = [[NSObject alloc] init];
    }
    return 0;
}

使用 clang 将 OC 代码转换为 C++ 代码

// 这种方式没有指定架构例如arm64架构 其中cpp代表(c plus plus)生成 main.cpp
clang -rewrite-objc main.m -o main.cpp 

使用 Xcode 工具 xcrun 进行转换

// 可以指定 arm64 架构 如果需要链接其他框架,使用-framework参数。比如-framework UIKit
// -o 输出的 cpp 文件
xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o main-arm64.cpp 

NSObject 对象的内存布局

NSObject 对象的 OC 定义如下:(我们可以在Xcode中点击进去查看)

@interface NSObject <NSObject> {
    Class isa  OBJC_ISA_AVAILABILITY;
}


NSObject 对应的结构体是 NSObject_IMPL结构体,NSObject_IMPL 定义如下:

struct NSObject_IMPL {
    Class isa;
};
typedef struct objc_class *Class;

NSObject_IMPL结构体只有一个成员 isa 指针,而指针在64位架构中占8个字节。也就是说一个NSObjec对象所占用的内存是8个字节。

NSObject *objc = [[NSObject alloc] init];

上述一段代码中系统为 NSObject 对象分配 8个字节的内存空间,用来存放一个成员 isa 指针。那么 isa 指针这个变量的地址就是结构体的地址,也就是 NSObjcet 对象的地址。

NSObject 只需要8字节的空间,但实际上,NSObject 对象占用 16 字节空间,iOS 下对象至少占用 16 字节

NSObject 子类对象的内存布局

代码如下:

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

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        Student *stu = [[Student alloc] init];
        stu -> _no = 4;
        stu -> _age = 5;
        
        NSLog(@"%@",stu);
    }
    return 0;
}
@end

对应的 C++ 结构体 Student_IMPL 如下:

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

因此此结构体占用多少存储空间,对象就占用多少存储空间。因此结构体占用的存储空间为,isa指针8个字节空间,int类型 _no 占用4个字节空间,int类型 _age 占用4个字节空间,共16个字节空间。

image.png

sutdent对象的3个变量分别有自己的地址。而stu指向isa指针的地址。因此stu的地址为0x100400110,stu对象在内存中占用16个字节的空间。并且经过赋值,_no里面存储4 ,_age里面存储5。

验证对象的内存布局

struct Student_IMPL {
    Class isa;
    int _no;
    int _age;
};
@interface Student : NSObject
{
    @public
    int _no;
    int _age;
}
@end

@implementation Student
int main(int argc, const char * argv[]) {
    @autoreleasepool {
            // 强制转化
            Student *stu = [[Student alloc] init];
            stu -> _no = 4;
            stu -> _age = 5;
            struct Student_IMPL *stuImpl = (__bridge struct Student_IMPL *)stu;
            NSLog(@"_no = %d, _age = %d", stuImpl->_no, stuImpl->_age); // 打印出 _no = 4, _age = 5
    }
    return 0;
}
@end

上述代码将oc对象强转成Student_IMPL的结构体。也就是说把一个指向oc对象的指针,指向这种结构体。最终可以转化成功,所以对象在内存中的布局与结构体在内存中的布局相同。由此说明stu这个对象指向的内存确实是一个结构体。

更复杂的继承关系

///  Person 
@interface Person : NSObject
{
    int _age;
}
@end
/// Student
@interface Student : Person
{
    int _no;
}
@end
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        
        NSLog(@"%zd  %zd",
              class_getInstanceSize([Person class]),
              class_getInstanceSize([Student class])
              );
    }
    return 0;
}

我们发现只要是继承自NSObject的对象,那么底层结构体内一定有一个isa指针。那么他们所占的内存空间是多少呢?单纯的将指针和成员变量所占的内存相加即可吗?上述代码实际打印的内容是16 16,也就是说,person对象和student对象所占用的内存空间都为16个字节。 其实实际上person对象确实只使用了12个字节。但是因为内存对齐的原因。使person对象也占用16个字节。

编译器在给结构体开辟空间时,首先找到结构体中最宽的基本数据类型,然后寻找内存地址能是该基本数据类型的整倍的位置,作为结构体的首地址。将这个最宽的基本数据类型的大小作为对齐模数。
为结构体的一个成员开辟空间之前,编译器首先检查预开辟空间的首地址相对于结构体首地址的偏移是否是本成员的整数倍,若是,则存放本成员,反之,则在本成员和上一个成员之间填充一定的字节,以达到整数倍的要求,也就是将预开辟空间的首地址后移几个字节。

我们可以总结内存对齐为两个原则:

原则 1. 前面的地址必须是后面的地址正数倍,不是就补齐。
原则 2. 整个Struct的地址必须是最大字节的整数倍。
原则 3. OC 对象至少占 16 字节

通过上述内存对齐的原则我们来看,person对象的第一个地址要存放isa指针需要8个字节,第二个地址要存放_age成员变量需要4个字节,根据原则一,8是4的整数倍,符合原则一,不需要补齐。然后检查原则2,目前person对象共占据12个字节的内存,不是最大字节数8个字节的整数倍,所以需要补齐4个字节,因此person对象就占用16个字节空间。

而对于student对象,我们知道sutdent对象中,包含person对象的结构体实现,和一个int类型的_no成员变量,同样isa指针8个字节,_age成员变量4个字节,_no成员变量4个字节,刚好满足原则1和原则2,所以student对象占据的内存空间也是16个字节。

代码方式获取对象占用空间大小

创建一个实例对象,至少需要多少内存?
#import <objc/runtime.h> class_getInstanceSize([NSObject class]);
●
●
●
创建一个实例对象,实际上分配了多少内存?
#import <malloc/malloc.h> malloc_size((__bridge const void *)obj);

备注:sizeof()不是一个函数

由于一些系统内存分配机制,内存对齐等,实际分配的内存可能要比实际需要的内存大一些。