WWDC20 10163 - Objective-C Runtime 的改进

知识点问题梳理

这里罗列了一些问题用来考察你是否已经掌握了这篇文章,如果没有建议你加入 收藏 再次阅读。

  • Dirty Memory 与 Clean Memory 要如何区分?这次的优化是如何分离出 Dirty Memory 部分的?

  • class_rw_tclass_ro_t 分别存放了 class 的什么信息?为什么分离出部分 class_rw_t 就能起到优化作用?

  • Methods 中包含哪几个部分?对方法的绝对寻址和相对寻址有什么区别?为什么将方法查找改成相对寻址能够节省内存空间?

  • 将方法的寻址方式修改后,Method Swizzling 是否受影响?官方解决的方式是怎样的?

  • Tagged Pointer 是什么?为什么可以加快访问和操作速度?

  • 为什么在 Intel 和 ARM64 架构下要对 Tagged Pointer 区别对待?这么做的目的是什么?

概述

Objective-C 是一门古老的语言,诞生于 1984 年,跟随 Apple 一路浮沉,见证了乔布斯创建了 NeXT,也见证了乔布斯重回 Apple 重创辉煌,它用它特立独行的语法,堆砌了 UIKitAppKitFoundationKit 等一个个基石。

时间来到 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 MemoryClean Memory 要昂贵得多。因为它需要更多的内存信息,并且只要进程正在运行,就必须保留它。对于我们来说,越多的 Clean Memory 显然是更好的,因为它可以节约更多的内存。

我们可以通过分离出永不更改的数据部分,将大多数类数据保留为 Clean Memory

在介绍优化方法之前,我们先来看一下,在类加载之后,类的结构会变成如何呢?如下图:

在类加载到 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,修改之后的结构如下:

经过拆分,可以把 90% 的类优化为 Clean Memory,在系统层面节省了大约 14MB 的内存,使内存可用于更有效的用途。

那么如何验证效果呢?

$ head Mail | egrep 'class_rw|COUNT'

可以使用上述命令,来查看 class_rw_t 消耗的内存。在上述例子中,我们查看的是 Mail 应用的使用情况。

相对方法地址优化

在我们的认知中,每个类都包含一个方法列表,以便 Runtime 可以查找和消息发送。结构大概如下图所示:

方法包含了 3 部分的内容:

  • Selector:方法名称或选择器。选择器是字符串,但是它们是唯一的

  • 方法类型编码:方法类型编码标识(详情可以查看参考链接)

  • IMP:方法实现的函数指针

在 64 位系统中,它们占用了 24 字节的空间

了解了方法的结构之后,我们来看下进程中内存的简化视图

这是一个 64 位的地址空间,其中各种块分别表示了栈,堆以及各种库。我们把焦点放在 AppKit 库中的init方法。

如图所示,图中的 3 个地址分别为方法的 3 个部分的表示的绝对地址。

我们知道,库的地址取决于动态链接库加载之后的位置,这是因为ASLR(Address Space Layout Randomization)地址空间布局随机化的存在。

而动态链接器需要修正真实的指针地址,这也是一种代价。由于方法地址仍旧在当前二进制地址空间区域内,所以方法列表并不需要使用 64 位的寻址范围空间,它们仅需要根据自身地址以及偏移量就可以找到其他方法位置

因此我们可以使用 32 位相对偏移来代替绝对 64 位地址。优化之后,方法与内存地址的寻址表现如下:

这么做有几个优点:

  • 无论将库加载到内存中的任何位置,偏移量始终是相同的。因此加载后不需要进行修正指针地址;

  • 可以保存在只读存储器中,这会更加的安全;

  • 使用 32 位偏移量在 64 位平台上所需的内存量减少了一半。

相对方法地址会存在另外一个问题,在 Method Swizzling 如何处理呢?

众所皆知,Method Swizzling 替换的是 2 个函数指针的指向。函数可以在任意地方实现,但使用了上述的相对寻址优化之后,这样就无法正常工作了。

针对 Method Swizzling 官方使用全局映射表来解决这个问题,在映射表中维护 Swizzles 方法对应的实现函数指针地址。由于 Method Swizzling 的操作并不常见,所以这个表不会变得很大,新的 Method Swizzling 机制如下图。

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 填充,因此只用到了中间部分的位,出现了大量的内存浪费

由于以上痛点,按照 Tagged Pointer 的思路,可以将低位设置为 1 加以区分。

并且在最低位之后的 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 对象的表示如下

 OBJC_TAG_7 类型的 Tagged Pointer 是个例外,它可以将后 8 位作为扩展字段,基于此我们可以多支持 256 种类型的 Tagged Pointer,如 UIColors 或 NSIndexSets 之类的对象。

在 ARM64 中表现会不太一样:

最高位代表 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 上生效)。


你觉得这次的优化是否能大幅度的提升性能呢?欢迎一起讨论~

「一瓜技术」编辑的技术文章均有知识点问题梳理,也欢迎大家在下方评论区作答,与大家分享自己的思考结果。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值