阅读 4086

WWDC20 iOS14 Runtime优化

1. Class结构体变化

iOS 14之前,在磁盘中一个Class大概长这样:

这个类对象包含了最常用的信息:指向元类、父类、以及方法的缓存。它还有一个指针指向更多的额外信息class_ro_t,其中 ro表示read only 。这部分信息是只读的,其中包含了类名、方法、协议、实例变量和属性等信息。Swift类和Objective-C类均使用这个结构。

当类第一次从磁盘被加载到内存的时候,刚开始就是长这样的。但类一旦被使用,就会产生一些变化。

为了理解之后发生了什么,首先我们需要理解什么是 Clean MemoryDirty Memory

  • Clean Memory :被加载后就不会再变化的内存。例如,class_ro_t就是 Clean Memory ,因为它是只读的。
  • Dirty Memory :在进程运行时会发生变化的内存。类结构体一旦被使用就是 Dirty Memory ,因为运行时会写入新的数据,例如它的方法缓存部分。

Dirty MemoryClean Memory 代价更昂贵,因为在进程运行的整个过程中,都需要被保留; Clean Memory 则可以为其他事情滕出空间,因为当我们需要时,系统总是可以很容易地从磁盘中重新加载它。

macOS可以通过内存交换来解决内存不足的问题,但iOS不支持这个技术,所以 Dirty Memory 的代价会更昂贵。 Dirty Memory 就是为什么类结构被分为了这两个部分的原因。当然,如果我们可以拥有更多的 Clean Memory ,当然是更好的。把不会改变的数据分离出来,我们就可以让大部分的类数据保持为 Clean Memory

一旦类被使用,运行时会分配额外的空间来存储这部分数据,即class_rw_t,其中 rw表示read write 。这个结构体中,我们只存储运行时产生的数据。

  • First SubclassNext Sibling Class 指针让运行时可以遍历当前使用的所有类。
  • MethodsPropertiesProtocols ,这部分也是可以在运行时进行修改的。在实践中发现,其实只有大约10%类的方法会发生变化,所以这部分内存可以得到优化,滕出一些空间。
  • Demangled Name 只会被Swift类所使用,而且除非有需要获取它们的Objective-C名称,甚至都不会用到。

所以后两个不常用的部分,我们又可以拆分出来:

这样就把class_rw_t,拆成了2部分。如果确实有需要,我们才会这部分class_rw_ext_t结构分配内存。大约90%的类都不需要这部分额外的数据,系统就可以节约大概14MB的内存。

使用原结构大约需要30MB内存,拆分后可以节约大概14MB。

对macOS Big Sur的邮件App进行测试,发现大约有9千多个类使用了class_rw_t结构,而只有大约10%,即9百多个类使用到了class_rw_ext_t结构。

我们可以简单计算一下,class_rw_t结构大小减半,那么用1.0-(293120-43392)/293120≈14.8\%就是我们节约的内存。仅仅邮件就节约了大约15%的内存,通过这个优化,整个系统会减少大量 Dirty Memory

如果原来的代码直接访问class_rw_t结构,由于结构内存布局发生了变化,可能产生崩溃。苹果推荐使用运行时API,这样底层的细节会由他们处理。

2. 相关方法列表变化

每个类都有一个方法列表。当你写了一个方法,这个方法就会加入到方法列表中。运行时会用这些列表来解析发送给对象的消息。

每个方法包含3个部分的信息。

  • 名称,或者选择器,例如init
  • 方法参数类型的编码,例如@16@0:8
  • 方法的IMP,Objective-C方法最终会编译为一个C函数。

这些信息都是指针,在64位的系统上会占用24字节。

我们的方法列表是存在于镜像中的,而镜像的加载位置可能在内存的任何地方,这取决于动态链接器的选择。也就是说,链接器需要解析镜像中的指针,修复它们指向内存真实的的位置。这部分会产生额外的消耗。

又由于镜像中的方法都是固定的,不会跑到其他镜像中去。其实我们不需要64位寻址的指针,只需要32位即可。

这样做有几个好处:

  • 这个偏移量相对镜像是固定的,与镜像加载的位置无关,当它们从磁盘加载进来后就不要进行修复了。
  • 因为不再需要进行修复了,这部分数据就可以保存在只读内存(Clean Memory )中,这样也更安全。
  • 在64位系统中,指针大小从64位的24字节下降到32位的12字节。根据实际测量,方法列表占用内存大约为80MB,减半的话就可以节约40MB内存。

我们希望保持这部分数据是只读的,但如果我们使用了 Method Swizzling 呢?

苹果会在一个全局表中映射交换的实现。由于交换并不是非常常见的操作,所以这个全局表也不会特别大。

此外,在以前的实现中,进行方法交换会导致整个分页Page变成 Dirty Memory 。即仅仅一个交换,就可能造成数千字节的 Dirty Memory ,这是很不划算的。

如果我们的代码中直接处理了这些底层细节,但没有处理好的话,可能会造成1个64位的指针去读取2个32位的指针值。这是没有意义的,会造成崩溃。同样,苹果推荐使用运行时API,这样底层的细节会由他们处理。

3. 标记指针结构变化

首先,什么是标记指针 Tagged Pointer

这个指针中,其实只使用了中间高亮部分来表示一个真实的对象指针。

由于字节对齐的原因,低位总是0;由于我们不会真正用到所有64进行寻址,所以高位也有一部分总是0。

  • Intel处理器

    低位为0表示真实的指针,1表示标记指针。

    前面的3个比特是tag号,表示其类型。例如3表示NSNumber,6表示NSDate

    tag号为7时表示一种扩展的tag,会使用额外的8比特表示类型,但有意义的数据长度更短,例如UIColorNSIndexSet

    一般情况下,只有苹果可以添加标记指针的类型。 但如果你是Swift开发者,则可以创建自己的标记指针。如果你曾用过有类实例对象关联值的枚举,那就像是一个标记指针。

  • ARM64

    • iOS14以下系统

      ARM64中整个反过来了,首位为1表示标记指针,后面3位表示tag号。

      这个高低位的翻转主要是因为objc_msgSend的一个小优化。苹果需要尽可能快地处理objc_msgSend的指针,通常是普通指针,标记指针和nil更少见一些。使用一个比较就可以直接确定是标记真正或者是nil,更容易进入常见的逻辑中。

      #define likely(x) __builtin_expect(!!(x), 1)
      #define unlikely(x) __builtin_expect(!!(x), 0)
      复制代码

      同样,tag号为7时表示一种扩展的tag,会使用额外的8比特表示类型。

    • iOS14

      iOS 14中tag号被移动到了低位。对于现有的工具,例如动态链接器,对于指针的高8位,ARM的特性 Top Biyte Ignore 会直接被忽略。苹果把扩展部分放在了 Top Biyte Ignore 生效的部分。对于字节对齐的指针,低3位总是0,刚好放下3位的tag号。最终,带来的一个有趣的效果就是,一个标记指针的payload中就可以放下一个普通指针了。这就让一个标记指针可以指向一个常量,例如字符串或者其他可能占用 Dirty Memory 的数据结构。

      如果项目中有涉及到这部分的代码,再未来可能产生崩溃。同样,苹果推荐使用运行时API,这样底层的细节会由他们处理。

4. 总结

iOS14之后苹果为我们带来了3项运行时优化:

  • 更小的类数据结构。
  • 更小的方法列表。
  • 标记指针的变化。

苹果推荐使用运行时API,这样底层的细节会由他们处理。

5. 参考


如果觉得本文对你有所帮助,给我点个赞吧~