C3816698e16319b2ab24ef1cb8037b2d
WWDC20 10163 - Advancements in the Objective-C Runtime

Session:https://developer.apple.com/wwdc20/10163

概述

Objective-C 是一门古老的语言,诞生于 1984 年,跟随 Apple 一路浮沉,见证了乔布斯创建了 NeXT,也见证了乔布斯重回 Apple 重创辉煌,它用它特立独行的语法,堆砌了 UIKit,AppKit, Foundation 等一个个基石,时间来到 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

Tips:class_ro_t是只读的,存放的是编译期间就确定的字段信息;而class_rw_t是在 runtime 时才创建的,它会先将class_ro_t的内容拷贝一份,再将类的分类的属性、方法、协议等信息添加进去,之所以要这么设计是因为 Objective-C 是动态语言,你可以在运行时更改它们方法,属性等,并且分类可以在不改变类设计的前提下,将新方法添加到类中。

事实证明,class_rw_t会占用比class_ro_t占用更多的内存,在 iPhone 中,我们在系统测量了大约 30MB 的这些class_rw_t结构。应该如何优化这些内存呢?通过测量实际设备上的使用情况,我们发现大约 10% 的类实际会存在动态的更改行为,如动态添加方法,使用 Category 方法等。因此,我们能可以把这部分动态的部分提取出来,我们称之为class_rw_ext_t,所以,结构会变成这个样子。

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

Tips:heap App名称 | egrep 'class_rw|COUNT' 你可以使用此命令来查看 class_rw_t 消耗的内存。如:heap Mail | egrep 'class_rw|COUNT'查看 Mail 应用的使用情况。

相对方法地址

现在,我们来看看 Runtime 的第二处的变化,方法地址的优化。
每个类都包含一个方法列表,以便 Runtime 可以查找和消息发送。结构大概如下图所示:

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

  • Selector:方法名称或选择器。选择器是字符串,但是它们是唯一的
  • 方法类型编码:方法类型编码标识(详情可以查看参考链接)
  • IMP:方法实现的函数指针

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

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

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

如图所示,图中的3个地址分别为方法的 3 个部分的表示的绝对地址,我们知道,库的地址取决于动态链接库加载之后的位置,ASLR(Address space layout randomization 地址空间布局随机化)的存在,动态链接器需要修正真实的指针地址,这也是一种代价。由于方法实现地址不会脱离当前库的地址范围的特性存在,所以实际上,方法列表并不需要使用 64 位的寻址范围空间。他们只需要能够在自己的库地址中查找引用函数地址即可,这些函数将始终在附近。所以我们可以使用 32 位相对偏移来代替绝对 64 位地址。

现在我们地址将变成这样

这么做有几个优点:

  1. 无论将库加载到内存中的任何位置,偏移量始终是相同的,因此从加载后不需要进行修正指针地址。
  2. 它们可以保存在只读存储器中,这会更加的安全。
  3. 使用 32 位偏移量在 64 位平台上所需的内存量减少了一半。在 iPhone 中我们可以节省约 40MB 的内存大小。

优化后,指针所需的内存占用量可以减少一半。

相对方法地址会引发另外一个问题,那就是在Method Swizzling如何处理呢?众所皆知,Method Swizzling替换的是 2 个方法函数指针指向,方法函数实现可以在任意地方实现,使用了相对偏移地址了之后,这样就无法工作了。
针对Method Swizzling我们使用全局映射表来解决这个问题,在映射表中维护Swizzles方法对应的实现函数指针地址。由于Method Swizzling的操作并不常见,所以这个表不会变得很大,新的Method Swizzling机制如下图。

Tagged Pointer 格式的变化

top Created with Sketch.