WWDC2020 Class数据结构变化
WWDC2020 关于类的一些变化
Class Data structures Changes:
运行时的数据变化
Class in memory old
类对象本身存放了最常访问的信息,指向元类、超类和方法缓存的指针
它还有一个存放额外信息的class_ro_t
RO代表只读 存放了类名、方法、协议和实例变量的信息 Swift类和Objective-c共享这一基础结构。每个Swift也有这些数据结构。
clean memory
:指的是加载之后不会发生改变的内存。 class_ro_t
就属于clean memory 是只读的。
dirty memory
:指的是进程在运行时会发生改变的内存。类的结构一经使用会就变成dirty memory
,因为运行时会写入新的数据。
dirty memory
要比clean memory
昂贵的多,只要进程在运行,它就必须一只存在。clean memory
可以进行移除从而节省更多内存空间,因为如果需要clean memory
系统就可以从磁盘中重新加载。
ios不使用swap dirty memory
在iOS中代价很大,所以dirty memory
是类数据被分成两部分的原因。保持越多的clean memory
越好,通过分离出那些永不会改变的数据,可以吧大部分的数据存储位clean memory
但是运行时需要追踪每个类的更多信息,所以当一个类首次被使用运行时会为它分配额外的存储容量,这个存储容量是class_rw_t
用于读取-编写数据。这个数据结构存储了只有在运行时才会生成的新信息。比如:所有的类都会链接成一个树状结构,通过使用First Subclass
和Next Sibling Class
指针实现的,这允许运行时遍历当前使用的所有类,这在方法缓存无效时非常有用。
为什么方法和属性在只读数据中时(ro),rw中还要有方法和属性呢?
因为它们可以在运行时进行更改。当category被加载时,它可以向类中添加新的方法。而且开发人员也可以使用运行时API动态地添加它们。因为class_ro_t
是只读的,所以需要使用class_rw_t
来追踪这些信息。
有了ro还使用rw追中运行时的变化,这样会占用很多的内存。苹果开发人员在测试中发现,大约只有10%的类真正的改变了它们的方法。
上图中,Dmangled Name
是Swift使用的字段,这属于扩展字段,除非是需要使用它们的object-c名称时才需要,因此拆分掉那些平时不用的部分,这将减少了class_rw_t
一半的大小。对于确实需要使用扩展的类,可以统一分配到一个扩展中。
苹果给出了一个检查class_rw
使用的例子:
在Mac终端中使用$heap Mail | egrep class_rw|COUNT
检测了一下邮件app中大约9000多个这样的class_rw_t
类型,但是大约只有10%左右的类需要使用扩展信息。
苹果推荐坚持使用官方给出的API很重要,因为你不知道底层的数据结构发生了怎样的变化,但是总保证官方API的正常使用。
相反,那些自己直接访问底层数据结构的代码在更新后将产生崩溃。因为旧的访问方式不适用新的内存布局。同时也需要注意,可能有些第三方引入的代码也会产生这样的问题。
官方APIS例如:
class_getName
class_getSuperclass
class_copyMethodList
没事多看看官方文档。
Relative method lists:
相对方法列表
Method list - apple给我的
当你在类上编写新方法是它会被添加到列表中,运行时使用这些列表来解析消息发送。
每个方法都包含三个信息:
- 方法的名称 或者说选择器。选择器是字符串,但它们具有唯一性,所以它们可以使用指针相等来进行比较。
- 方法的类型编码。这是一个表示参数和返回类型的字符串。它是运行时introspection和消息forwarding所必须的东西
- 指向方法实现的指针,方法的实际代码。当你编写一个方法时,他会被编译成一个c函数,方法列表的entry会指向该函数。
由以上来看每个方法表条目占用8+8+8 = 24个字节。这属于clean memory
,它从磁盘中加载,存放在内存中。
这是要一个很大的地址空间,它需要64位来寻址。
这个地址空间它划分成了几个部分:栈、堆。
可执行文件和库或二进制图像,这些都加载到了进程中,用蓝色表示。
我们放大其中一个二进制图像来查看:
这个方法条目指的三个信息向其二进制文件中的位置。告诉我们二进制图像可以加载到内存中的任何地方,这取决于动态链接器决定吧它放在哪里。
这意味着 连接器需要将指针解析到图像中,并在加载时将其修正为 指向其在内存中的实际位置。
一个来自二进制文件的类方法条目永远只指向该二进制文件内的方法实现。
不可能使一个方法的元数据存在于一个二进制文件中,而实现它的代码在另外一个二进制文件中。
这意味着 方法列表条目实际上并不需要能够引用整个64位的地址空间。它只需要能够引用自己二进制中的函数,因为这些实现函数总是在附近。
因此 无需使用绝对的64位地址,可以使用二进制中的32位的相对偏移。(苹果说今年就是这么改的)
现在一个方法条目只需要 4+4+4 = 12个字节。
苹果说这么做有几个好处:
- 偏移量始终是相同的。不管image在哪里加载到内存中,从磁盘中加载后都不需要进行修正,所以放在只读内存中更安全。
- 32位的偏移量已经将64位平台所需的内存量减少了一般。
苹果爸爸说一台iPhone形同范围内测试约80MB的这些方法,因为它们的尺寸减半,节省了40MB的内存,这样你的app就有更多的内存从而可以让你的用户体验更好。
那么,Swizzling呢?
二进制中的方法列表现在不能引用完整的地址空间如果你swizzle一个方法,他就可以在任何地方实施。而且刚才说过,我们希望保持这些方法列表为只读,因而使用了一张全局列表。
这个全局列表将方法印射到他们被swizzle的实现上。
swizzling并不常见,绝大多数方法都没有被swizzle过所以这个表最终不会变的很大。
苹果说内存每次都是按页面来“弄脏”的,使用旧式的方法列表,swizzle一个方法,会“弄脏”它所在的整个页面,一次swizzle就会导致产生大量千字节的dirty memory
。
有了这个全局表 我们只需要为了一个额外的表的条目付出代价。
这个改变将会在一下系统开始生效
- macOS Big Sur
- iOS 14
- tvOS 14
- watchOS 7
苹果也说了 新旧两种方法列表今后可以同时兼容 前提是 你使用的是官方提供的APIs
如果是在不匹配的deployment targets上运行了自己构建的读取方法,则可能会出现运行时读取方法信息时的崩溃(旧版64位,新版32位加偏移量,不匹配就会发生读取错误).
Tagged pointer format changes:
arm64上 tagged pointer变化
什么是Tagged pointer?
首先我们看看一个普通的64位对象地址指针:
64位指针地址
指针地址一共64位 但是我们只在一个真正的对象指针中使用了中间这些位(途中黄色标记部分)
对象指针只占用中间部分
由于对齐要求的存在,低位始终未0,对象必须总是位域指针大小倍数的一个地址中
低位始终未0
由于地址空间有限,所以高位始终未0,实际上是用不到2^64。
地址高位始终未0正常对象指针地址的高位和低位始终未0。
如果这时候我们选择一个始终未0的位置,把它变为1。
那么这时候我们一看就知道这不是一个真正的对象指针。
然后也可以给其他位赋予一些其他意义 称作为 tagged pointer
例如我们可以在其他位中塞入一个数值 然后只要教NSNumber
如何读取这些位,并让运行时适当的处理tagged pointer,系统的其他部分就可以吧这些东西当做对象指针来处理,并且永远不知道其中的区别。
这样可以节省我们为每一种类似情况分配一个小数字对象的代价
顺便说一下,这些值实际上是通过与进程启动是初始化的随机值相结合而被混淆的。
不知道标题怎么起
苹果爸爸给出intel的完整的taggerd pointers示例
低位设置为1 表示这是一个taggerd pointer
,真正的指针这个位置必须为0.接下来的3位是标签号,这表示了taggerd pointer的类型。
例如3它表示是一个NSNumber,6表示是一个NSDate,最多可以表示8中类型。剩下的位是有效负载,这是特定类型可以随意使用的数据。
现在tag=7 有一个特殊情况,他表示一个扩展标签,扩展标签使用接下来的8位来编码类型,这允许多出256个标签类型,代价是减少了有效负载。这也使得能够表达更多的类型标签。
intel的targged pointers苹果爸爸说swift开发人员可以创建自己的tagged pointer
类型。
具有关联值的枚举,那就是一个类似于tagged pointer
的类。
swift运行时将枚举判别器存储在关联值有效负载的备用位中,
而且swift对值类型的使用实际上使得tagged pointer
变的没那么重要了,因为值不再需要完全是指针大小。
例如Swift UUID类型可以使两个字并保持内联,而不是分配一个单独的对象,因为它不适合放在一个指针里面。
在arm64位中,把最高位设置为1用来表示tagged pointer
,紧接着的3位作为tag,余下部分作为有效负载。
为什么在arm64中使用最高位为1而不像Intel一样使用最低为呢?
实际上是对objc_msgsend
的一个优化我们希望objc_msgsend
中最常见的路径可以尽可能的快,而最常见的路径就是一个普通指针。
我们有两种不太常见的情况,tagged pointer
和 nil
。实时证明,当使用最高位时,可以通过一次比较对这两个值进行检查。
相比于分开检查nil
和tagged pointer
,这样就可以给msgsend
中的常见情况节省了一个分支条件。
接下来的8位用作扩展标签,然后剩下的作为有效负载。单这是iOS13以前所使用的格式。
ios13及以前
iOS14之后 把tag移到最后3位,最高位任然是1 因为它对msgsend的优惠效果依旧很明显,如果正在使用扩展为,它会占据标签位后的高8位。
iOS14以后的tagged pointer
为什么要这么做呢?
来看看普通指针,我们现有的工具,比如动态链接会忽略指针的前8位。这是由于Top Byte Ignore
的ARM特性。
把扩展标签放在Top Byte Ignore
位。对于一个对齐指针,底部3位总是0,那么为它添加7以将低位设置为1。7表示这是一个扩展标签。这意味着我们实际上可将上面的指针放入一个扩展标签指针有效负载中。
结果就是一个tagged pointer
以及其有效负载中包含一个正常指针
为什么这样很有用呢?
它开启了tagged pointer
的能力,引用二进制文件中的常量数据的能力。例如字符串或其他数据结构,否则它们将不得不占用dirty memory
。
这些变化会在iOS14发布开始使用。并且相关的直接访问的代码也会失效,苹果爸爸这里再一次强调如果使用APIs就不会出现问题,要规范要规范要规范。
正确的使用API进行类型检查。
CF类型这样也能。
正确的使用API才能在新旧tagged pointer同时兼容
好了 终于写完了 对照视频一边看一边敲字 人快虚脱了 先来一杯82年的冰阔落压压惊