OC对象的本质

1,014 阅读13分钟

本次讲解的很多内容都涉及到objc的源码,有兴趣的可以去下载最新版本的objc4源码

1. OC对象的内存布局

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

我们平时开发中说用到了绝大多数的类都是以NSObject作为基类。我们进入NSObject.h文件可以看到NSObject类的定义如下:

@interface NSObject <NSObject> {
    Class isa ;
}

我们将OC代码转成c/c++代码后可以看到,NSObject类是通过结构体来实现的,如下所示:

struct NSObject_IMPL {
    Class isa;
};

// Class的定义
typedef struct object_class *Class;

从上面可以看出这个结构体和OC中NSObject类的定义是一致的。这个类中只包含一个Class类型的属性,而Class是一个指向object_class结构体的指针(结构体的地址就是结构体中第一个成员的地址),所以isa就是一个指针,占8个字节(64位机器上面)。那么,是不是意味着一个NSObject对象在内存中就占8个字节呢?我们通过代码测试一下:

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

// class_getInstanceSize()函数需要引入头文件#import <objc/runtime.h>    
NSLog(@"---%zd",class_getInstanceSize([obj class]));

// malloc_size()函数需要引入头文件#import <malloc/malloc.h>
NSLog(@"---%zd",malloc_size((__bridge const void *)(obj)));
    
    
// ***************打印结果***************
2020-01-03 11:48:21.302884+0800 AppTest[62149:5950838] ---8
2020-01-03 11:48:21.303065+0800 AppTest[62149:5950838] ---16
  • class_getInstanceSize()函数得到的结果和我们预期是一致的,这个函数是runtime的一个函数,它返回的是类的一个实例的大小。我们查看objc4源码可以看到这个函数返回的是类的成员变量所占内存的大小(是内存对齐后的大小,结构体内存对齐的规则是结构体总大小必须是结构体中最大成员所占内存大小的倍数),所以得到的结果是8.
  • malloc_size()函数返回的是系统实际分配的内存大小,是16个字节,但是实际使用的只有8个字节。

所以,一个NSObject对象所占用的内存是16个字节。为什么会分配16个字节呢?我们可以去objc4源码看看alloc方法(在NSObject.mm文件中)的调用流程:

alloc-->_objc_rootAlloc()-->callAlloc()-->class_createInstance()-->_class_createInstanceFromZone()

_class_createInstanceFromZone()函数中调用了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;
    }

可以看到给一个对象分配内存的大小最小为16(这是系统硬性规定)。另外要注意的是一个实例对象占用多少内存和类中是否有方法是没有关系的,因为类中的方法并不存放在实例对象中。

1.2 自定义对象占多少内存?

我们先定义一个继承自NSObjectStudent类,Student类声明了2个int类型属性:

@interface Student : NSObject

@property (nonatomic , assign) int age;
@property (nonatomic , assign) int height;

@end

我们将其转换为c/c++代码查看其底层结构:

struct Student_IMPL {
	struct NSObject_IMPL NSObject_IVARS;
	int age;
	int height;
};

struct NSObject_IMPL {
	Class isa;
};

Student_IMPL结构体的第一个成员就是其父类的结构体,从上面我们可以得到2个信息:子类的成员变量包含了父类的成员变量;父类成员变量放在子类成员变量的前面。所以Student类有3个成员变量:isa(8字节)、age(4字节)和height(4字节)。那一个Student的实例对象是不是占16个字节呢?下面我们测试一下:

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

// ***************打印结果***************
2020-01-03 17:36:47.773438+0800 CommandLine[62909:6064600] 16

如果此时我们在Student类中新增一个int类型的属性weight,那一个Student的实例对象是不是就占20个字节呢?测试发现结果并不是20,而是32。为什么会这样呢,这就涉及到了iOS的内存字节对齐,其结果就是系统给一个对象分配的内存大小都是16的倍数。所以系统给一个自定义类的实例对象分配内存时,先计算类的所有成员变量(包括父类以及整个继承链的成员变量)的大小size,如果size刚好是16的倍数,那分配的内存的大小就是size;如果size不是16的倍数,那就将size补齐到刚好是16的倍数为止,补齐后的结果就是实际分配的内存大小。(结构体内存对齐字节数是8,OC对象内存对齐字节数是16,有关iOS系统分配内存时的对齐规则可以查看libmalloc库中的malloc.c文件中的malloc_zone_calloc()函数)。

为什么要进行内存对齐呢?简单来说就是未对齐的数据会大大降低CPU的性能。因为CPU读取数据时不是一个字节一个字节进行读取的,而是每次读取一块数据,块的大小在不同的系统上是不一样的,可以是2、4、8、16个字节。比如说如果CPU一次读取16个字节,如果一个对象占用内存的大小不是16的倍数,那么CPU读取这个对象数据时就需要做一些额外的操作,影响CPU的性能。

2. OC对象的分类

前面提到的对象都是实例对象,OC中除了实例对象之外,还有另外两种对象:类对象元类对象

2.1 实例对象

实例对象就是通过类alloc出来的对象,比如Student *student = [[Student alloc] init];,这样就创建了Student类的一个实例对象,每次调用alloc都会产生新的实例对象。

一个实例对象在内存中存储的信息前面也提到了,它的内存结构是比较简单的,就只存了实例对象的所有成员变量的数据:

  • isa指针(isa指针也是成员变量,只是它比较特殊,所有OC对象都有isa指针,具体的后面会介绍)。
  • 其他成员变量

2.2 类对象

OC中每个类都有一个与之对应的类对象,而且有且只有一个类对象。与实例对象相比,类对象的内存结构要复杂很多(关于类的底层数据结构后面再做介绍),其在内存中存储的信息主要包括:

  • isa指针
  • superclass指针
  • 属性信息
  • 对象方法(-开头的方法)信息
  • 协议信息
  • 成员变量信息(这里存储的是成员变量名字、类型等信息,这个和实例对象中存储的成员变量数据不是一个概念)。
  • 其它一些信息。

获取类对象的方法有多种,不管哪一种方法获取到的类对象都是一样的。

    Student *stu = [[Student alloc] init];
    
    // 1. 调用实例对象的class方法来获取
    Class stuClass1 = [stu class];
    
    // 2. 调用类的class方法来获取
    Class stuClass2 = [Student class];
    
    // 3. 调用runtime的object_getClass(object1);
    Class stuClass3 = object_getClass(stu);

注意第3种方法不要写错了,runtime中还有另外一个很相似的函数:

// 上面用的是这个函数,传入一个,返回这个对象所属的类
Class object_getClass(id _Nullable obj);

// 这个方法是传入一个字符串,返回类名是这个字符串的类
Class objc_getClass(const char * _Nonnull name);

2.3 元类对象

从上面介绍我们可以看出,类对象是用来存储实例对象的信息的(比如实例方法、属性等信息),那类对象的信息(比如类的类方法信息)又是存在哪里呢?这就是我们要介绍的元类对象

每个类在内存中有且只有一个元类对象,元类对象和类对象的内存结构是一样的,只是具体存储的信息不同,用途也不同。元类对象存储的信息主要包括:

  • isa 指针
  • superclass 指针
  • 类方法(+开头的方法)信息
  • 其他一些信息

获取元类对象也是调用object_getClass()函数,只是传入参数是类对象。换句话说object_getClass()函数传入的是实例对象的话就返回类对象,传入的是类对象的话就返回元类对象。

// 获取元类对象
Class metaClass = object_getClass([Student class]);

// 判断某个对象是否是元类对象
BOOL isMetaClass = class_isMetaClass([Student class]);

这里要注意[[Student class] class]这种写法,[Student class]返回的是类对象,那类对象再调用class方法是不是就返回的是元类对象呢?答案是否定的,一个类对象调用class方法返回的就是它自身。

3. isa指针和superclass指针

从前面介绍可以看出,所有继承自NSObject的对象都有isa指针,所有类对象和元类对象都有superclass指针。那这两种指针到底有什么用呢?

我们首先来了解一下OC的方法调用原理,这属于runtime的知识,这里只是简单介绍一下,不做深入讲解。调用OC方法底层是通过c语言的发送消息机制来实现的,比如一个实例对象stu调用study方法[stu study],其底层就是给stu对象发送消息(objc_msgSend(stu, @selector(study)))。但是study方法的相关信息并不是存储在实例对象中,而是在类对象中,那实例对象如何查找到study方法呢?这里isa指针就起作用了,实例对象的isa指针就是指向实例所属的类对象的(严格来说,isa指针并不是一个普通的指针,它里面存储的信息除了类对象的地址外,还包括很多其他信息,这里不做深入讲解,我们简单理解为实例对象的isa就是指向类对象即可)。

实例对象通过isa指针找到了类对象,然后在类对象中查找study方法并执行。但是如果study方法是Student的父类实现的,那么在Student类中是找不到study方法的,此时就要根据superclass指针找到父类对象(superclass指针存储的就是父类的地址,这和isa指针是不一样的),如果父类也找不到那就继续沿着继承链进行查找。如果一直找到NSObject基类都没找到的话,就会抛出unrecognized selector异常(这里不考虑runtime的消息转发)。

对于类方法的调用也是一样的流程,只不过是从给实例对象发消息变成了给类对象发消息。类对象会根据自己的isa指针找到元类对象,然后在元类对象中查找类方法,查找不到也是根据元类的superclass指针沿着继承链查找。

isa指针和superclass指针的指向可以总结为下面一张图:

对象、类、元类

  • 实例对象的isa指针指向该对象的类,该对象的实例方法保存在这个类的继承链中;
  • 类对象的isa指针指向该类的元类,类方法保存在元类的继承链中;
  • 元类和普通类一样也有父类,也具备自己的继承关系链,一个元类的父类就是这个元类的类的父类的元类(有点绕);
  • 所有元类的isa指针都是指向元类的根类(包括元类的根类的isa指针也是指向它自己);
  • 注意根元类的super_class指针指向的是根类(NSObject)。

最后一点要格外注意,举个例子,如果一个以NSObject为基类的类MyClss,MyClass中声明了一个类方法+(void)myTest;,但是并没有实现这个类方法(整个继承链上都没有实现),如果我们调用[MyClass myTest]的话是会报unrecognized selector异常的。

但是,如果我们给NSObject添加一个分类,在分类中实现了一个实例方法-(void)myTest;,此时再调用[MyClass myTest]的话时能正常运行的,而且执行的就是分类中添加的实例方法-(void)myTest;。这个其实可以用上面那张图进行解释:首先MyClass类对象会根据其isa指针找到其元类对象,然后在元类对象和元类的继承链上进行查找,一直查找到根元类对象都没有找到一个名叫myTest的方法,然后跟元类又会沿着其superclass指针找到NSObject类对象,而NSObject类对象中刚好有个叫myTest的方法,所以就直接执行这个方法。

4. 类对象和元类对象的存储结构(objc_class)

前面已经提过,不管是类对象还是元类对象,它们在内存中的存储结构是一样的。相关信息在objc4源码中。下面我会列出一些关键信息,想要了解完整信息可以去查看源码。


首先我们来看下objc_class这个结构体(这是c++语法,结构体可以继承也可以在结构体里面定义函数),这个结构体我只列出了部分信息:

struct objc_class : objc_object {
    // Class ISA;
    Class superclass;
    cache_t cache;             // formerly cache pointer and vtable
    class_data_bits_t bits;    // class_rw_t * plus custom rr/alloc flags

    class_rw_t *data() { 
        return bits.data();
    }
}

可见objc_class继承自objc_object而objc_object结构体里面就只有isa这一个成员。我们再来看看objc_class里面的内容:

  • superclass指针
  • cache,方法缓存。是cache_t的结构体,这个结构体的定义可以去看源码。
  • bits是一个class_data_bits_t结构体,这个结构体详细信息可以去看源码,这里我们主要介绍它后面的那个函数bits.data(),看源码可知,这个函数的实现其实就是bits & FAST_DATA_MASK),这个操作就是取出bits的某些位得到的就是一个指向结构体的指针,也就是class_rw_t这个结构体。

下面我们来看看class_rw_t这个结构体(rw其实就是readwrite的意思,也就是表示类中可读可写的信息):

struct class_rw_t {
    uint32_t flags;
    uint32_t version;

    // 类中的只读信息(详细内容见后面介绍)
    const class_ro_t *ro;
    
    // 方法列表(如果是类对象这里就是实例方法列表,如果是元类对象这里就是类方法列表)
    method_array_t methods;
    
    // 属性列表
    property_array_t properties;
    
    // 协议列表
    protocol_array_t protocols;

    Class firstSubclass;
    Class nextSiblingClass;
    char *demangledName;
    }

下面我们再来看看class_ro_t这个结构体(ro其实就是readonly的意思,也就是表示类中只读的信息):

struct class_ro_t {
    uint32_t flags;
    uint32_t instanceStart;
    uint32_t instanceSize; // 实例对象占用内存的大小
#ifdef __LP64__
    uint32_t reserved;
#endif

    const uint8_t * ivarLayout;
    
    const char * name; // 类名
    method_list_t * baseMethodList; // 方法列表
    protocol_list_t * baseProtocols; // 协议列表
    const ivar_list_t * ivars; // 成员变量列表

    const uint8_t * weakIvarLayout;
    property_list_t *baseProperties; // 属性列表
    }

我们发现在class_rw_tclass_ro_t都有方法列表、属性列表和协议列表,比如在class_rw_t中的方法列表是methods,在class_ro_t中的方法列表是baseMethodList,那这两个有什么区别呢?class_ro_t的初始化是在编译的过程中完成的,对于一个类对象来说,编译完成后,class_ro_t中的baseMethodList存着实例方法列表,这部分内容是不可以修改的,当class_rw_t进行初始化时,会先将baseMethodList拷贝放入methods中,之后程序运行过程中动态添加的方法也是存放在methods中。对于属性列表和协议列表也是一样的。