本文将阐述OC中类在C/C++底层语言中的具体实现,对象的分类,isa指针和superclass指针,顺便也说明了类的load和initialize方法
- NSObject类在C/C++中的呈现方式
- NSObject对象内存分配大小
- class的底层结构
- 对象的分类及其isa和superclass指针
1 NSObject类在C/C++中的呈现方式
iOS的应用从编写完成到装入到设备中有这样的一个过程:OC语言经过编译,转成C/C语言的代码;C/C再经过编译,转成汇编语言;汇编语言再进行编译,转成设备识别的机器语言(只有01组成的)
OC—————>C/C++—————>汇编语言—————>机器语言
1.1 窥看NSObject类
通过xcode我们可以查看到NSObject的申明,是这样的
// oc语言@interface NSObject {Class isa;}@end// 另外可以查看到Class是什么东东typedef struct object_class *Class;// 是object_class指针,
里面有一个isa熟悉,再找到Class,我们会发现,isa是object_class指针,object_class将在后面进行介绍。
OC语言中是这样的,那在C/C++中是什么样的呢?
这时候我们可以通过命令行将OC语言的代码编译成C/C++的代码
xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc 源文件 -o 输出的cpp文件// 如果需要链接到其他框架,使用 **-framework**参数
经过编译后,我们打开编译后的文件,可以找到NSObject对应的C++实现
// c/c++struct NSObject_IMPL {Class isa;}
以上我们可以发现,NSObject在底层是个结构体,同样含有isa指针。
1.2 NSObject子类实现窥看
我们可以再自定义一个类,继承自NSObject,然后再编译成C++语言实现。这里我们定义一个Student类,然后添加两个成员变量,如下:
// oc@interface Student: NSObject {@publicint _age;int _no;}@end
这时候,我们再执行编译命令,将其编译成C++实现,打开文件我们可以找到对应的实现:
// c/c**struct Student_IMPL {struct NSObject_IMPL NSObject_IVAR;int _age;int _no;}
我们可以看出,C将OC的类用结构体实现,而对于继承自NSObject,这里包含了一个结构体成员关联到NSObject的C结构体NSObject_IMPL。
另外,我们也可以反向进行验证:
依旧定义一个Student类,然后添加两个成员变量。然后再定义一个对应的结构体。如下:
// oc@interface Student: NSObject {@publicint _age;int _no;}@end// 由于 struct NSObject_IMPL结构体中只有一个isa指针,因此可以简写成struct Student_IMPL {Class isa;int _age;int _no;}
这时候,我们再创建一个student对象,并给属性赋值;然后通过定义的结构体,创建一个实例桥接到刚刚创建的student对象,再通过这个结构体实例查看其内部的成员变量。如下:
Student *stu = [[Student alloc] init];stu->_age = 4;stu->_no = 5;// 可以将stu对象转换成结构体struct Student_IMPL stu_impl = (__ bridge struct Student_IMPL)stu;stu_impl->_age, stu_impl->_no;//4,5 这里获取的信息和stu获取的信息一样
通过以上,我们可以真正了解到类在C++底层的具体实现。关于isa指针和object_class结构体会在文章后面进行介绍。
1.3 引申面试题:一个NSObject对象,会给其分配的多大的内存地址
这里我们可以自己创建测试下:
#import <objc/runtime.h> // 需要在头部导入runtime库NSLog(@"%zu", class_getInstanceSize([NSObject class]));// 8
我们通过runtime的API可以得出,NSObject的对象内存大小是8个字节。
那是不是,NSObject的对象就是8个字节呢?
我们可以再通过malloc的API查看下:
#import <malloc/malloc.h> // 需要先导入库文件NSObject *obj = [[NSObject alloc] init];NSLog(@"%zu", malloc_size((__bridge const void *)obj));// 16
这时候我们又发现,打印出来的是16。
那么问题来了,runtime的API得到的是8,malloc的API得到的是16?
OK,这里做个说明。runtime的API获取实例对象在内存中的内存大小是实际真正使用的大小,而malloc的API获取的是系统给其分配的大小。也就是说,NSObject实例对象在内存中只用到了8个字节(isa指针的大小就是这么大的),但是内存给他分配了16个字节的大小。
为什么会这样分呢,这里又需要引入一个知识点。内存对齐。内存在给数据类型进行存储空间分配时,也是要对齐的,那么iOS系统中内部规定其必须是16的倍数(iOS系统中,会提前准备好内存块,有16、32、48、64…最大256,在分配时会从这里拿一块去给与)因此,NSObject被分配了16个字节大小的内存。
2 Class底层结构
前面在窥探NSObject底层结构的时候,我们有发现isa指针,它是object_class结构体的指针,那object_class结构体内部是什么样的呢?我们顺着Class->object_class可以找到其内部的具体信息。
struct objc_class {Class _Nonnull isa OBJC_ISA_AVAILABILITY;#if !__OBJC2__Class _Nullable super_class OBJC2_UNAVAILABLE;const char * _Nonnull name OBJC2_UNAVAILABLE;long version OBJC2_UNAVAILABLE;long info OBJC2_UNAVAILABLE;long instance_size OBJC2_UNAVAILABLE;struct objc_ivar_list * _Nullable ivars OBJC2_UNAVAILABLE;struct objc_method_list * _Nullable * _Nullable methodLists OBJC2_UNAVAILABLE;struct objc_cache * _Nonnull cache OBJC2_UNAVAILABLE;struct objc_protocol_list * _Nullable protocols OBJC2_UNAVAILABLE;#endif} OBJC2_UNAVAILABLE;
这里我们发现,类结构体中还有一个isa指针。其实这个isa指针指向的是类的类也就是元类。
Superclass,当然,指向的是其父类。同时也能看到属性列表、方法列表、协议列表以及缓存列表。
这是runtime库中直接给我们看到的objc_class,这里再给大家看看源码的内部信息(源码下载地址),源码中我们可以看到以下信息(这里挑出了部分重要信息)
struct objc_class : objc_object {Class superclass; // 父类cache_t cache; // 方法缓存class_data_bits_t bits; // 用于获取具体的类信息}// objc_objectstruct objc_object {private:isa_t isa;}
由于objc_object中有isa指针,那可以直接将objc_class简单简写成:
struct objc_class {isa_t isa;Class superclass; // 父类cache_t cache; // 方法缓存class_data_bits_t bits; // class_rw_t * plus custom rr/alloc flags - 用于获取具体的类信息}
接下来我们针对这几个信息一个个进行分析。
2.1 isa:isa_t
每一个实例对象或者类对象的底层都有isa指针,在arm64之前,isa只是一个普通的指针,指着它的类或者元类;在arm64之后,对isa进行了优化,变成了一个共用体(union)结构,并使用位域来存储更多的信息
arm64之后,isa是一个共用体了(isa_t),具体信息如下:
union isa_t {Class cls; // 存储着类的地址unitptr_t bits; // 利用位域存储更多的信息struct {unitptr_t nonpointer : 1; // 0代表普通指针,存储着class或meta-class,1代码优化过的,使用位域存储更多的信息unitptr_t has_assoc : 1; // 是否有设置关联对象,如果没有,释放时会更快unitptr_t has_cxx_dtor : 1; // 是否有c++的析构函数unitptr_t shiftcls : 3; // 存储着Class、Meta-Class对象的内存地址信息unitptr_t magic : 6; // 用于在调试时分辨对象是否未完成初始化unitptr_t weakly_referenced : 1; // 是否有被弱引用指向过,如果没有,释放时会更快unitptr_t deallocating : 1; // 对象是否正在释放unitptr_t has_sidetable_rc : 1; // 引用计数器是否过大无法存储在isa中,如果为1,那么引用计数会存储在一个叫SideTable的类的属性中unitpyr_t extra_rc : 1; // 里面存储的值是引用计数器减1}}
在上面的源码中,我对参数添加了注释,相信大家看了之后或多或少会有个清晰的认识。
2.2 superclass
是指向的其父类,具体内部的信息和当前我们分析的objc_class一样,这里不做过多阐述。
2.3 class_data_bits_t bits——class_rw_t
源码中,class_data_bits 结构体中并没有什么过多的信息介绍,但我们在objc_class结构体中bits后面能看到这样一段解释:class_rw_t * plus custom rr/alloc flags,以及以下源码:
// objc_class结构体中的class_rw_t *data() {return bits.data();}// class_data_bits_t结构体中class_rw_t* data() {return (class_rw_t *)(bits & FAST_DATA_MASK);}// data pointer#define FAST_DATA_MASK 0x00007ffffffffff8UL
可以看到objc_class中的bits经过位运算(&FAST_DATA_MASK)能得到class_rw_t结构体的指针。接着我们再查看class_rw_t结构体,这里面到底有什么。
// class_data_bits_t bits 经过位运算&FAST_DATA_MASK可获得以下数据struct class_rw_t {// Be warned that Symbolication knows the layout of this structure.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结构体指针,这时候我们再看看class_ro_t结构体信息:
// 类的原始信息struct class_ro_t {uint32_t flags;uint32_t instanceStart;uint32_t instanceSize; // instance对象占用多少存储空间#ifdef __LP64__uint32_t reserved;#endifconst 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;// 原始属性列表method_list_t *baseMethods() const {return baseMethodList;}};
这个结构体中我们又看到了方法列表、协议列表、属性列表等。
下面再展示写方法、协议、属性结构体:
// 方法结构体struct method_t {SEL name; // 方法名const char *types; // 编码(返回值、参数类型)MethodListIMP imp; // 方法地址struct SortBySELAddress :public std::binary_function<const method_t&,const method_t&, bool>{bool operator() (const method_t& lhs,const method_t& rhs){ return lhs.name < rhs.name; }};};// 属性结构体struct property_t {const char *name; //属性名const char *attributes;};// 成员结构体struct ivar_t {#if __x86_64__// *offset was originally 64-bit on some x86_64 platforms.// We read and write only 32 bits of it.// Some metadata provides all 64 bits. This is harmless for unsigned// little-endian values.// Some code uses all 64 bits. class_addIvar() over-allocates the// offset for their benefit.#endifint32_t *offset;const char *name;const char *type;// alignment is sometimes -1; use alignment() insteaduint32_t alignment_raw;uint32_t size;uint32_t alignment() const {if (alignment_raw == ~(uint32_t)0) return 1U << WORD_SHIFT;return 1 << alignment_raw;}};
看了这些源码后,最后给大家总结下信息(class_rw_t):
class_rw_t 这个结构体中存放的是这个类的所有成员列表、方法列表、协议列表,并且每个列表都是一个二维数组
class_ro_t 这个结构体中存放的就是这个类原有的成员列表、方法列表、协议列表
为什么类中会有原有列表和所有列表呢?
这是因为分类category的缘由,分类的信息也属于这个类的信息。那么我们就好理解了,class_ro_t中存储的是这个类直接关联的方法、成员、属性、协议信息;class_rw_t中的是包含了类原始的信息和分类信息的总和。这里既然提到了分类,那也顺便说下分类的具体实现。
category实现原理:首先,在编译阶段,会将category中的信息存放在一个叫category_t的结构体中;然后,在程序运行的时候,runtime会将category中的数据,合并到原始类信息中(类对象-对象方法、属性信息、协议信息,元类对象-类方法中)。这里说合并并不准确,应该说是插入到类的class_rw_t对应的数组中,并且是插入到原始类信息的前面,这样就有了在执行方法的时候,为什么是先执行分类的再执行原始类的情况了。另外,说道categery分类,就要提下他和类拓展的区别:
1)class extension在编译的时候就已经将数据合并在类信息中了,category先放在了category——t这样的数据结构中,运行时再合并到类信息中;
2)category添加的属性不会自动生成setter和getter方法的实现和成员变量,只会声明setget方法,需要通过runtime自己实现set和get方法;
3)class extension 主要是为了不将私有的属性暴露在头文件中,自己内部使用
2.4 cache_t cache:方法缓存
这里我们介绍objc_class结构体中最后一个重要的信息,cache: cache_t。
struct cache_t {struct bucket_t *_buckets; // 散列表mask_t _mask; // 散列表长度mask_t _occupied; // 已经缓存的方法数量}struct bucket_t {cache_key_t _key; // SEL作为key,在查找时,也就是拿SEL来进行查找IMP _imp; // 函数的内存地址}#if __LP64__typedef uint32_t mask_t; // x86_64 & arm64 asm are less efficient with 16-bits#elsetypedef uint16_t mask_t;#endif
这里截取了cache_t的部分信息,可以发现cache_t中有个结构体指针_buckets,在这里这个指针的意思不仅限于指针,它也是一个数组的首地址,更具体的说是一个散列表的首地址。因为可以看下这个指针类型bucket_t,结构体bucket_t里有个key和函数实现地址。
在这里来进行总结下,class的方法缓存是用散列表(后面会简单介绍下散列表)的形式进行缓存的,在调用方法(函数)的时候,将方法名进行哈希值运算得到一个index,通过这个index从散列表里找方法的缓存,如果找到了,直接拿到函数的地址进行调用;如果没找到就进行后面的操作(具体后面的操作可以看后面的方法调用—isa和superclass的作用),然后再将方法的实现地址_imp连同其key缓存到这个散列表中,下次就可以匹配到了。
散列表的简单补充:
散列表其实是一个数组,但是效率由于数组,这个在于哈希函数。数组在查找元素的时候,是重头到位进行遍历,知道找到这个元素或者数组遍历到结尾;而散列表是经过一个哈希函数得到一个index,这里的index其实就是数组的下标,可以通过这个key直接找到对应位置的元素,这就相对于数组遍历来说,效率高德原因。当然,这里只是介绍了散列表的皮毛而已,如果想要更清楚的了解散列表可以去查看数据结构中的散列表的介绍。后期如果有机会的话,也会在博客里介绍数据结构与算法。
散列表其实是拿空间换时间,一开始会申请一块内存地址,能够容纳一定数量的元素,保证可存储的空间是大于要存储的元素的。
散列表中的每个元素都有一个key和要查询的值value。
这里再罗列下,class中cache散列表的流程:
1)先开辟一定大小的存储空间给散列表,如一开始给开辟4个单元的大小,设定需要与之计算的mask(一般是散列表长度-1,因为列表重0开始计算,3);
2)存储方法时(这里以test()为例),用类似于**@selector(test)&mask计算方法,获取一个可以,也就是index下标,用这个index下标找到散列表中的位置,如果为空,就将test()方法包装成元素(key,vaule)放置在此位置中,将缓存数量加1;
3)如果根据index找到的位置不为空,取出对应位置的值,比较两个值的key是否相同,如果相同就不在进行缓存,如果不相同然后将index加1进行下标下移,直到找到空位子为止;
4)如果缓存的数量即将等于或大于散列表的长度,会将散列表的历史元素清除,然后再将散列值的大小扩大至两倍;
5)查找方法时,根据元素特定的值与散列表的mask值进行计算,得出index下标值;
6)根据下标值找到对应位置的元素,比较元素中的key值,如果key值相同,则取出里面的值。
3 load和initialize方法
在讨论了那么多的class底层结构后,在这里顺便说下类的两个初始化方法,load和initialize。
3.1 +load
调用时机:+load方法会在类、分类被加载到内存的时候调用。
调用次数:每个类、分类的+load在程序运行过程中只加载一次。
调用过程:区别于普通方法以消息发送机制(objec_senMessage)调用,它是直接拿到+load的内存地址(从load方法数组中获取)直接调用。当然,如果手动调用+load方法,就同普通方法调用一样以消息发送机制进行调用。
调用顺序:先调用类的+load,再调用分类的+load方法,区别于其他方法的调用顺序
3.2 initialieze
调用时机:在类第一次接收到消息的时候进行调用
调用过程:使用的消息发送机制objc_sendMessage()进行的
调用顺序:先调用父类的,再调用子类的。
3.3 load与initialize的区别
3.3.1 调用方式
1)load的调用方式是,找到load方法的内存地址, 直接进行调用
2)initialize的调用方式是,消息发送机制进行调用(objc_sendMessage)
3.3.2 调用时间
1)load是在类被加载到内存的时候进行调用
2)initialize是在类第一次接受到消息的时候进行调用
3.3.3 调用顺序
load的调用顺序是:
1 >先调用类的,在调用分类的load
1.1> 先编译的类,优先调用load
1.2> 调用子类的load前会先调用父类的load(底层在调用之前会递归遍历父类进行调用)
2> 再调用分类的load
2.1 分类之间,按照编译顺序进行调用,先编译的先调用(底层将load方法按照编译顺序存储在了有序数组中的)
initialize的调用顺序是:
1)先初始化父类的,再初始化子类的
2)如果子类没有实现初始化方法,通过superclass向上寻找调用父类的
4 对象的分类
OC是一门面向对象的语言,那在这文章的最后,我们讨论下OC对象的分类。
OC的对象分为三类,分别是 实例对象、类对象、元类对象
4.1 实例对象
通过类alloc出来的对象,每次调用alloc都会产生新的实例对象。
实例对象内存储的信息包括:
- isa指针
- 其他成员变量
4.2 类对象
每一个类对象在内存中都且只有一个class对象。
类对象在内存中的存储信息主要包括:
- isa指针
- superclass指针
- 类的属性信息(@property)
- 类的对象方法(instance method)
- 类的协议信息(protocol)
- 类的成员变量信息(ivar)
- ·······
4.3 元类对象
每一个类在内存中有且只有一个元类对象(meta-class)
元类对象在内存中的存储信息包括:
- isa指针
- superclass指针
- 类的类方法信息(class method)
- ······
以下贴出了怎样获取刚刚提到的每个分类对象
NSObject *bj = [[NSObject alloc] init]; // bj实例对象Class obClass1 = [bj class]; // 类对象Class obClass2 = [NSObject class];// 类对象Class obClass3 = object_getClass(bj);// 类对象 runtime APIClass metaClass1 = object_getClass(obClass3); // 元类对象,runtime APIClass obClass4 = [[NSObject class] class]; // 获取的还是类对象// 获取元类对象只有用runtime的api进行获取Class metaClass2 = object_getClass([NSObject class]);// 判断某个类对象是不是元类对象# import <objc/runtime.h>BOOL result = class_isMetaClass([NSObject class]);
4.4 对象的关系—isa和superclass的作用
以上提到的三类对象,他们的内在联系是通过一个叫isa的指针进行连接的。
- 实例对象的isa指针指向类(class)
- Class 的isa指针指向meta-class(元类)
之所以对象会有以上那层关系,那是因为它们在方法调用上,完全依赖于以上的关系逻辑。
对象方法调用时:
先通过对象的isa指针,找到其class,查看其class中是否有对应的对象方法,如果有就调用其对象方法,如果没有,通过类的superclass指针找到其父类并查看其是否有对应的对象方法,以此类推,直到找到基类,如果基类也没有,就会进入到方法调用的下一层级(这个逻辑会放在后期的博文中阐述)。
类方法调用时:
先通过类对象的isa指针,找到其元类对象meta-class,查看其class中是否有对应的类方法,如果有就实现,如果没有,就通过meta-class的superclass指针找到其父类并查看其是否有对应的类方法,以此类推,直到基类元类,如果基类元类也没有,再通过基类元类的suerclass指针找到基类,查看其对象方法中有没有同名的对象方法,如果有就调用对象方法;如果到这一步还没有,就会进入到方法调用的下一层级(这个逻辑会放在后期的博文中阐述)。
同样,在属性查找上,也是同样的一套逻辑。
tips
1) 类对象、元类对象才有superClass指针
2)isa指针指向的并不是类的地址,而是需要位运算下才能得到真正需要指向的地址,& ISA_MASK
2)superclass指向的就是其父类的地址
参考资源:
1 底层源码,[链接]
2 MJ底层课程
