大多数浏览器和
Developer App 均支持流媒体播放。
-
Objective-C 运行时的改进
随我们一起潜入 Objective-C 与 Swift 类背后低级编程语言的微观世界吧。了解内部数据结构、方法列表及指针标记的最新改进及其提升性能与降低内存消耗的原理。我们将会展示如何辨认和修复依赖内部细节的代码崩溃,以及怎样在运行时出现变更时保持代码不受影响。
资源
相关视频
WWDC20
-
下载
(你好 WWDC 2020) 你好 欢迎来到 WWDC
(Objective-C 运行时的改进) 大家好 我是 Ben 来自语言与运行时团队 我将与你分享 我们今年在 iOS 、mac OS 上的 Objective-C 运行时做出的更改 这些更改极大地提高了内存的使用率 本次讲座与大多数讲座有一点点不同 你不需要更改你的任何代码 我不打算讲述今年要学习的任何新 API 也不打算讲述需要取消的弃用警告 运气好的话 你什么都不用做 你的 app 也会变得更快 为什么我们要告诉你这些改进呢? 嗯 部分原因是我们觉得 这些改进很酷、很有趣 但也因为这些在运行时的改进 是唯一可能的 因为我们的内部数据结构 隐藏在 API 后面 当 app 直接访问这些数据结构时 事情会变得有点棘手 在本次讲座中 你会学到一些注意事项 以防止他人在你的代码库上工作时 显然不是你 访问他们不应该访问的东西 在本视频中 我们将介绍三个变化 首先是 数据结构的变化 Objective-C 运行时会使用它们来追踪类
然后 我们会谈谈 Objective-C 方法列表的变化 最后 我们将谈谈 tagged pointer 格式的变化
那么 让我们先来谈谈 类的运行时数据的变化 在磁盘上 在你的 app 二进制文件中 类是这样的
首先 这有个类对象本身 它包含了最常被访问的信息 指向元类、超类和方法缓存的指针
它还有一个指向更多数据的指针 存储额外信息的地方叫做 class_ro_t “Ro”代表只读 它包括像类名称 方法、协议和实例变量的信息
Swift 类和 Objective-C 类 共享这一基础结构 所以每个 Swift 类也有这些数据结构 当类第一次从磁盘加载到内存中时 它们一开始也是这样的 但是一经使用 它们就会发生变化 现在 在了解这些变化之前 我们有必要了解下 clean memory 和 dirty memory 的区别
clean memory 是指 加载后不会发生更改的内存 class_ro_t 就属于 clean memory 因为它是只读的
dirty memory 是指 在进程运行时会发生更改的内存
类结构一经使用就会变成 dirty memory 因为运行时会向它写入新的数据 例如 创建一个新的方法缓存 并从类中指向它
dirty memory 比 clean memory 要昂贵得多 只要进程在运行 它就必须一直存在
另一方面 clean memory 可以进行移除 从而节省更多的内存空间 因为如果你需要 clean memory 系统可以从磁盘中重新加载
macOS 可以选择换出 dirty memory 但因为 iOS 不使用 swap 所以 dirty memory 在 iOS 中代价很大
dirty memory 是这个类数据 被分成两部分的原因
可以保持清洁的数据越多越好 通过分离出那些永远不会更改的数据 可以把大部分的类数据 存储为 clean memory
虽然这些数据足以让我们开始 但运行时需要追踪每个类的更多信息 所以 当一个类首次被使用 运行时会为它分配额外的存储容量
这个运行时分配的存储容量是 class_rw_t 用于读取-编写数据 在这个数据结构中 我们存储了 只有在运行时才会生成的新信息 例如 所有的类都会链接成一个树状结构 这是通过使用 First Subclass 和 Next Sibling Class 指针实现的 这允许运行时遍历当前使用的所有类 这对于使方法缓存无效非常有用
但为什么方法和属性也在只读数据中时 我们这里还要有方法和属性呢? 嗯 因为它们可以在运行时进行更改 当 category 被加载时 它可以向类中添加新的方法 而且程序员可以 使用运行时 API 动态地添加它们
因为 class_ro_t 是只读的 所以我们需要在 class_rw_t 中 追踪这些东西
现在 结果是这样做会占用相当多的内存 在任何给定的设备中都有许多类在使用 我们在 iPhone 上的整个系统中测量了 大约 30 兆字节这些 class_rw_t 结构 那么我们如何缩小这些结构呢? 记住 我们在读取-编写部分需要这些东西 因为它们可以在运行时进行更改
但是通过检查实际设备上的使用情况 我们发现 大约只有 10% 的类 真正地更改了它们的方法
而且只有 Swift 类会使用 这个 demangled name 字段 并且 Swift 类并不需要这一字段 除非有东西询问 它们的 Objective-C 名称时才需要
所以 我们可以拆掉那些平时不用的部分 这将 class_rw_t 的大小减少了一半
对于那些确实需要额外信息的类 我们可以分配这些扩展记录中的一个 并把它滑到类中供其使用
大约 90% 的类从来不需要这些扩展数据 这在系统范围内可节省大约 14MB 的内存 这些内存现在可用于更有效的用途 比如存储你的 app 的数据 因此 实际上你可以在你的 Mac 上 看到这一变化带来的影响 这只需要在终端机上运行一些简单的命令 现在让我们一起来看一下 在此我要进入我的 MacBook 的终端 我要运行一个命令 它在任何 Mac 上都可用 叫做 heap 它还允许你检查 正在运行的进程所使用的堆内存 我将在 Mac 中的 Mail app 上运行它 现在 如果我运行该命令 它会输出数千行 显示通过邮件进行的每个堆分配 所以 我只是要 grep 它为 我们今天一直在谈论的类型 class_rw_t 类型 我还需要查询标头
从返回的结果中 可以看到 我们在邮件 app 中使用了 大约 9000 个这样的 class_rw_t 类型 但其中只有大约十分之一 900 多一点 实际上需要使用这一扩展信息 所以 我们可以很容易地计算出 通过这个改变所节省的内存
这是大小减半的类型 所以 如果我们从这个数字中减去 我们必须分配给扩展类型的内存量 我们可以看到 我们节省了 大约一兆字节数据的四分之一 这还只是对于邮件 app 而言 如果我们在系统范围内进行扩展 对 dirty memory 而言 这是真正的节省内存
现在 很多从类中获取数据的代码 必须同时处理那些 有扩展数据和没有扩展数据的类 当然运行时会为你处理这一切 并且从外部看 一切都像往常一样工作 只是使用更少的内存 之所以会这样 是因为 读取这些结构的代码 都在运行时内并且还会同时进行更新 坚持使用这些 API 真的很重要 因为任何试图直接访问 这些数据结构的代码 都将在今年的 OS 版本中停止工作 因为东西已经发生了变化 而且该代码不知道新的布局 我们看到 一些真实的代码 由于这些变化而崩溃 除了你自己的代码之外 还要注意那些外部依赖性 你可能正把他们带入到你的 app 中 它们可能会在你没有意识到的情况下 挖掘这些数据结构
这些结构中的所有信息 都可通过官方 API 获得 有一些函数 如 class_getName 和 class_getSuperclass 当你使用这些 API 访问信息时 你知道 无论我们在后台进行什么更改 他们都将继续工作 所有的 API 都可以 在 Objective-C 运行时说明文档中找到 而这个文档在 developer.apple.com 中 接下来 让我们更深入地了解一下 这些类的数据结构 并看看另一个变化 相对方法列表
每个类都附带一个方法列表 当你在类上编写新方法时 它就会被添加到列表中 运行时使用这些列表来解析消息发送
每个方法都包含三个信息
首先是方法的名称 或者说选择器 选择器是字符串 但它们具有唯一性 所以它们可以使用指针相等来进行比较
接下来是方法的类型编码 这是一个表示参数和返回类型的字符串 它不是用来发送消息的 但它是运行时 introspection 和消息 forwarding 所必需的东西
最后 还有一个指向方法实现的指针 方法的实际代码 当你编写一个方法时 它会被编译成一个 C 函数 其中包含你的实施 然后方法列表中的 entry 会指向该函数 让我们来看一个具体的方法 我选择了 init 方法 它包含的条目有方法名称、类型和实施
方法列表中的每一条数据都是一个指针 在我们的 64 位系统中 这意味着每个方法表条目占用 24 个字节
现在这个是 clean memory 但它并不是免费的 它还是必须从磁盘中加载 并且使用时会占用内存
现在这是一个进程中内存的放大视图 注意这不是按比例放大的 这有一个很大的地址空间 它需要 64 位来寻址 在这个地址空间内 它划分成了几个部分 分别为栈、堆 可执行文件和库或二进制图像 而这些都加载到了进程中 这里以蓝色表示的
让我们放大并查看其中的一个二进制图像
这里我们显示了三个方法表条目 它们指向其二进制文件中的位置 这向我们展示了另一个代价 二进制图像可以加载到内存中的任何地方 这取决于动态链接器决定把它放在哪里
这意味着 链接器需要 将指针解析到图像中 并在加载时将其修正为 指向其在内存中的实际位置 而这也是有代价的 现在 请注意 一个来自二进制文件的类方法条目 永远只指向该二进制文件内的方法实现 我们不可能使一个方法的元数据 存在于一个二进制文件中 而实现它的代码在另一个二进制文件中
这意味着 方法列表条目 实际上并不需要能够 引用整个 64 位地址空间
它们只需要能够引用自己二进制中的函数 而且这些函数总是在附近
因此 无需使用绝对的 64 位地址 它们可使用二进制中的 32 位的相对偏移 这也是我们今年做出的一个更改 这有几大好处 首先 偏移量始终是相同的 不管 image 在哪里加载到内存中 所以他们从磁盘加载后不需要进行修正 而由于它们不需要进行修正 所以它们可以存储在真正的只读内存中 这样更安全 当然 32 位的偏移 意味着我们已经将 64 位平台上所需的内存量减少了一半 在一台典型 iPhone 中的系统范围内 我们测量了约 80MB 的这些方法 因为它们的尺寸减半 所以我们节省了 40MB 的内存 这样你的 app 就有更多的内存 从而可以让你的用户体验更好
但是 swizzling 呢? 二进制中的方法列表 现在不能引用完整的地址空间 但如果你 swizzle 一个方法 它就可以在任何地方实施 而且 我们刚刚说过 我们希望保持这些方法列表为只读
为了处理该问题 我们提供了一个全局表 这个全局表将方法 映射到它们被 swizzle 的实施上
swizzling 并不常见 实际上绝大多数方法从未被 swizzle 过 所以这个表最终不会变得很大 更好地是 这个表会很紧凑 内存每次都是按页面来“弄脏”的 使用旧式的方法列表 swizzle 一个方法 会“弄脏”它所在的整个页面 一次 swizzle 就会导致产生 大量千字节的 dirty memory
有了这个全局表 我们只需 为一个额外的表的条目付出代价 和往常一样 你看不见这些变化 一切都会像以前一样照常进行 这些相对方法列表 在新的 OS 版本上是受支持的 并会在今年晚些时候推出 当你使用相应的最小部署目标进行构建时 工具会自动在你的二进制文件中 生成相对方法列表
如果你需要针对旧 OS 版本的方法列表 也不用担心 Xcode 也会生成旧式的方法列表格式 这仍然是完全受支持的 你仍然可以从使用新的相对方法列表 构建的 OS 本身中受益 而且系统可以同时在同一 app 中 使用两种格式
不过如果你能针对 今年的 OS 版本进行构建 你的二进制文件会变小 并且你所使用的内存也会减少
总的来说 这在 Objective-C 或 Swift 中是一个不错的提示 最小的部署目标 这并不仅是关于你可以使用哪些 SDK API 如果 Xcode 知道它不需要 支持旧的 OS 版本 它通常可以发布更好的优化代码或数据
我们理解你们中的许多人 需要支持更旧的 OS 版本 但这就是为什么 无论何时你都可以增加部署目标 是一个好主意
现在 有一件事需要注意 那就是使用一个比你打算使用的 更新的部署目标进行构建
Xcode 一般会阻止这种情况的发生 但也有可能漏掉 特别是如果你在 其他地方构建自己的库或框架 然后把它们带了进来
当在旧的 OS 中运行时 旧的运行时会看到这些相对方法 但它对此一无所知 所以它会尝试像 旧式的基于指针的方法一样来解释它们
这意味着 它将尝试把一对 32 位的字段 作为 64 位的指针来读取
这样的结果是 两个整数作为一个指针粘在一起 这是一个无意义的值 它在实际使用时肯定会崩溃
你可以通过运行时读取方法信息时的崩溃 识别出何时发生了这种情况 在这种情况下 坏指针看起来 就像两个 32 位值平滑在一起 正如本例中所示
如果你运行的代码通过这些结构进行挖掘 以读取值 该代码会出现和这些旧运行时一样的问题 当用户升级设备时 app 就会崩溃
所以不要这样做 请使用 API 不管底层的东西怎么变 那些 API 都能继续工作 例如 有一些函数 给定其一个方法指针 就会返回其字段的值
我们再来探讨一下今年的另一个变化 arm64 上 tagged pointer 格式的变化
首先 我们需要知道 什么是 tagged pointer 我们在此将探索底层真正的实现 但不用担心 就像我们谈论过的所有事情一样 你不需要知道这些 它只是很有趣 也许还有助于你更好地 了解你的内存使用情况 让我们先来看看普通对象指针的结构 通常 当我们看到这些指针时 它们被打印成这些大的十六进制数字 我们在前面看到过这些数字
让我们把它分解成二进制表示法 我们有 64 位 然而 我们并没有真正地使用到所有这些位
我们只在一个真正的对象指针中 使用了中间的这些位
由于对齐要求的存在 低位始终为 0 对象必须总是位于 指针大小倍数的一个地址中
由于地址空间有限 所以高位始终为 0 我们实际上不会用到 2^64
这些高位和低位总是 0 所以 让我们从这些始终为 0 的位中 选择一个位并把它设置为 1
这可以让我们立即知道 这不是一个真正的对象指针 然后我们可以给其他所有位 赋予一些其他的意义 我们称这种指针为 tagged pointer
例如 我们可以在其他位中塞入一个数值 只要我们想教 NSNumber 如何读取这些位 并让运行时适当地处理 tagged pointer 系统的其他部分就可以 把这些东西当做对象指针来处理 并且永远不会知道其中的区别
这样可以节省我们为每一种类似情况 分配一个小数字对象的代价 这是一个重大的改进
顺便说一下 这些值实际上是通过 与进程启动时初始化的随机值相结合 而被混淆的
这一安全措施使得 很难伪造 tagged pointer 在接下来的讨论中 我们将忽略这一点 因为它只是在顶部增加了一层 只是要注意 如果你真的试图在内存中查看这些值 它们会被打乱 所以这就是 Intel 上 tagged pointer 的完整格式
我们把低位设置为 1 表示这是一个 tagged pointer 正如我们所讨论的 对于一个真正的指针 这个位必须始终为 0 所以这让我们可以把它们区分开来
接下来的 3 位是标签号 这表示 tagged pointer 的类型 例如 3 表示它是一个 NSNumber 6 表示它是一个 NSDate
由于我们有 3 个标签位 所以有 8 种可能的标签类型
剩下的位是有效负载 这是特定类型可以随意使用的数据
对于标记的 NSNumber 这是实际的数字
现在标签 7 有一个特殊情况 它表示一个扩展标签 扩展标签使用接下来的 8 位来编码类型 这允许多出 256 个标签类型 但是代价是减少有效负载
这使得我们可以将 tagged pointer 用于更多的类型 只要它们可以将其数据装入更小的空间
这可用于一些东西 如用户界面 colors 或 NSIndexSets
现在 如果这对你来说非常方便 你可能会感到失望 因为只有运行时维护者 即 Apple 可以添加 tagged pointer 类型 但如果你是一个 Swift 程序员 你会感到很高兴 可以创建自己的 tagged pointer 类型 如果你曾经使用过一个具有关联值的枚举 那是一个类似于 tagged pointer 的类 Swift 运行时将枚举判别器存储在 关联值有效负载的备用位中 而且 Swift 对值类型的使用 实际上使得 tagged pointer 变得没那么重要了 因为值不再需要完全是指针大小 例如 Swift UUID 类型 可以是两个字并保持内联 而不是分配一个单独的对象 因为它不适合在一个指针里面 这就是 intel 上的 tagged pointer 让我们来看看 ARM
在 arm64 上 这些是反过来的 最高位设置为 1 而不是最低位 用来表示一个 tagged pointer
然后在接下来的 3 个位中出现标签号 而有效负载使用剩余的位 为什么我们在 ARM 上 使用顶部位来表示 tagged pointer 而不是像在 intel 上那样 使用底部位来表示呢? 嗯 这实际上是对 objc_msgSend 的一个小优化 我们希望 msgSend 中最常见的路径 可以尽可能地快 而最常见的路径是一个普通的指针 我们有两种不太常见的情况
tagged pointer 和 nil
事实证明 当我们使用最高位时 我们可以通过一次比较对这两个进行检查 相比于分开检查 nil 和 tagged pointer 这就为 msgSend 中的 常见情况节省了一个条件分支
和 Intel 中一样 对于标签 7 我们有一个特殊情况 接下来的 8 位被用作扩展标签 然后剩下的位用于有效负载 或者说 这其实是 iOS 13 使用的旧格式 在今年的版本中 我们做了一些改动 我们将标签位保持在最高位 因为 msgSend 的优化还是非常有用的 标签号现在移到了最下面的 3 个位 如果正在使用扩展标签 那么它会占据标签位后的高 8 位
为什么我们要这样做呢? 好吧 让我们再来看看正常指针
我们现有的工具 比如动态链接 会忽略指针的前 8 位 这是由于 一个名为 Top Byte Ignore 的 ARM 特性
而我们会把扩展标签 放在 Top Byte Ignore 位
对于一个对齐指针 底部 3 个位总是 0 但我们可以改变这一点 只需要通过在指针上添加一个小数字 我们将添加 7 以将低位设置为 1 请记住 7 表示这是一个扩展标签
这意味着我们实际上可将上面的这个指针 放入一个扩展标签指针有效负载中 这个结果是 一个 tagged pointer 以及 其有效负载中包含一个正常指针 为什么这很有用呢? 好的 它开启了 tagged pointer 的能力 引用二进制文件中的常量数据的能力 例如字符串或其他数据结构 否则它们将不得不占用 dirty memory 当然 现在这些变化意味着 今年晚些时候 iOS 14 发布时 直接访问这些位的代码将会失效 在过去 像这样的位检查是可以进行的 但在未来的 OS上 它会给你错误的答案 然后 你的 app 会开始 莫名其妙地破坏用户数据 所以 不要使用那些依赖于 我们所谈到的任何东西的代码 相反 你大概可以猜到我要说什么 也就是要使用 API 像 isKindOfClass 这样的类型检查 它们在旧的 tagged pointer 格式上工作 在新的 tagged pointer 格式上 它们也将继续工作 所有的 NSString 或 NSNumber 方法 都能继续工作 这些 tagged pointer 中的所有信息 都可以通过标准的 API 来检索
值得注意的是 这也适用于 CF 类型 我们不想隐藏任何东西 也绝对不想破坏任何人的 App 当这些细节没有公开的时候 这仅仅是因为我们需要保持灵活性 来进行此类更改 并且只要你的 app 不依赖这些内部细节 它们就可以正常工作
那么 让我们来总结一下 在本次讲座中 我们看到了一些幕后的改进 这些改进减少了运行时的代价 将更多的内存留给你和你的用户
你不需要做任何事情就能获得这些改进 可能除了需要考虑提高你的部署目标外
要帮助我们每年做出这些改进 只要遵循一个简单的规则 不要直接读取内部的位 请使用 API 感谢观看 请享受设备更快的使用体验吧
-
-
5:37 - Use the heap command to calculate memory savings
heap Mail | egrep 'class_rw|COUNT'
-
7:35 - Use the APIs
class_getName class_getSuperclass class_copyMethodList
-
14:38 - Use the APIs
method_getName method_getTypeEncoding method_getImplementation
-
21:52 - Use the APIs
if ([obj isKindOfClass:[NSString class]]) { // a string } NSUInteger length = [obj length]; if (CFGetTypeID(obj) == CFStringGetTypeID()) { // a string } CFIndex length = CFStringGetLength(obj);
-
-
正在查找特定内容?在上方输入一个主题,就能直接跳转到相应的精彩内容。
提交你查询的内容时出现错误。请检查互联网连接,然后再试一次。