【详解】探究 Objective-C 对象的底层原理

本文基于对象的实现原理来深入剖析 OC 的底层相关原理。这里并不会简单的介绍纯理论知识,而是借助工具和编码实现相关业务逻辑并作论述。

内容简介

1、instance对象的内存探究
2、OC对象的分类及其底层数据结构
3、isa/superclass指针
4、OC对象相关总结

一、instance对象的内存探究

我们平时创建一个 OC 对象是这样的:NSObject *obj = [[NSObject alloc] init];但是我们这样编写一行代码之后,不妨思考下:它最终会生成什么样子的代码?obj 对象的内存分配是怎样的?对象底层的数据结构是如何的?带着这些疑问,我们可以一探究竟。

众所周知:OC 在编译器的作用下,最终会转成 C/C++ 代码,进而转成汇编代码,最后才会生成机器可以识别的二进制代码,如下图:

因此,作为 iOS 工程师理论上我们可以通过C/C++、汇编、机器语言来探究它的底层。但由于篇幅原因,本文会重点从 C/C++ 层面来一一论述。

要想看 obj 的底层实现,我们需要借助 clang(Xcode自带的编译器前端) 编译器进行编译。基于简单考量,我们可以建立一个命令行项目,然后 cd 到 main.m 的目录下,通过终端运行指令:

  • $: xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o main_arm64.cpp

具体操作可参考下图:

此时生成的 main_arm64.cpp 就是 main.m 在编译后的源码文件。通过查阅源码发现:NSObject 转成 C++ 代码长这样子的:

二者极其的相似,而 Class 又是这样定义的: typedef struct objc_class Class,说白了它就是一个结构体!因此说,一个 NSObject 类编译后是一个 C++ 的结构体,结构体的成员变量仅包含一个 isa。因此我们可以得出结论:一个 NSObject 对象在内存分配上相当于包含一个成员变量的结构体的内存分配。而该结构体在64bit 系统下只占用8个字节(当然32bit下占用4个字节),也就是说 obj 对象在内存分配上实际占用8个字节。

我们不妨继续深入探究来验证上述结论。通过 runtime 的 class_getInstanceSize(Class _Nullable cls) 我们可以获取 NSObject 的实例对象所占用内存的大小;通过 *extern size_t malloc_size(const void ptr) 可以获得 obj 指针所指向的那块内存的大小。但是,二者有什么区别吗?我们可以通过 Xcode 日志查看一下,如图:

打印结果不一样!为什么?带着这个好奇心我们不妨通过两个函数的具体实现来解释说明。

实际上,目前来说苹果的很多底层实现都是开源的了。我们可以在苹果开源上来下载源码阅读,本文内容中只需下载 objc4 即可。查阅源码后不难发现:class_getInstanceSize 的底层实现其实就是依次调用了:alignedInstanceSize()、word-align()。我们都知道 alloc 的内部实际是调用了allocWithZone:,而通过源码发现 allocWithZone 内部又是依次调用了:objc-rootAllocWithZone > class-createInstance > class-createInstanceFromZone > instanceSize > alignedInstanceSize > word-align。仔细看可以发现,最后两步的函数调用二者是一样的。但是关键一步在于:instanceSize()。如图:

原来苹果在 CF 框架内部硬性规定了所有的对象在内存上必须至少是占用16个字节。也就是说:alignedInstanceSize() 内存对齐后是8个字节,由于extraBytes等于0,因此 size < 16成立,所以最终的 size 返回的是16!

综上:也就解释了为什么打印了 8 和 16 不同的结果。实际上,我们可以这么理解class_getInstanceSize、mallocsize的区别:前者是获得NSObject实例对象的成员变量所占用的大小,后者是操作系统实际上给NSObject的实例对象 obj 分配的内存大小。打个比方:我今天去菜市场买肉,我本来只要半斤就够吃了,但是卖肉的老板必须卖给我一斤,因为老板卖肉的规则就是至少是每人每次卖一斤,且必须也是一斤的整数倍量。

假如现在我定义一个 Person 类,其内部包含一个成员变量:int age; 那么我们猜一下class_getInstanceSize、mallocsize分别会打印多少?答案是:16、16。没错的,因为这里涉及到一个结构体成员数据对齐的常识。即在结构体中,成员数据对齐需满足以下规则:

  • 结构体中的第一个成员的首地址也即是结构体变量的首地址。
  • 结构体中的每一个成员的首地址相对于结构体的首地址的偏移量(offset)是该成员数据类型大小的整数倍。
  • 结构体的总大小是对齐模数(对齐模数等于#pragma pack(n)所指定的n与结构体中最大数据类型的成员大小的最小值)的整数倍。

因此,在包含一个成员变量的 Person 类中,编译后生成的 C++ 结构体中本质上是有两个成员:isa、age,由于 isa 占用8个字节,age 类型为 int 占用4个字节,为了满足规则第三条:结构体的总大小必须是最大数据类型的成员大小的整数倍,就是 8 的整数倍为 16。

综上,一个 OC 对象在编译后会生成一个 C++ 结构体,结构体中包含了所有的成员变量和一个 isa,在内存分配上会按照一定的对齐规则进行管理。

二、OC对象的分类及其底层数据结构

上面讲述了 instance 对象本质的一些认识,接下来重点阐述 OC 三大对象的底层之间的相互关系。
从语言设计角度来划分,可将 OC 对象分为三大类:

  • 实例对象,
  • 类对象
  • 元类对象

1、如何获取三大对象的地址?

首先导入头文件#import <objc/runtime.h>并创建以下对象:

obj1、obj2为两个不同的实例对象
NSObject *obj1 = [[NSObject alloc] init];
NSObject *obj2 = [[NSObject alloc] init];

objClass1、objClass2、objClass3、objClass4为NSObject的类对象
Class objClass1 = [obj1 class];
Class objClass2 = [obj2 class];
Class objClass3 = object_getClass(obj1);
Class objClass4 = object_getClass(obj2);

objMetaClass1、objMetaClass2为NSObject的元类对象
Class objMetaClass1 = object_getClass(objClass2);
Class objMetaClass2 = object_getClass(objClass4);

通过日志打印,获得地址分别如下图所示:

通过阅读内存地址可知:
一个类可以创建多个不同的实例对象,但是仅可以创建一个类对象和一个元类对象!

2、对象的底层数据结构

今假设存在以下三种类JDMan、JDPerson、NSObject, 继承关系为:
JDMan继承自JDPerson,JDPerson继承自NSObject。
且JDPerson包含2个成员变量、1个属性、1个对象方法,1个类方法;JDMan同JDPerson
如图所示:

结合前边讨论,我们可将OC的类编译成C++代码,如下图所示:

不难发现:所有的实例对象的C++结构体中仅仅包含了成员变量(当然也存储这一个isa指针和一个superclass指针),也就是说实例对象仅仅存储各自的成员变量的值。那么他们的对象方法、类方法、甚至协议等相关信息存储在哪里呢?

我们不妨先来思考一个事情:对象可以创建多个,每个对象都有自己的成员变量和对应的值,但是方法大家调用的都是同一个,不管你是实例对象还是类对象都是调用的一个方法。因此OC在设计这门语言的时候,我们有必要将只需要存储一份的数据交给实例对象去管理吗?肯定不需要。

上述我们发现而类对象和元类对象正好在内存中只有一份。这恰巧在某种程度上佐证了一个事实:实例方法存储中类对象中,类方法存储在元类对象中。

当然如果进一步分析的话,类对象中都存储着以下信息数据:

isa指针
superclass指针
类的成员变量信息(ivar)
类的属性信息(@property)、
类的协议信息(@protocol)、
类的对象方法信息(instance method)、
......

而元类对象中存储的信息数据包括:

isa指针
superclass指针
类的类方法信息(class method)
......

上述只是我们主观的分析得出的结论。那么接下来我们就要去用事实证明这些结论的正确性。

我们已经知道,三大对象(实例对象、类对象、元类对象)本质上都是OC中的Class类型的结构体。要想证明我们上述的分析结果,那么就必须要彻底探究清楚Class的深层结构。在OC中,我们通过查看头文件的方式只能看到typedef struct objc_class *;这样的声明,继续阅读objc_class *的相关代码。如下图所示:

但是很遗憾,在OC2.0版本中很多都是过期的,如此、这并不是我们期望的。

那么有没有其他方式呢?答案是有的。

幸好苹果给我们开源了相关源码。我们可以到苹果开源上去下载相关源码objc4-723库,从objc4-723库中找到objc-runtime-new文件进而找到struct objc_class *的定义。如下图所示:

会发现实现的相关代码(上图中并没有展示全部的相关代码,只是截取了部分核心代码。如有兴趣可自行查阅原文)太多了,好复杂。不过这足以能够帮我们去证明一些事情了。

熟悉C++的同学应该都知道,只要是结构体的内部数据结构、格式是一致的,那么就可以进行结构体之间的类型转换(可以理解为OC中的对象类型强转,不过可能会导致被转换的结构体的某些数据的丢失)的。

基于这一点,我们不妨把官方的实现进行简化:只保留必要的我们期望的信息,如方法缓存表、协议缓存表、属性表、属性信息、协议信息、描述信息等等。如下所示:

# if __arm64__
#   define ISA_MASK        0x0000000ffffffff8ULL
# elif __x86_64__
#   define ISA_MASK        0x00007ffffffffff8ULL
# endif

#if __LP64__
typedef uint32_t mask_t;
#else
typedef uint16_t mask_t;
#endif
typedef uintptr_t cache_key_t;

struct bucket_t {
    cache_key_t _key;
    IMP _imp;
};

struct cache_t {
    bucket_t *_buckets;
    mask_t _mask;
    mask_t _occupied;
};

struct entsize_list_tt {
    uint32_t entsizeAndFlags;
    uint32_t count;
};

struct method_t {
    SEL name;
    const char *types;
    IMP imp;
};

struct method_list_t : entsize_list_tt {
    method_t first;
};

struct ivar_t {
    int32_t *offset;
    const char *name;
    const char *type;
    uint32_t alignment_raw;
    uint32_t size;
};

struct ivar_list_t : entsize_list_tt {
    ivar_t first;
};

struct property_t {
    const char *name;
    const char *attributes;
};

struct property_list_t : entsize_list_tt {
    property_t first;
};

struct chained_property_list {
    chained_property_list *next;
    uint32_t count;
    property_t list[0];
};

typedef uintptr_t protocol_ref_t;
struct protocol_list_t {
    uintptr_t count;
    protocol_ref_t list[0];
};

struct class_ro_t {
    uint32_t flags;
    uint32_t instanceStart;
    uint32_t instanceSize;             // instance对象占用的内存空间
#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;
};

struct class_rw_t {
    uint32_t flags;
    uint32_t version;
    const class_ro_t *ro;
    method_list_t *methods;             // 方法列表
    property_list_t *properties;        // 属性列表
    const protocol_list_t *protocols;   // 协议列表
    Class firstSubclass;
    Class nextSiblingClass;
    char *demangledName;
};

#define FAST_DATA_MASK          0x00007ffffffffff8UL
struct class_data_bits_t {
    uintptr_t bits;
public:
    class_rw_t *data() {
        return (class_rw_t *)(bits & FAST_DATA_MASK);
    }
};

/// OC对象
struct jd_objc_object {
    void *isa;
};

/// 类对象
struct jd_objc_class : jd_objc_object {
    Class superclass;
    cache_t cache;  
    class_data_bits_t bits;
public:
    class_rw_t *data() {
        return bits.data();
    }
    
    jd_objc_class *metaClass() {
        return (jd_objc_class *)((long long)isa & ISA_MASK);
    }
};

这样我们就可以通过断点调试的方式进行验证了。以类对象personClassData为例进行验证。因为struct jd_objc_class *中有isa(继承而来)、superclass、cache(方法缓存相关)、bits、data等信息,通过分析可得:通过data函数的调用可以获得类对象中主要的存储的信息数据。personClassData正是通过调用struct jd_objc_class *data()函数获取的struct class_rw_t *类型的返回值。如下图对struct class_rw_t *的分析所示:

从图中可知:personClass类对象中的data函数返回的personClassData(struct class_rw_t *类型)存储着很多和JDPerson相关的信息:如成员变量、协议信息、对象方法等等。这样就证明了我们前边的分析的结论了。

同理:我们可以对元类对象也用同样的方式进行分析。结果是一样的。

三、isa/superclass指针

我们已经知道,三大对象的结构体中都有一个isa指针、一个superclass指针。那么他们之间的关系是如何的呢?

1、isa指针

我们可以通过代码进行测试,如下图:

obj是一个实例对象,里边有一个isa指针,再用LLVM的相关指令:p/x可以打印出obj的isa的值0x001dffffa8f48141。objClass为Class类型的类对象,将其强制转换成我们简化后的结构体struct jd_objc_class *然后打印的其地址为0x001dffffa8f48140。地址并不相等。貌似实例对象的isa指针不是指向类对象。

然而事实并不是这样的。从iOS系统支持64bit以来,实例对象的isa指针需要进行一个与运算& ISA_MASK才行。也就是说用0x001dffffa8f48141 & ISA_MASK 得到的值才是isa真实的指向。那么对于ISA_MASK的定义苹果是这样设计的:

# if __arm64__
#   define ISA_MASK        0x0000000ffffffff8ULL
# elif __x86_64__
#   define ISA_MASK        0x00007ffffffffff8ULL
# endif

由于我们Mac(iOS架构是arm64)的架构是__x86_64__,因此也就是说:我们要想得到实例对象isa真正的指向我们需要进行运算:0x001dffffa8f48141 & 0x00007ffffffffff8。得到的结果是:0x001dffffa8f48140。正如上图所示的一样。因此实例对象的isa指针事实上是指向类对象的。

那么类对象的isa指针是指向哪里的呢?看下图代码所示:

jd_manClass是我们获得简化后的类对象,jd_manMetaClass是我们获得简化后的元类对象。图中代码同理:我们也验证了类对象的isa指针指向的是元类对象。

2、superclass

我们已经验证到:实例对象的isa指向类对象,类对象的isa指向元类对象。但是他们各自的superclass又是如何指向的呢?
其实,我们可以利用类似的断点调试方式对superclass进行类似的探究。
我们同样可利用JDMan、JDPerson、NSObjet来测试。代码如下:

// JDMan类对象结构
Class manClass = [JDMan class];
struct jd_objc_class *jd_manClass = (__bridge struct jd_objc_class *)manClass;
// JDPerson类对象结构
Class personClass = [JDPerson class];
struct jd_objc_class *jd_personClass = (__bridge struct jd_objc_class *)personClass;
// NSObject类对象结构
Class objClass = [NSObject class];
struct jd_objc_class *jd_objClass = (__bridge struct jd_objc_class *)objClass;

相关的日志打印如下:

(lldb) p/x jd_manClass -> superclass
(Class) $0 = 0x0000000100001460 JDPerson
(lldb) p/x jd_personClass 
(jd_objc_class *) $1 = 0x0000000100001460
(lldb) p/x jd_personClass -> superclass
(Class) $2 = 0x00007fffa8f48140 NSObject
(lldb) p/x jd_objClass 
(jd_objc_class *) $3 = 0x00007fffa8f48140
(lldb) 

很明显:子类类对象的superclass指向父类类对象,父类类对象的superclass指向基类(即NSObject类)

四、OC对象相关总结

说了这么多,现以下图总结:


1、在OC对象中可分为实例对象、类对象、元类对象

2、实例对象保存成员变量信息,类对象保存属性、对象方法、协议信息、成员变量描述信息,元类对象保存的是类方法等信息

3、对于一个NSObject对象,在内存分配时操作系统给其分类了16个字节(通过malloc_size获得),但是实际使用的是8个字节(64bit环境下通过class_getInstanceSize获得)

**4、类对象和元类对象本质上都是一个Class类型的结构体,Class的定义为:typedef struct objc_class Class; 实例对象的本质是objc_object类型的结构体。另外objc_class继承自objc_object,而typedef struct objc_class Class的具体实现可去苹果开源下载源码,可参考objc4-723库中objc-runtime-new.h文件中的相关源码实现

5、isa:子类的实例对象的isa指针指向子类的类对象,子类的类对象的isa指针指向子类的元类对象,子类的元类对象的isa指针指向基类的元类对象(即NSObject的元类对象)

6、superclass:子类的类对象的superclass指针指向父类的类对象,父类的类对象的superclass指针指向基类的类对象,基类的类对象的superclass指针指向nil(即没有父类指向nil);元类对象的superclass同理:子类的元类对象的superclass指向父类的元类对象,父类的元类对象的superclass指向基类的元类对象,基类的元类对象的superclass指向基类的类对象

写在最后:

本文对OC中三大对象(实例对象、类对象、元类对象)之间的联系和区别以及我们开发中常见的isa、superclass等指针进行了较为详尽的论述。同时我们一步一步的也窥探了OC实例对象在内存中的分配情况。本文涉及到的内容在一些常规开发中可能并不常见,但是也许能很好的帮助我们更深入的去理解OC的一些底层的实现原理。这样我们在开发中遇到一些莫名其妙的问题时也许本文就起作用了,同时也希望通过本文的阐述,能给读者一些启发,能帮助大家提高阅读源码的主动性,促进大家勇于去探究未知事情的本质。由于笔者水平有限,如有纰漏烦请大家积极斧正,欢迎评论区留言;如果您喜欢这篇文章并觉得对您有帮助,请别忘了STAR收藏一下哈。让我们共同学习,共同成长。谢谢大家的支持!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,425评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,058评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,186评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,848评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,249评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,554评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,830评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,536评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,239评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,505评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,004评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,346评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,999评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,060评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,821评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,574评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,480评论 2 267