知识点问题梳理
这里罗列了一些问题用来考察你是否已经掌握了这篇文章,如果没有建议你加入 收藏 再次阅读。
Dirty Memory 与 Clean Memory 要如何区分?这次的优化是如何分离出 Dirty Memory 部分的?
class_rw_t
和class_ro_t
分别存放了class
的什么信息?为什么分离出部分class_rw_t
就能起到优化作用?Methods
中包含哪几个部分?对方法的绝对寻址和相对寻址有什么区别?为什么将方法查找改成相对寻址能够节省内存空间?将方法的寻址方式修改后,
Method Swizzling
是否受影响?官方解决的方式是怎样的?Tagged Pointer 是什么?为什么可以加快访问和操作速度?
为什么在 Intel 和 ARM64 架构下要对 Tagged Pointer 区别对待?这么做的目的是什么?
概述
Objective-C 是一门古老的语言,诞生于 1984 年,跟随 Apple 一路浮沉,见证了乔布斯创建了 NeXT,也见证了乔布斯重回 Apple 重创辉煌,它用它特立独行的语法,堆砌了 UIKit
、AppKit
、FoundationKit
等一个个基石。
时间来到 2020 年,面对汹涌的 “后浪” Swift,“老前辈” Objective-C 也在发挥着自己的余热。今年,Apple 给 Objective-C Runtime 带来了新的优化,让我们看看具体有哪些。
类数据结构变化
首先我们先来了解一下二进制类在磁盘中的表示:
首先是类对象本身,包含最常访问的信息:指向元类,超类和方法缓存的指针。在类结构之中有指向包含更多数据的结构体 class_ro_t
的指针,拥有类的名称,方法,协议,实例变量等等编译期确定的信息。其中 ro
表示 Read Only 的意思。
当类被 Runtime 加载之后,类的结构会发生一些变化,在了解这些变化之前,我们需要知道 2 个概念:
Clean Memory:加载后不会发生更改的内存块,
class_ro_t
属于Clean Memory
,因为它是只读的。Dirty Memory:运行时会进行更改的内存块,类一旦被加载,就会变成
Dirty Memory
,例如,我们可以在 Runtime 给类动态的添加方法。
这里要明确,Dirty Memory 比 Clean Memory 要昂贵得多。因为它需要更多的内存信息,并且只要进程正在运行,就必须保留它。对于我们来说,越多的 Clean Memory 显然是更好的,因为它可以节约更多的内存。
我们可以通过分离出永不更改的数据部分,将大多数类数据保留为 Clean Memory。
在介绍优化方法之前,我们先来看一下,在类加载之后,类的结构会变成如何呢?如下图:
![](https://i-blog.csdnimg.cn/blog_migrate/44df4c1ab9c745d05d2575bcf94f93aa.png)
在类加载到 Runtime 中后,才会分配用于读取/写入数据的结构体 class_rw_t
。
class_ro_t
是只读的,存放的是编译期间就确定的字段信息;而 class_rw_t
是在 Runtime 时才创建的,它会先将class_ro_t
的内容拷贝一份,再将类的分类的属性、方法、协议等信息添加进去。
这么设计的原因是因为 Objective-C 是动态语言,你可以在运行时更改它们方法,属性等,并且分类可以在不改变类设计的前提下,将新方法添加到类中。
事实证明,class_rw_t
会占用比 class_ro_t
占用更多的内存。在系统测量了大约 30MB 的内存占用中,发现大约 10% 的类实际会存在动态的更改行为。这些更改行为包括:动态添加方法、使用 Category 方法等。
因此,官方 将动态的部分提取出来,称之为 class_rw_ext_t
,修改之后的结构如下:
![](https://i-blog.csdnimg.cn/blog_migrate/00d0db6878a2d29a110b7c4ead663a77.png)
经过拆分,可以把 90% 的类优化为 Clean Memory
,在系统层面节省了大约 14MB 的内存,使内存可用于更有效的用途。
那么如何验证效果呢?
$ head Mail | egrep 'class_rw|COUNT'
可以使用上述命令,来查看 class_rw_t
消耗的内存。在上述例子中,我们查看的是 Mail 应用的使用情况。
相对方法地址优化
在我们的认知中,每个类都包含一个方法列表,以便 Runtime 可以查找和消息发送。结构大概如下图所示:
![](https://i-blog.csdnimg.cn/blog_migrate/5b90204be68042c4b50a94db3ca47e3e.png)
方法包含了 3 部分的内容:
Selector:方法名称或选择器。选择器是字符串,但是它们是唯一的
方法类型编码:方法类型编码标识(详情可以查看参考链接)
IMP:方法实现的函数指针
在 64 位系统中,它们占用了 24 字节的空间
![](https://i-blog.csdnimg.cn/blog_migrate/36fc4010e46a9325d32d9a269667b081.png)
了解了方法的结构之后,我们来看下进程中内存的简化视图
![](https://i-blog.csdnimg.cn/blog_migrate/05dce3bc3892b371f29859649b4ff926.png)
这是一个 64 位的地址空间,其中各种块分别表示了栈,堆以及各种库。我们把焦点放在 AppKit 库中的init
方法。
![](https://i-blog.csdnimg.cn/blog_migrate/d75fd095e2772a334de204b0f6b2548a.png)
如图所示,图中的 3 个地址分别为方法的 3 个部分的表示的绝对地址。
我们知道,库的地址取决于动态链接库加载之后的位置,这是因为ASLR(Address Space Layout Randomization)地址空间布局随机化的存在。
而动态链接器需要修正真实的指针地址,这也是一种代价。由于方法地址仍旧在当前二进制地址空间区域内,所以方法列表并不需要使用 64 位的寻址范围空间,它们仅需要根据自身地址以及偏移量就可以找到其他方法位置。
因此我们可以使用 32 位相对偏移来代替绝对 64 位地址。优化之后,方法与内存地址的寻址表现如下:
![](https://i-blog.csdnimg.cn/blog_migrate/e642a0747fa7963f1e9b836619c2639d.png)
这么做有几个优点:
无论将库加载到内存中的任何位置,偏移量始终是相同的。因此加载后不需要进行修正指针地址;
可以保存在只读存储器中,这会更加的安全;
使用 32 位偏移量在 64 位平台上所需的内存量减少了一半。
![](https://i-blog.csdnimg.cn/blog_migrate/a43489a58175ceb796c83ad0760c4945.png)
相对方法地址会存在另外一个问题,在 Method Swizzling 如何处理呢?
众所皆知,Method Swizzling 替换的是 2 个函数指针的指向。函数可以在任意地方实现,但使用了上述的相对寻址优化之后,这样就无法正常工作了。
针对 Method Swizzling 官方使用全局映射表来解决这个问题,在映射表中维护 Swizzles
方法对应的实现函数指针地址。由于 Method Swizzling 的操作并不常见,所以这个表不会变得很大,新的 Method Swizzling 机制如下图。
![](https://i-blog.csdnimg.cn/blog_migrate/7abf436b162714af3fcf29e7e5b506f1.png)
Tagged Pointer 格式的变化
首先,让我们先来了解下 Tagged Pointer 是什么?
Tagged Pointer 是一种特殊标记的对象,通过在其最后一个 bit 位设置为特殊标记位,并将数据直接保存在指针自身中。
Tagged Pointer 是一个“伪”对象,但是在 iOS 开发中带来了 3 倍的访问速度提升、100 倍的创建、销毁速度提升的收益。
这里推荐观看「WWDC2013 - Advances in Objective-C」,会有对 Tagged Pointer 较为详细的讲解。
在 64 位系统中查看对象指针时,我们会看到 16 进制的地址表示,例如 0x00000001003041e0
将其转换为二进制表示如下:
在 64 位系统中,我们有 64 位空间可以表示一个对象指针。由于内存对齐,通常没有真正使用到所有这些位。对象必须位于指针大小倍数的地址中,低位和高位均被 0 填充,因此只用到了中间部分的位,出现了大量的内存浪费。
![](https://i-blog.csdnimg.cn/blog_migrate/c57374680283186a90196817dabdd9cd.png)
由于以上痛点,按照 Tagged Pointer 的思路,可以将低位设置为 1 加以区分。
![](https://i-blog.csdnimg.cn/blog_migrate/cabf787b64b78c83ed7d5a392c749788.png)
并且在最低位之后的 3 位,赋予其类型意义。3 位,可以表示 7 种数据类型
OBJC_TAG_NSAtom = 0,
OBJC_TAG_1 = 1,
OBJC_TAG_NSString = 2,
OBJC_TAG_NSNumber = 3,
OBJC_TAG_NSIndexPath = 4,
OBJC_TAG_NSManagedObjectID = 5,
OBJC_TAG_NSDate = 6,
OBJC_TAG_7 = 7
在剩余的字段中,记录所包含的数据。在 Intel 的 x86 架构中,我们 Tagged Pointer 对象的表示如下
![](https://i-blog.csdnimg.cn/blog_migrate/784e5846609a149411962a1e99f7a7d2.png)
OBJC_TAG_7
类型的 Tagged Pointer 是个例外,它可以将后 8 位作为扩展字段,基于此我们可以多支持 256 种类型的 Tagged Pointer,如 UIColors 或 NSIndexSets 之类的对象。
在 ARM64 中表现会不太一样:
![](https://i-blog.csdnimg.cn/blog_migrate/a4d98b1db75097c57e453d073a338712.png)
最高位代表 Tagged Pointer 标识位,次 3 位标识 Tagged Pointer 的类型,接下去的位来表示包含的数据(可能包含扩展类型字段)。
那么在 ARM64 中,为什么要用最高位代表的 Tagged Pointer 标记,而不是像 Intel 一样使用低位标记?
它实际是对 objc_msgSend
的微小优化。我们希望 objc_msgSend
检索的时间尽可能快,这个时间开销是出现在 objc_msgSend
查找指针的一种 Corner Case 上,即对比 Tagged Pointer 指针和 nil
。当标记在最高位时,可以通过复杂度
的比较直接完成,无形之中节省了一次遍历的时间。
总结
在 2020 年中,Apple 针对 Objective-C 做了三项优化
类数据结构变化:节约了系统更多的内存(在最新的 Runtime 版本中体现,即 macOS 10.5.5 中已存在);
相对方法地址:节约了内存,并且提高了性能(Xcode developmentTarget > 14 时会自动进行处理);
Tagged Pointer 格式的变化;提高了
objc_msgSend
性能(iOS 14, MacOS Big Sur, iPadOS 14 上生效)。
你觉得这次的优化是否能大幅度的提升性能呢?欢迎一起讨论~
「一瓜技术」编辑的技术文章均有知识点问题梳理,也欢迎大家在下方评论区作答,与大家分享自己的思考结果。