Advancements in the Objective-C runtime

  • WWDC2020

发布于 2020-07-07

这是近年来为数不多的有关 OC 优化的 Session,在这场 session 中,介绍了三个对于 OC 底层的优化。

类数据结构的变化

类在磁盘上的表示是这样的,首先是类本身,包含了元类、超类、方法缓存,以及指向更多信息的 class_ro_t 这个数据结构的指针,这个数据结构包含了类的名称、大小、方法、协议等等在编译器就已经确定的信息,其中 ro 表示 read only 只读。

Advancements%20in%20the%20Objective%20C%20runtime%20e8222edc5bd14b08902b4df5b4abd446/Screen_Shot_2020-07-07_at_12.04.37_AM.png

在类被加载到内存时,其在内存中的结构也是这样子,但作为一门动态语言,在运行时类的结构会有所变化。在此之前我们先要了解一下什么是 Clean Memory 和 Dirty Memory :Clean Memory 就是被加载后不会发生变化的内存,比如 class_ro_t ,因为他是只读的;而 Dirty Memory 就对应的是在加载后会发生变化的内存,比如我们在运行时给类动态的添加方法等。很明显 Dirty Memory 比 Clean Memory 要昂贵得多,他们在整个进程运行中都要保留,而 Clean Memory 和保存在磁盘上的结构是一致的,随时可以再从磁盘上加载。所以优化方法是,尽可能的将结构作为 Clean Memory 进行保存。

我们先来看看当一个类被加载后,结构会发生哪些变化

Advancements%20in%20the%20Objective%20C%20runtime%20e8222edc5bd14b08902b4df5b4abd446/Screen_Shot_2020-07-07_at_12.19.55_AM.png

会被分配一个 class_rw_t 的结构,它是在运行时才被创建,它包含了从 class_ro_t 复制过来的一些信息,也包含了如类的方法、属性、协议等等。

通过实验发现,我们发现 class_rw_t 占用了约 30MB 的内存,但是仅有 10% 的类会有类方法的修改,所以我们可以将其分离,把动态的部分抽离出来,定义成 class_rw_ext_t ,于是结构变成了这样

Advancements%20in%20the%20Objective%20C%20runtime%20e8222edc5bd14b08902b4df5b4abd446/Screen_Shot_2020-07-07_at_12.26.51_AM.png

经过这样拆分,将 90% 的类优化成了以 Clean Memory 形式存储,节约了大约 14MB 的内存。

相对方法列表

每一个类都维护了一张方法表,当给这个类添加一个方法时,都会将其添加到这张表中,Runtime 会用这张表来处理消息发送。

Advancements%20in%20the%20Objective%20C%20runtime%20e8222edc5bd14b08902b4df5b4abd446/Screen_Shot_2020-07-07_at_12.33.20_AM.png

这张表中的每个方法都存储了三部分内容:方法名,方法类型(方法的参数和返回类型)以及指向方法实现的指针。在 64 位的系统中,每一个方法占用了 24 个字节来存储,每个部分分别为 8 字节,表示了其绝对地址。

在一个进程中的内存,从下到上分别是栈、堆和各种库,库的地址取决于动态连接库加载之后的位置,动态连接器需要修正真实的指针地址(绝对地址)。但实际上,每个库的方法的内存地址必然在这个库的内存地址范围之内,所以只需要知道每个库的方法在库内存地址中的相对位置和库内存地址,便能计算出该方法的绝对地址,因此我们不需要使用 64 位的地址空间,而只需要 32 位的相对偏移即可,于是每个方法的三部分都只占有了 4 个字节来存储,总共只需要 12 字节即可。

这样的有优点:

  1. 无论库被加载到内存的哪个位置,方法的偏移量总是相同,这样在加载后不需要修正指针地址,直接通过偏移量计算即可。
  2. 因为不需要做指针修正,可以保存到只读存储器中
  3. 占用内存减少一半

但是 Swizzle 呢?Swizzle 替换的是两个方法的函数指针指向,如果使用了相对偏移地址,那么原来的 Swizzle 就无法使用了。

我们通过全局映射表来解决这个问题,用来维护 Swizzle 方法对应的实现函数指针

Advancements%20in%20the%20Objective%20C%20runtime%20e8222edc5bd14b08902b4df5b4abd446/Screen_Shot_2020-07-07_at_12.54.15_AM.png

Tagged Pointer 的变化

什么是 Tagged Pointer,我们首先来观察一个对象指针

Advancements%20in%20the%20Objective%20C%20runtime%20e8222edc5bd14b08902b4df5b4abd446/Screen_Shot_2020-07-07_at_1.01.55_AM.png

虽然我们有 64 位,但是我们并没有用到它所有位;由于字节对齐的需要,低位始终为 0,而高位也因为地址空间的限制都为 0,所以我们实际上只用到了中间的一部分。

于是我们可以最低位设置为 1,表示它不是一个普通的对象,而是一个 Tagged Pointer。在Intel中,Tagged Pointer 表示如下:

Advancements%20in%20the%20Objective%20C%20runtime%20e8222edc5bd14b08902b4df5b4abd446/Screen_Shot_2020-07-07_at_1.04.39_AM.png

最低位后的三位表示 Tag,表示该 Tagged Pointer 的类型,剩余都是该对象的数据,但在 ARM64 中,Tagged Pointer 的表示有所变化:

Advancements%20in%20the%20Objective%20C%20runtime%20e8222edc5bd14b08902b4df5b4abd446/Screen_Shot_2020-07-07_at_1.19.06_AM.png

为什么 Intel 和 ARM64 的表示正好相反呢?实际上这是对 objc_msgSend 的一些优化,我们希望 msgSend 中最常用的路径尽可能快。最常用的路径表示普通对象指针。我们有两种不常见的情况:Tagged Pointer 指针和 nil。事实证明,当我们使用最高位时,可以通过一次比较来检查两者。与分别检查 nil 和 Tagged Pointer 指针相比,这会为 msgSend 中的节省了条件分支。

欢迎使用由 A2OS 开发的产品

SafeU|云U盘

隐私、安全、易用的文件分享服务